Compare commits
9
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
54b59965ed | ||
|
|
6d45f3ed0d | ||
|
|
4ba6471070 | ||
|
|
b7608d0a9a | ||
|
|
5d7906ae0d | ||
|
|
428e87674e | ||
|
|
1cbd215286 | ||
|
|
c4ea7a855e | ||
|
|
7f7a91db0d |
@@ -11,14 +11,61 @@ single Go service with the React PWA embedded in the binary.
|
||||
> `/health`, embedded PWA skeleton, Makefile, Dockerfile, CI). The planned
|
||||
> application is described in [docs/ARCHITECTURE.md](docs/ARCHITECTURE.md).
|
||||
|
||||
## Running it locally
|
||||
## Quick start
|
||||
|
||||
The developer setup (prerequisites, Authentik and Google clients, the
|
||||
`MEALQUEST_*` environment, `make dev` and the optional SearXNG sidecar) is
|
||||
runbook information and lives in CTXD: project `mealquest`, file
|
||||
`RUNBOOKS.MD`, section "Local development". Configuration is read only from
|
||||
the environment, and the service refuses to start, naming every missing or
|
||||
invalid variable.
|
||||
Requirements: Go 1.25 and Node.js `^22.22.2 || ^24.15.0 || >=26.0.0` — that
|
||||
is, 22.22.2 or later on the 22 line, 24.15.0 or later on the 24 line, or 26.0.0
|
||||
or later. Node.js 23, 24.0–24.14 and 25 are not supported: this is the
|
||||
`engines` range of jsdom in the committed lockfile, the strictest of all the
|
||||
web dependencies.
|
||||
|
||||
```sh
|
||||
(cd web && npm ci)
|
||||
mkdir -p ~/.mealquest-dev # outside the repository: it holds a secret
|
||||
chmod 700 ~/.mealquest-dev
|
||||
head -c 32 /dev/urandom | base64 > ~/.mealquest-dev/secret_key
|
||||
chmod 600 ~/.mealquest-dev/secret_key
|
||||
export MEALQUEST_HTTP_ADDR=:8080
|
||||
export MEALQUEST_PUBLIC_URL=http://localhost:5173
|
||||
export MEALQUEST_DATA_DIR="$HOME/.mealquest-dev"
|
||||
export MEALQUEST_SHUTDOWN_TIMEOUT=10s
|
||||
export MEALQUEST_SECRET_KEY_FILE="$HOME/.mealquest-dev/secret_key"
|
||||
# Authentik OAuth2/OpenID provider for MealQuest (redirect URI:
|
||||
# $MEALQUEST_PUBLIC_URL/auth/callback). Save its client secret to the file.
|
||||
export MEALQUEST_OIDC_ISSUER=https://auth.example.com/application/o/mealquest/
|
||||
export MEALQUEST_OIDC_CLIENT_ID=mealquest
|
||||
export MEALQUEST_OIDC_CLIENT_SECRET_FILE="$HOME/.mealquest-dev/oidc_client_secret"
|
||||
export MEALQUEST_OIDC_ALLOWED_GROUPS=mealquest-admin
|
||||
# Optional: sessions end after MEALQUEST_SESSION_IDLE unused (default 720h)
|
||||
# and always MEALQUEST_SESSION_MAX after sign-in (default 4320h).
|
||||
# export MEALQUEST_SESSION_IDLE=720h
|
||||
# export MEALQUEST_SESSION_MAX=4320h
|
||||
# Optional: comma-separated IPs/CIDRs of reverse proxies whose
|
||||
# X-Forwarded-For/-Proto headers are honoured (production: Traefik). Unset
|
||||
# trusts no peer, which is right for local development.
|
||||
# export MEALQUEST_TRUSTED_PROXY=10.60.1.33
|
||||
# Optional: a SearXNG instance the Settings screen suggests as the recipe
|
||||
# search URL until one is saved.
|
||||
# export MEALQUEST_SEARXNG_URL=http://localhost:8888
|
||||
export MEALQUEST_DEFAULT_TZ=America/New_York
|
||||
make dev
|
||||
```
|
||||
|
||||
`make dev` runs the Go server on `:8080` and the Vite dev server on `:5173`
|
||||
(which proxies `/api`, `/auth` and `/media` to `:8080`). Open
|
||||
http://localhost:5173. Configuration is environment-only and fails fast:
|
||||
every variable above is required except the commented-out session
|
||||
lifetimes (when set they must be positive, with MAX at least IDLE),
|
||||
trusted proxy list (every entry must be an IP or CIDR), the SearXNG URL
|
||||
(an absolute `http://` or `https://` URL without credentials, a query or a
|
||||
fragment) and `MEALQUEST_DEFAULT_TZ`, and
|
||||
secrets are read only from files named by `*_FILE` variables. `MEALQUEST_PUBLIC_URL`, `MEALQUEST_OIDC_ISSUER` and the
|
||||
endpoints the issuer advertises must be `https://`; plain `http://` is
|
||||
accepted only for `localhost`, `127.0.0.1` and `[::1]`.
|
||||
`MEALQUEST_DEFAULT_TZ` is the IANA zone the household settings are created
|
||||
with on first boot: until those settings exist, boot fails unless it is a
|
||||
valid IANA zone. Later boots ignore it, set or not (change the zone in
|
||||
household settings instead).
|
||||
|
||||
## Common commands
|
||||
|
||||
@@ -31,8 +78,7 @@ invalid variable.
|
||||
|
||||
## Where to read next
|
||||
|
||||
- [AGENTS.md](AGENTS.md) — house rules, approved dependencies, the quality
|
||||
floor every change must pass, repository layout, conventions.
|
||||
- [AGENTS.md](AGENTS.md) — house rules, approved dependencies, conventions.
|
||||
- [docs/ARCHITECTURE.md](docs/ARCHITECTURE.md) — architecture of record.
|
||||
- [design/MealQuest Requirements.md](design/MealQuest%20Requirements.md) — product requirements.
|
||||
- [design/MealQuest UI Designs/design_handoff_mealquest/README.md](design/MealQuest%20UI%20Designs/design_handoff_mealquest/README.md) — UI handoff.
|
||||
|
||||
@@ -19,7 +19,6 @@ import (
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/household"
|
||||
"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/internal/vapid"
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/migrations"
|
||||
)
|
||||
|
||||
@@ -60,17 +59,6 @@ func loadConfig(t *testing.T, keyFile string) config.Config {
|
||||
t.Setenv(config.EnvOIDCAllowedGroups, "mealquest-admin")
|
||||
t.Setenv(config.EnvGoogleClientID, "mealquest.apps.googleusercontent.com")
|
||||
t.Setenv(config.EnvGoogleClientSecretFile, googleSecretPath)
|
||||
pair, err := vapid.Generate()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
vapidKeyPath := filepath.Join(dir, "vapid_private_key")
|
||||
if err := os.WriteFile(vapidKeyPath, []byte(pair.PrivateKey+"\n"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Setenv(config.EnvVAPIDPublicKey, pair.PublicKey)
|
||||
t.Setenv(config.EnvVAPIDPrivateKeyFile, vapidKeyPath)
|
||||
t.Setenv(config.EnvVAPIDSubject, "mailto:owner@example.com")
|
||||
// MEALQUEST_DEFAULT_TZ is left unset; t.Setenv restores any outer value.
|
||||
t.Setenv(config.EnvDefaultTZ, "")
|
||||
if err := os.Unsetenv(config.EnvDefaultTZ); err != nil {
|
||||
|
||||
+1
-7
@@ -25,7 +25,6 @@ import (
|
||||
"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/push"
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/recipes"
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/secretbox"
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/server"
|
||||
@@ -139,11 +138,6 @@ func run(cfg config.Config, logger *slog.Logger) error {
|
||||
return err
|
||||
}
|
||||
|
||||
pushSvc, err := push.NewService(db, cfg.VAPID.PublicKey)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
sessions, err := auth.NewSessionManager(db, cfg.SessionIdle, cfg.SessionMax)
|
||||
if err != nil {
|
||||
return err
|
||||
@@ -160,7 +154,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, shopSvc, googleSvc, pushSvc, runner, photos, frontend.Assets())
|
||||
router, err := server.NewRouter(logger, version, cfg.TrustedProxy, authFlow, sessions, settings, search, ai, dinnerSvc, suggestSvc, planSvc, shopSvc, googleSvc, runner, photos, frontend.Assets())
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -1,39 +0,0 @@
|
||||
// Command vapidgen prints a new VAPID key pair for Web Push. The public key
|
||||
// line goes into deploy/.env as is; the private key line goes, alone, into
|
||||
// the secret file MEALQUEST_VAPID_PRIVATE_KEY_FILE names. Both keys are
|
||||
// unpadded base64url P-256 keys, the encoding config.Load validates.
|
||||
//
|
||||
// go run ./cmd/vapidgen
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/config"
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/vapid"
|
||||
)
|
||||
|
||||
func main() {
|
||||
if len(os.Args) > 1 {
|
||||
fmt.Fprintln(os.Stderr, "usage: vapidgen (takes no arguments)")
|
||||
os.Exit(2)
|
||||
}
|
||||
if err := run(os.Stdout); err != nil {
|
||||
fmt.Fprintln(os.Stderr, err)
|
||||
os.Exit(1)
|
||||
}
|
||||
}
|
||||
|
||||
// run writes a new key pair to w in four lines: a comment and the public
|
||||
// key's environment line, then a comment and the private key alone.
|
||||
func run(w io.Writer) error {
|
||||
pair, err := vapid.Generate()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
_, err = fmt.Fprintf(w, "# Environment (deploy/.env):\n%s=%s\n# Private key: this next line alone is the file %s names.\n%s\n",
|
||||
config.EnvVAPIDPublicKey, pair.PublicKey, config.EnvVAPIDPrivateKeyFile, pair.PrivateKey)
|
||||
return err
|
||||
}
|
||||
@@ -1,46 +0,0 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/config"
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/vapid"
|
||||
)
|
||||
|
||||
// TestRunPrintsAMatchingPair checks the output shape and that the printed
|
||||
// private key line is a key config.Load accepts for the printed public key.
|
||||
func TestRunPrintsAMatchingPair(t *testing.T) {
|
||||
t.Parallel()
|
||||
var out strings.Builder
|
||||
if err := run(&out); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
lines := strings.Split(strings.TrimSuffix(out.String(), "\n"), "\n")
|
||||
if len(lines) != 4 || !strings.HasPrefix(lines[0], "# ") || !strings.HasPrefix(lines[2], "# ") {
|
||||
t.Fatalf("output = %q, want comment, public key, comment, private key", out.String())
|
||||
}
|
||||
public, ok := strings.CutPrefix(lines[1], config.EnvVAPIDPublicKey+"=")
|
||||
if !ok {
|
||||
t.Fatalf("public key line = %q, want %s=<key>", lines[1], config.EnvVAPIDPublicKey)
|
||||
}
|
||||
pub, err := vapid.ParsePublicKey(public)
|
||||
if err != nil {
|
||||
t.Fatalf("printed public key: %v", err)
|
||||
}
|
||||
priv, err := vapid.ParsePrivateKey(lines[3])
|
||||
if err != nil {
|
||||
t.Fatalf("printed private key: %v", err)
|
||||
}
|
||||
if err := vapid.CheckPair(pub, priv); err != nil {
|
||||
t.Error(err)
|
||||
}
|
||||
|
||||
var second strings.Builder
|
||||
if err := run(&second); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if second.String() == out.String() {
|
||||
t.Error("two runs printed the same key pair")
|
||||
}
|
||||
}
|
||||
+31
-32
@@ -1,16 +1,20 @@
|
||||
# MealQuest production environment: the template for deploy/.env (never
|
||||
# committed). The service fails fast: a missing or invalid required variable
|
||||
# stops it at boot with the variable named. Creating deploy/.env: CTXD
|
||||
# project mealquest, RUNBOOKS.MD "Stack and first deploy" > "3. Environment
|
||||
# file".
|
||||
# MealQuest production environment. Copy to deploy/.env (never committed)
|
||||
# and replace the placeholders. The service fails fast: a missing or invalid
|
||||
# required variable stops it at boot with the variable named.
|
||||
#
|
||||
# Secrets are never set here. They live in deploy/secrets/ (never committed),
|
||||
# one value per file, owned by the container user 10001:10001 with mode
|
||||
# 0600: secret_key, oidc_client_secret, google_client_secret and
|
||||
# vapid_private_key. SearXNG's
|
||||
# searxng.env is the exception: compose reads it itself, so it is owned by
|
||||
# the host account that runs compose, mode 0600. Creating them: CTXD project
|
||||
# mealquest, RUNBOOKS.MD "Stack and first deploy" > "4. Secrets".
|
||||
# one value per file, owned by the container user and readable only by it:
|
||||
#
|
||||
# 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
|
||||
# stays owned by you (mode 0600). Create it after the chown above:
|
||||
#
|
||||
# (umask 077; printf 'SEARXNG_SECRET=%s\n' "$(openssl rand -hex 32)" > deploy/secrets/searxng.env)
|
||||
#
|
||||
# deploy/compose.yaml mounts them at /run/secrets/<name> and points the
|
||||
# *_FILE variables at them.
|
||||
@@ -38,7 +42,8 @@ MEALQUEST_TRUSTED_PROXY=10.60.1.33
|
||||
|
||||
# IANA zone the household settings are created with on first boot. Required
|
||||
# until those settings exist; ignored afterwards (change the zone in
|
||||
# household settings instead).
|
||||
# household settings instead). Confirm the household's zone before the first
|
||||
# deploy.
|
||||
MEALQUEST_DEFAULT_TZ=America/New_York
|
||||
|
||||
# --- Authentik OIDC ----------------------------------------------------------
|
||||
@@ -52,25 +57,12 @@ MEALQUEST_OIDC_ALLOWED_GROUPS=mealquest-admin
|
||||
|
||||
# --- Google Calendar (Push to Calendar) ------------------------------------
|
||||
|
||||
# Client ID of the Google OAuth web client (CTXD project mealquest,
|
||||
# RUNBOOKS.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.
|
||||
# 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
|
||||
|
||||
# --- Web Push (VAPID) ------------------------------------------------------
|
||||
|
||||
# Required: the service will not start without the public key, the subject
|
||||
# and the private key file, or with a private key that does not belong to
|
||||
# the public key. `go run ./cmd/vapidgen` prints a new pair: its
|
||||
# MEALQUEST_VAPID_PUBLIC_KEY line goes here, its private key line alone
|
||||
# into deploy/secrets/vapid_private_key. Both are unpadded base64url P-256
|
||||
# keys. Key pair: CTXD project mealquest, RUNBOOKS.MD "VAPID keys (Web
|
||||
# Push)". Replacing the pair invalidates every browser's subscription.
|
||||
MEALQUEST_VAPID_PUBLIC_KEY=<base64url VAPID public key>
|
||||
# The push services' contact for this sender: mailto:<address> or https://.
|
||||
MEALQUEST_VAPID_SUBJECT=mailto:<owner address>
|
||||
|
||||
# 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
|
||||
@@ -86,10 +78,17 @@ MEALQUEST_SESSION_MAX=4320h
|
||||
# The Authentik provider's client secret.
|
||||
# MEALQUEST_GOOGLE_CLIENT_SECRET_FILE=/run/secrets/google_client_secret
|
||||
# The Google OAuth web client's secret.
|
||||
# MEALQUEST_VAPID_PRIVATE_KEY_FILE=/run/secrets/vapid_private_key
|
||||
# The VAPID private key printed by `go run ./cmd/vapidgen`.
|
||||
# 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
|
||||
# provider and base URL come from PATCH /api/v1/settings/recipe-search.
|
||||
# See CTXD project mealquest, RUNBOOKS.MD "Recipe search (SearXNG)".
|
||||
# See deploy/RUNBOOK.md "Recipe search (SearXNG)".
|
||||
|
||||
# --- Planned; not read by the service yet -------------------------------------
|
||||
#
|
||||
# Added to deploy/compose.yaml and this file by the features that use them.
|
||||
#
|
||||
# Web Push (Feature 19). Generate the key pair with cmd/vapidgen.
|
||||
# MEALQUEST_VAPID_PUBLIC_KEY=<base64url VAPID public key>
|
||||
# MEALQUEST_VAPID_PRIVATE_KEY_FILE=/run/secrets/vapid_private_key
|
||||
# MEALQUEST_VAPID_SUBJECT=mailto:<owner address>
|
||||
|
||||
+1417
File diff suppressed because it is too large
Load Diff
@@ -1,7 +1,8 @@
|
||||
# Development override layered on deploy/compose.yaml: publishes SearXNG
|
||||
# on the dev machine's loopback (127.0.0.1:8888) so it can be queried
|
||||
# directly. Using it: CTXD project mealquest, RUNBOOKS.MD "Local
|
||||
# development".
|
||||
# Development override: publishes SearXNG on the dev machine's loopback so
|
||||
# it can be queried directly, e.g.
|
||||
#
|
||||
# docker compose -f deploy/compose.yaml -f deploy/compose.dev.yaml up -d searxng
|
||||
# curl 'http://127.0.0.1:8888/search?q=chicken+tikka+masala+recipe&format=json'
|
||||
#
|
||||
# Loopback only, never a LAN address. Never used in production: the
|
||||
# systemd unit starts deploy/compose.yaml alone.
|
||||
|
||||
+12
-14
@@ -1,16 +1,18 @@
|
||||
# Production stack for MealQuest on 10.60.1.14 (D-018). Building, deploying
|
||||
# and operating it: CTXD project mealquest, RUNBOOKS.MD "Stack unit" and
|
||||
# "Routine deploy and rollback".
|
||||
# Production stack for MealQuest on 10.60.1.14 (D-018). Build on the host,
|
||||
# then start through systemd:
|
||||
#
|
||||
# docker compose -f deploy/compose.yaml build
|
||||
# sudo systemctl restart mealquest
|
||||
#
|
||||
# The services use restart: "no" on purpose: Docker must not start them
|
||||
# before the DOCKER-USER firewall is applied. deploy/systemd/mealquest.service
|
||||
# owns start and stop and requires mealquest-firewall.service first.
|
||||
#
|
||||
# Configuration comes from deploy/.env (template: deploy/.env.example) and
|
||||
# secrets from deploy/secrets/; neither is committed. Compose bind-mounts
|
||||
# file secrets with their host owner and mode, so each file in
|
||||
# deploy/secrets/ must be owned by the container user 10001:10001 with mode
|
||||
# 0600 (except searxng.env, read by compose itself; see the searxng service).
|
||||
# Configuration comes from deploy/.env (copy deploy/.env.example) and secrets
|
||||
# from deploy/secrets/; neither is committed. Compose bind-mounts file secrets
|
||||
# with their host owner and mode, so each file in deploy/secrets/ must be
|
||||
# owned by the container user 10001:10001 with mode 0600 (except
|
||||
# searxng.env, read by compose itself; see the searxng service).
|
||||
name: mealquest
|
||||
|
||||
services:
|
||||
@@ -31,13 +33,11 @@ services:
|
||||
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_VAPID_PRIVATE_KEY_FILE: /run/secrets/vapid_private_key
|
||||
MEALQUEST_SEARXNG_URL: http://searxng:8080
|
||||
secrets:
|
||||
- secret_key
|
||||
- oidc_client_secret
|
||||
- google_client_secret
|
||||
- vapid_private_key
|
||||
volumes:
|
||||
- mealquest-data:/data
|
||||
ports:
|
||||
@@ -65,8 +65,8 @@ services:
|
||||
# the app's own user (owner of the volume) with a read-only root
|
||||
# filesystem, no privilege escalation and no Linux capabilities.
|
||||
#
|
||||
# Restoring the database: CTXD project mealquest, RUNBOOKS.MD "Restore
|
||||
# the database".
|
||||
# Restore: follow "Restore the database" in deploy/RUNBOOK.md (stop the
|
||||
# stack, move the database with its -wal and -shm aside, restore, check).
|
||||
litestream:
|
||||
image: litestream/litestream:0.5.17
|
||||
user: "10001:10001"
|
||||
@@ -132,8 +132,6 @@ secrets:
|
||||
file: ./secrets/oidc_client_secret
|
||||
google_client_secret:
|
||||
file: ./secrets/google_client_secret
|
||||
vapid_private_key:
|
||||
file: ./secrets/vapid_private_key
|
||||
|
||||
volumes:
|
||||
mealquest-data:
|
||||
|
||||
@@ -5,9 +5,8 @@
|
||||
# are one-way: a failure here fails this unit visibly and never fails
|
||||
# docker.service.
|
||||
#
|
||||
# mealquest.service requires this unit and starts the stack only after it
|
||||
# has succeeded. Installing and operating both: CTXD project mealquest,
|
||||
# RUNBOOKS.MD "Host firewall" and "Stack unit".
|
||||
# Start MealQuest only after this unit has succeeded:
|
||||
# systemctl start mealquest-firewall && docker compose up -d
|
||||
[Unit]
|
||||
Description=Restrict MealQuest port 8090 to Traefik (DOCKER-USER)
|
||||
After=docker.service
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
# Runs the nightly MealQuest media backup (/usr/local/sbin/mealquest-media-backup).
|
||||
# Triggered by mealquest-media-backup.timer; output goes to the journal.
|
||||
# Installing it and running it by hand: CTXD project mealquest,
|
||||
# RUNBOOKS.MD "Media — nightly rsync".
|
||||
# Triggered by mealquest-media-backup.timer; run by hand with
|
||||
# systemctl start mealquest-media-backup
|
||||
# and read the result with
|
||||
# journalctl -u mealquest-media-backup
|
||||
#
|
||||
# The script runs as the app's user (10001, host user "mealquest") with no
|
||||
# capabilities. systemd, as root, only bind-mounts the data volume's root
|
||||
|
||||
@@ -15,9 +15,8 @@
|
||||
# swapping the path afterwards cannot redirect it. Symlinks inside the
|
||||
# directory are copied as links (-a), never followed.
|
||||
#
|
||||
# The media directory is created at deploy time (CTXD project mealquest,
|
||||
# RUNBOOKS.MD "Media — nightly rsync"). If it is missing the job fails; it
|
||||
# is never treated as "nothing to back up".
|
||||
# The media directory is created at deploy time (deploy/RUNBOOK.md). If it
|
||||
# is missing the job fails; it is never treated as "nothing to back up".
|
||||
#
|
||||
# rsync mirrors into .../mealquest-backups/media/ only: the target ends in
|
||||
# a slash, so --delete removes files from that directory and nothing
|
||||
|
||||
@@ -43,7 +43,6 @@ headers, so sign-in works whichever peer the request arrives from.
|
||||
| Path | Purpose |
|
||||
| --- | --- |
|
||||
| `cmd/server` | Entry point; wiring only. |
|
||||
| `cmd/vapidgen` | Prints a new VAPID key pair for Web Push. |
|
||||
| `internal/config` | Env-only `MEALQUEST_*` config; secrets from `*_FILE`. |
|
||||
| `internal/server` | HTTP server, router, middleware. |
|
||||
| `internal/frontend` | Embedded PWA, SPA fallback. |
|
||||
@@ -54,7 +53,6 @@ headers, so sign-in works whichever peer the request arrives from.
|
||||
| `internal/ai/llm`, `internal/ai/harness`, `internal/ai/skills` | AI provider adapters, skill harness, skills. |
|
||||
| `internal/suggest`, `internal/plan`, `internal/shopping`, `internal/ideas` | Feature services. |
|
||||
| `internal/gcal`, `internal/notify`, `internal/push`, `internal/chat` | Integrations. |
|
||||
| `internal/vapid` | VAPID key generation and validation. |
|
||||
| `internal/jobs`, `internal/scheduler` | Background jobs and schedules. |
|
||||
| `migrations/` | SQL migrations (no goose or golang-migrate). |
|
||||
| `web/` | React PWA. |
|
||||
@@ -659,31 +657,6 @@ Runs are recorded in `schedule_runs` and executed through `jobs`.
|
||||
- Delivery goes through `notification_outbox`.
|
||||
- Web Push uses VAPID (`webpush-go`). Google Chat uses the stored incoming
|
||||
webhook.
|
||||
- VAPID configuration (`MEALQUEST_VAPID_PUBLIC_KEY`,
|
||||
`MEALQUEST_VAPID_PRIVATE_KEY_FILE`, `MEALQUEST_VAPID_SUBJECT`) is required
|
||||
at boot, all three together. Both keys are unpadded base64url P-256 keys
|
||||
(`internal/vapid`): the public key the 65-byte uncompressed point, the
|
||||
private key the 32-byte scalar, read only from its file. Boot derives the
|
||||
public point from the private key and refuses a pair that does not
|
||||
match. The subject is `mailto:<address>` or an `https://` URL.
|
||||
`go run ./cmd/vapidgen` prints a new pair.
|
||||
- `internal/push` keeps the subscriptions, one row per endpoint, under the
|
||||
signed-in `/api/v1`:
|
||||
- `GET /api/v1/push/vapid-public-key` answers `{"public_key": "…"}`, the
|
||||
browser's `applicationServerKey`. The private key is never served.
|
||||
- `POST /api/v1/push/subscriptions` (CSRF-checked) takes
|
||||
`{"endpoint", "keys": {"p256dh", "auth"}, "user_agent"?}` — the
|
||||
endpoint an `https://` URL, the keys unpadded base64url as
|
||||
`PushSubscription.toJSON()` gives them (p256dh a P-256 point, auth 16
|
||||
bytes); unknown fields such as `expirationTime`, and nulls, are
|
||||
refused. It upserts by endpoint: an endpoint already stored keeps its
|
||||
id and `created_at`, has its keys and user agent replaced and its
|
||||
failure count cleared. It answers 200 with
|
||||
`{id, endpoint, user_agent, created_at, updated_at}`, never the keys.
|
||||
- `DELETE /api/v1/push/subscriptions` (CSRF-checked) takes
|
||||
`{"endpoint"}` and answers 204 whether or not it was stored.
|
||||
- An invalid body is 400 `invalid_request` (not a JSON object of the
|
||||
known fields) or `invalid_subscription` (a field fails validation).
|
||||
|
||||
## Google Calendar
|
||||
|
||||
|
||||
@@ -3,11 +3,9 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"crypto/ecdh"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net"
|
||||
"net/mail"
|
||||
"net/url"
|
||||
"os"
|
||||
"strconv"
|
||||
@@ -16,7 +14,6 @@ import (
|
||||
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/forwarded"
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/secureurl"
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/vapid"
|
||||
)
|
||||
|
||||
// Environment variable names.
|
||||
@@ -35,10 +32,6 @@ const (
|
||||
EnvGoogleClientID = "MEALQUEST_GOOGLE_CLIENT_ID"
|
||||
EnvGoogleClientSecretFile = "MEALQUEST_GOOGLE_CLIENT_SECRET_FILE"
|
||||
|
||||
EnvVAPIDPublicKey = "MEALQUEST_VAPID_PUBLIC_KEY"
|
||||
EnvVAPIDPrivateKeyFile = "MEALQUEST_VAPID_PRIVATE_KEY_FILE"
|
||||
EnvVAPIDSubject = "MEALQUEST_VAPID_SUBJECT"
|
||||
|
||||
EnvSessionIdle = "MEALQUEST_SESSION_IDLE"
|
||||
EnvSessionMax = "MEALQUEST_SESSION_MAX"
|
||||
|
||||
@@ -77,9 +70,6 @@ type Config struct {
|
||||
GoogleClientID string
|
||||
GoogleClientSecret Secret
|
||||
|
||||
// VAPID is the key pair and contact subject Web Push signs with.
|
||||
VAPID VAPID
|
||||
|
||||
// SessionIdle ends a session left unused this long.
|
||||
SessionIdle time.Duration
|
||||
// SessionMax ends every session this long after sign-in, used or not.
|
||||
@@ -103,20 +93,6 @@ type Config struct {
|
||||
SearXNGURL string
|
||||
}
|
||||
|
||||
// VAPID is the Web Push signing identity (RFC 8292). All three variables
|
||||
// are required together, and the private key must belong to the public key.
|
||||
type VAPID struct {
|
||||
// PublicKey is the unpadded base64url uncompressed P-256 point, as
|
||||
// browsers take it for applicationServerKey.
|
||||
PublicKey string
|
||||
// PrivateKey is the unpadded base64url 32-byte P-256 scalar, read from
|
||||
// the file MEALQUEST_VAPID_PRIVATE_KEY_FILE names.
|
||||
PrivateKey Secret
|
||||
// Subject is the push services' contact for this sender: a mailto: URI
|
||||
// or an https:// URL.
|
||||
Subject string
|
||||
}
|
||||
|
||||
// PublicOrigin returns the scheme and host of PublicURL, the only parts of it
|
||||
// that are safe to log.
|
||||
func (c Config) PublicOrigin() string {
|
||||
@@ -146,8 +122,6 @@ func load(lookup func(string) (string, bool)) (Config, error) {
|
||||
GoogleClientID: r.str(EnvGoogleClientID),
|
||||
GoogleClientSecret: r.secretFile(EnvGoogleClientSecretFile),
|
||||
|
||||
VAPID: r.vapid(),
|
||||
|
||||
SessionIdle: r.sessionDuration(EnvSessionIdle, DefaultSessionIdle),
|
||||
SessionMax: r.sessionDuration(EnvSessionMax, DefaultSessionMax),
|
||||
|
||||
@@ -413,61 +387,6 @@ func (r *reader) secretFile(name string) Secret {
|
||||
return Secret{value: value}
|
||||
}
|
||||
|
||||
// vapid reads the VAPID public key, private key file and subject, each
|
||||
// required, and checks the private key against the public key by deriving
|
||||
// its public point. A missing variable is reported by name; the private key
|
||||
// is never echoed.
|
||||
func (r *reader) vapid() VAPID {
|
||||
cfg := VAPID{
|
||||
PublicKey: r.str(EnvVAPIDPublicKey),
|
||||
PrivateKey: r.secretFile(EnvVAPIDPrivateKeyFile),
|
||||
Subject: r.vapidSubject(EnvVAPIDSubject),
|
||||
}
|
||||
var public *ecdh.PublicKey
|
||||
if cfg.PublicKey != "" {
|
||||
k, err := vapid.ParsePublicKey(cfg.PublicKey)
|
||||
if err != nil {
|
||||
r.fail(EnvVAPIDPublicKey, "invalid VAPID public key: %v", err)
|
||||
}
|
||||
public = k
|
||||
}
|
||||
var private *ecdh.PrivateKey
|
||||
if cfg.PrivateKey.Reveal() != "" {
|
||||
k, err := vapid.ParsePrivateKey(cfg.PrivateKey.Reveal())
|
||||
if err != nil {
|
||||
r.fail(EnvVAPIDPrivateKeyFile, "invalid VAPID private key in secret file: %v", err)
|
||||
}
|
||||
private = k
|
||||
}
|
||||
if public != nil && private != nil && vapid.CheckPair(public, private) != nil {
|
||||
r.fail(EnvVAPIDPrivateKeyFile, "VAPID private key does not match %s", EnvVAPIDPublicKey)
|
||||
}
|
||||
return cfg
|
||||
}
|
||||
|
||||
// vapidSubject reads a VAPID subject: mailto: followed by one email
|
||||
// address, or an absolute https:// URL.
|
||||
func (r *reader) vapidSubject(name string) string {
|
||||
v, ok := r.required(name)
|
||||
if !ok {
|
||||
return ""
|
||||
}
|
||||
if addr, found := strings.CutPrefix(v, "mailto:"); found {
|
||||
parsed, err := mail.ParseAddress(addr)
|
||||
if err != nil || parsed.Address != addr {
|
||||
r.fail(name, "invalid mailto: subject (want mailto:<email address>)")
|
||||
return ""
|
||||
}
|
||||
return v
|
||||
}
|
||||
u, err := url.Parse(v)
|
||||
if err != nil || u.Scheme != "https" || u.Host == "" || u.User != nil {
|
||||
r.fail(name, "invalid subject (want mailto:<email address> or an https:// URL)")
|
||||
return ""
|
||||
}
|
||||
return v
|
||||
}
|
||||
|
||||
func unwrapPathError(err error) error {
|
||||
var pe *os.PathError
|
||||
if errors.As(err, &pe) {
|
||||
|
||||
@@ -14,24 +14,8 @@ import (
|
||||
"time"
|
||||
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/forwarded"
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/vapid"
|
||||
)
|
||||
|
||||
// writeVAPIDKey writes a new VAPID key pair's private key to dir/name, as
|
||||
// cmd/vapidgen tells the operator to, and returns the pair.
|
||||
func writeVAPIDKey(t *testing.T, dir, name string) (vapid.KeyPair, string) {
|
||||
t.Helper()
|
||||
pair, err := vapid.Generate()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
path := filepath.Join(dir, name)
|
||||
if err := os.WriteFile(path, []byte(pair.PrivateKey+"\n"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return pair, path
|
||||
}
|
||||
|
||||
func TestLoad(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
@@ -53,16 +37,6 @@ func TestLoad(t *testing.T) {
|
||||
if err := os.WriteFile(emptyPath, []byte("\n"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
vapidPair, vapidKeyPath := writeVAPIDKey(t, dir, "vapid_private_key")
|
||||
otherPair, otherKeyPath := writeVAPIDKey(t, dir, "other_vapid_private_key")
|
||||
pemKeyPath := filepath.Join(dir, "vapid_private_key.pem")
|
||||
if err := os.WriteFile(pemKeyPath, []byte("-----BEGIN EC PRIVATE KEY-----\nMHcCAQEEIA==\n-----END EC PRIVATE KEY-----\n"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
paddedKeyPath := filepath.Join(dir, "padded_vapid_private_key")
|
||||
if err := os.WriteFile(paddedKeyPath, []byte(vapidPair.PrivateKey+"=\n"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
valid := func() map[string]string {
|
||||
return map[string]string{
|
||||
@@ -79,10 +53,6 @@ func TestLoad(t *testing.T) {
|
||||
|
||||
EnvGoogleClientID: "mealquest.apps.googleusercontent.com",
|
||||
EnvGoogleClientSecretFile: googleSecretPath,
|
||||
|
||||
EnvVAPIDPublicKey: vapidPair.PublicKey,
|
||||
EnvVAPIDPrivateKeyFile: vapidKeyPath,
|
||||
EnvVAPIDSubject: "mailto:owner@example.com",
|
||||
}
|
||||
}
|
||||
with := func(changes map[string]string, unset ...string) map[string]string {
|
||||
@@ -132,108 +102,8 @@ func TestLoad(t *testing.T) {
|
||||
EnvHTTPAddr, EnvPublicURL, EnvDataDir, EnvShutdownTimeout, EnvSecretKeyFile,
|
||||
EnvOIDCIssuer, EnvOIDCClientID, EnvOIDCClientSecretFile, EnvOIDCAllowedGroups,
|
||||
EnvGoogleClientID, EnvGoogleClientSecretFile,
|
||||
EnvVAPIDPublicKey, EnvVAPIDPrivateKeyFile, EnvVAPIDSubject,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "VAPID public key missing",
|
||||
env: with(nil, EnvVAPIDPublicKey),
|
||||
badVars: []string{EnvVAPIDPublicKey},
|
||||
},
|
||||
{
|
||||
name: "VAPID private key file missing",
|
||||
env: with(nil, EnvVAPIDPrivateKeyFile),
|
||||
badVars: []string{EnvVAPIDPrivateKeyFile},
|
||||
},
|
||||
{
|
||||
name: "VAPID subject missing",
|
||||
env: with(nil, EnvVAPIDSubject),
|
||||
badVars: []string{EnvVAPIDSubject},
|
||||
},
|
||||
{
|
||||
name: "only the VAPID public key set",
|
||||
env: with(nil, EnvVAPIDPrivateKeyFile, EnvVAPIDSubject),
|
||||
badVars: []string{EnvVAPIDPrivateKeyFile, EnvVAPIDSubject},
|
||||
},
|
||||
{
|
||||
name: "no VAPID variable set",
|
||||
env: with(nil, EnvVAPIDPublicKey, EnvVAPIDPrivateKeyFile, EnvVAPIDSubject),
|
||||
badVars: []string{EnvVAPIDPublicKey, EnvVAPIDPrivateKeyFile, EnvVAPIDSubject},
|
||||
},
|
||||
{
|
||||
name: "VAPID private key file unreadable",
|
||||
env: with(map[string]string{EnvVAPIDPrivateKeyFile: unreadablePath}),
|
||||
badVars: []string{EnvVAPIDPrivateKeyFile},
|
||||
},
|
||||
{
|
||||
name: "VAPID private key file empty",
|
||||
env: with(map[string]string{EnvVAPIDPrivateKeyFile: emptyPath}),
|
||||
badVars: []string{EnvVAPIDPrivateKeyFile},
|
||||
},
|
||||
{
|
||||
name: "inline VAPID private key rejected",
|
||||
env: with(map[string]string{"MEALQUEST_VAPID_PRIVATE_KEY": vapidPair.PrivateKey}),
|
||||
badVars: []string{"MEALQUEST_VAPID_PRIVATE_KEY"},
|
||||
},
|
||||
{
|
||||
name: "VAPID private key as PEM",
|
||||
env: with(map[string]string{EnvVAPIDPrivateKeyFile: pemKeyPath}),
|
||||
badVars: []string{EnvVAPIDPrivateKeyFile},
|
||||
},
|
||||
{
|
||||
name: "padded VAPID private key",
|
||||
env: with(map[string]string{EnvVAPIDPrivateKeyFile: paddedKeyPath}),
|
||||
badVars: []string{EnvVAPIDPrivateKeyFile},
|
||||
},
|
||||
{
|
||||
name: "VAPID private key from another pair",
|
||||
env: with(map[string]string{EnvVAPIDPrivateKeyFile: otherKeyPath}),
|
||||
badVars: []string{EnvVAPIDPrivateKeyFile},
|
||||
},
|
||||
{
|
||||
name: "VAPID public key from another pair",
|
||||
env: with(map[string]string{EnvVAPIDPublicKey: otherPair.PublicKey}),
|
||||
badVars: []string{EnvVAPIDPrivateKeyFile},
|
||||
},
|
||||
{
|
||||
name: "VAPID public key malformed",
|
||||
env: with(map[string]string{EnvVAPIDPublicKey: "not-a-key"}),
|
||||
badVars: []string{EnvVAPIDPublicKey},
|
||||
},
|
||||
{
|
||||
name: "VAPID public key padded",
|
||||
env: with(map[string]string{EnvVAPIDPublicKey: vapidPair.PublicKey + "="}),
|
||||
badVars: []string{EnvVAPIDPublicKey},
|
||||
},
|
||||
{
|
||||
name: "VAPID private key given as the public key",
|
||||
env: with(map[string]string{EnvVAPIDPublicKey: vapidPair.PrivateKey}),
|
||||
badVars: []string{EnvVAPIDPublicKey},
|
||||
},
|
||||
{
|
||||
name: "VAPID https subject",
|
||||
env: with(map[string]string{EnvVAPIDSubject: "https://meals.example.com/contact"}),
|
||||
},
|
||||
{
|
||||
name: "VAPID subject without mailto:",
|
||||
env: with(map[string]string{EnvVAPIDSubject: "owner@example.com"}),
|
||||
badVars: []string{EnvVAPIDSubject},
|
||||
},
|
||||
{
|
||||
name: "VAPID mailto: subject without an address",
|
||||
env: with(map[string]string{EnvVAPIDSubject: "mailto:owner"}),
|
||||
badVars: []string{EnvVAPIDSubject},
|
||||
},
|
||||
{
|
||||
name: "VAPID mailto: subject with a display name",
|
||||
env: with(map[string]string{EnvVAPIDSubject: "mailto:Owner <owner@example.com>"}),
|
||||
badVars: []string{EnvVAPIDSubject},
|
||||
},
|
||||
{
|
||||
name: "VAPID plain http subject",
|
||||
env: with(map[string]string{EnvVAPIDSubject: "http://meals.example.com"}),
|
||||
badVars: []string{EnvVAPIDSubject},
|
||||
},
|
||||
{
|
||||
name: "invalid duration",
|
||||
env: with(map[string]string{EnvShutdownTimeout: "ten seconds"}),
|
||||
@@ -576,7 +446,6 @@ func TestLoadValues(t *testing.T) {
|
||||
if err := os.WriteFile(googleSecretPath, []byte("google-s3cret\n"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
vapidPair, vapidKeyPath := writeVAPIDKey(t, dir, "vapid_private_key")
|
||||
env := map[string]string{
|
||||
EnvHTTPAddr: "127.0.0.1:9000",
|
||||
EnvPublicURL: "http://localhost:9000",
|
||||
@@ -592,10 +461,6 @@ func TestLoadValues(t *testing.T) {
|
||||
EnvGoogleClientID: "mealquest.apps.googleusercontent.com",
|
||||
EnvGoogleClientSecretFile: googleSecretPath,
|
||||
|
||||
EnvVAPIDPublicKey: vapidPair.PublicKey,
|
||||
EnvVAPIDPrivateKeyFile: vapidKeyPath,
|
||||
EnvVAPIDSubject: "mailto:owner@example.com",
|
||||
|
||||
EnvDefaultTZ: "Europe/London",
|
||||
|
||||
EnvSearXNGURL: "http://searxng:8080",
|
||||
@@ -635,9 +500,6 @@ func TestLoadValues(t *testing.T) {
|
||||
if cfg.GoogleClientSecret.Reveal() != "google-s3cret" {
|
||||
t.Errorf("GoogleClientSecret.Reveal() = %q", cfg.GoogleClientSecret.Reveal())
|
||||
}
|
||||
if want := (VAPID{PublicKey: vapidPair.PublicKey, PrivateKey: Secret{value: vapidPair.PrivateKey}, Subject: "mailto:owner@example.com"}); cfg.VAPID != want {
|
||||
t.Errorf("VAPID = %+v, want public key, trimmed private key and subject", cfg.VAPID)
|
||||
}
|
||||
if want := []string{"mealquest-admin", "family"}; !slices.Equal(cfg.OIDCAllowedGroups, want) {
|
||||
t.Errorf("OIDCAllowedGroups = %q, want %q", cfg.OIDCAllowedGroups, want)
|
||||
}
|
||||
@@ -676,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, GoogleClientSecret: s, VAPID: VAPID{PrivateKey: 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)
|
||||
@@ -685,38 +547,6 @@ func TestSecretRedacted(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestVAPIDErrorsNeverQuoteThePrivateKey checks every VAPID private key
|
||||
// problem names the variable without echoing the file's contents.
|
||||
func TestVAPIDErrorsNeverQuoteThePrivateKey(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
dir := t.TempDir()
|
||||
pair, _ := writeVAPIDKey(t, dir, "unused")
|
||||
other, err := vapid.Generate()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for name, contents := range map[string]string{
|
||||
"mismatched": other.PrivateKey,
|
||||
"padded": pair.PrivateKey + "=",
|
||||
"truncated": pair.PrivateKey[:40],
|
||||
} {
|
||||
path := filepath.Join(dir, name)
|
||||
if err := os.WriteFile(path, []byte(contents+"\n"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
env := map[string]string{EnvVAPIDPublicKey: pair.PublicKey, EnvVAPIDPrivateKeyFile: path, EnvVAPIDSubject: "mailto:owner@example.com"}
|
||||
_, err := load(func(k string) (string, bool) { v, ok := env[k]; return v, ok })
|
||||
var cerr *Error
|
||||
if !errors.As(err, &cerr) || !slices.ContainsFunc(cerr.Problems, func(p Problem) bool { return p.Var == EnvVAPIDPrivateKeyFile }) {
|
||||
t.Errorf("%s: error = %v, want problem for %s", name, err, EnvVAPIDPrivateKeyFile)
|
||||
}
|
||||
if err != nil && (strings.Contains(err.Error(), contents) || strings.Contains(err.Error(), contents[:16])) {
|
||||
t.Errorf("%s: error quotes the private key: %v", name, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestPublicURLWithCredentialsRejected(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
|
||||
@@ -1,77 +0,0 @@
|
||||
// Code generated by sqlc. DO NOT EDIT.
|
||||
// versions:
|
||||
// sqlc v1.31.1
|
||||
// source: push_subscriptions.sql
|
||||
|
||||
package db
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
)
|
||||
|
||||
const deletePushSubscription = `-- name: DeletePushSubscription :execrows
|
||||
DELETE FROM push_subscriptions
|
||||
WHERE endpoint = ?
|
||||
`
|
||||
|
||||
func (q *Queries) DeletePushSubscription(ctx context.Context, endpoint string) (int64, error) {
|
||||
result, err := q.db.ExecContext(ctx, deletePushSubscription, endpoint)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return result.RowsAffected()
|
||||
}
|
||||
|
||||
const upsertPushSubscription = `-- name: UpsertPushSubscription :one
|
||||
INSERT INTO push_subscriptions (
|
||||
id, endpoint, p256dh, auth, user_agent, failure_count, created_at, updated_at
|
||||
) VALUES (
|
||||
?, ?, ?, ?, ?, 0, ?, ?
|
||||
)
|
||||
ON CONFLICT (endpoint) DO UPDATE SET
|
||||
p256dh = excluded.p256dh,
|
||||
auth = excluded.auth,
|
||||
user_agent = excluded.user_agent,
|
||||
failure_count = 0,
|
||||
updated_at = excluded.updated_at
|
||||
RETURNING id, endpoint, p256dh, auth, user_agent, failure_count, last_success_at, created_at, updated_at
|
||||
`
|
||||
|
||||
type UpsertPushSubscriptionParams struct {
|
||||
ID string
|
||||
Endpoint string
|
||||
P256dh string
|
||||
Auth string
|
||||
UserAgent sql.NullString
|
||||
CreatedAt string
|
||||
UpdatedAt string
|
||||
}
|
||||
|
||||
// Stores a browser's push subscription, keyed by its endpoint. Subscribing
|
||||
// an endpoint already stored replaces its keys and user agent and clears
|
||||
// its failure count, keeping its id, created_at and last_success_at.
|
||||
func (q *Queries) UpsertPushSubscription(ctx context.Context, arg UpsertPushSubscriptionParams) (PushSubscription, error) {
|
||||
row := q.db.QueryRowContext(ctx, upsertPushSubscription,
|
||||
arg.ID,
|
||||
arg.Endpoint,
|
||||
arg.P256dh,
|
||||
arg.Auth,
|
||||
arg.UserAgent,
|
||||
arg.CreatedAt,
|
||||
arg.UpdatedAt,
|
||||
)
|
||||
var i PushSubscription
|
||||
err := row.Scan(
|
||||
&i.ID,
|
||||
&i.Endpoint,
|
||||
&i.P256dh,
|
||||
&i.Auth,
|
||||
&i.UserAgent,
|
||||
&i.FailureCount,
|
||||
&i.LastSuccessAt,
|
||||
&i.CreatedAt,
|
||||
&i.UpdatedAt,
|
||||
)
|
||||
return i, err
|
||||
}
|
||||
@@ -62,7 +62,6 @@ type Querier interface {
|
||||
DecideSuggestion(ctx context.Context, arg DecideSuggestionParams) (int64, error)
|
||||
DeleteAIRunsBefore(ctx context.Context, cutoff string) (int64, error)
|
||||
DeleteIntegrationsGoogle(ctx context.Context) error
|
||||
DeletePushSubscription(ctx context.Context, endpoint string) (int64, error)
|
||||
DeleteRecipeIngredients(ctx context.Context, recipeID string) error
|
||||
DeleteRecipeSteps(ctx context.Context, recipeID string) error
|
||||
DeleteShoppingGroup(ctx context.Context, id string) error
|
||||
@@ -277,10 +276,6 @@ type Querier interface {
|
||||
// Creates the single row (id 1) or replaces every column except id and
|
||||
// created_at.
|
||||
UpsertIntegrationsGoogle(ctx context.Context, arg UpsertIntegrationsGoogleParams) (IntegrationsGoogle, error)
|
||||
// Stores a browser's push subscription, keyed by its endpoint. Subscribing
|
||||
// an endpoint already stored replaces its keys and user agent and clears
|
||||
// its failure count, keeping its id, created_at and last_success_at.
|
||||
UpsertPushSubscription(ctx context.Context, arg UpsertPushSubscriptionParams) (PushSubscription, error)
|
||||
// A recipe is keyed by source_url: re-fetching the same URL updates the
|
||||
// existing row and keeps its id and created_at, which the returned row
|
||||
// carries. Replace its ingredients and steps in the same transaction with
|
||||
|
||||
@@ -1,203 +0,0 @@
|
||||
// Package push keeps the household's Web Push subscriptions, one per
|
||||
// browser, keyed by the push service endpoint the browser was given, and
|
||||
// the VAPID public key browsers subscribe with.
|
||||
package push
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/ecdh"
|
||||
"database/sql"
|
||||
"encoding/base64"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/url"
|
||||
"strings"
|
||||
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/db"
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/store"
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/vapid"
|
||||
)
|
||||
|
||||
// Limits on a subscription's fields.
|
||||
const (
|
||||
MaxEndpointBytes = 2048
|
||||
MaxUserAgentBytes = 512
|
||||
p256dhSize = 65 // uncompressed P-256 point
|
||||
authSize = 16
|
||||
)
|
||||
|
||||
// keyEncoding is the unpadded base64url PushSubscription.toJSON() gives
|
||||
// the keys in.
|
||||
var keyEncoding = base64.RawURLEncoding
|
||||
|
||||
// Subscription is a stored push subscription. Its keys are never returned:
|
||||
// only the sender needs them.
|
||||
type Subscription struct {
|
||||
ID string `json:"id"`
|
||||
Endpoint string `json:"endpoint"`
|
||||
UserAgent *string `json:"user_agent"`
|
||||
CreatedAt string `json:"created_at"`
|
||||
UpdatedAt string `json:"updated_at"`
|
||||
}
|
||||
|
||||
// SubscribeRequest is a browser's push subscription: the endpoint and keys
|
||||
// of its PushSubscription, and optionally a user agent to tell the
|
||||
// household's devices apart.
|
||||
type SubscribeRequest struct {
|
||||
Endpoint string
|
||||
P256dh string
|
||||
Auth string
|
||||
UserAgent *string
|
||||
}
|
||||
|
||||
// Problem is one invalid field of a request, named by its JSON key.
|
||||
type Problem struct {
|
||||
Field string
|
||||
Reason string
|
||||
}
|
||||
|
||||
// ValidationError lists every invalid field of a request.
|
||||
type ValidationError struct {
|
||||
Problems []Problem
|
||||
}
|
||||
|
||||
func (e *ValidationError) Error() string {
|
||||
parts := make([]string, len(e.Problems))
|
||||
for i, p := range e.Problems {
|
||||
parts[i] = p.Field + ": " + p.Reason
|
||||
}
|
||||
return "invalid push subscription: " + strings.Join(parts, "; ")
|
||||
}
|
||||
|
||||
// Service stores push subscriptions.
|
||||
type Service struct {
|
||||
st *store.Store
|
||||
publicKey string
|
||||
}
|
||||
|
||||
// NewService returns a Service over st handing out publicKey, the VAPID
|
||||
// public key in vapid's encoding.
|
||||
func NewService(st *store.Store, publicKey string) (*Service, error) {
|
||||
if st == nil {
|
||||
return nil, errors.New("push: nil store")
|
||||
}
|
||||
if _, err := vapid.ParsePublicKey(publicKey); err != nil {
|
||||
return nil, fmt.Errorf("push: VAPID public key: %w", err)
|
||||
}
|
||||
return &Service{st: st, publicKey: publicKey}, nil
|
||||
}
|
||||
|
||||
// PublicKey returns the VAPID public key, for applicationServerKey.
|
||||
func (s *Service) PublicKey() string { return s.publicKey }
|
||||
|
||||
// Subscribe stores req by its endpoint. An endpoint already stored keeps
|
||||
// its id and created_at and has its keys and user agent replaced, so
|
||||
// subscribing twice leaves one subscription. An invalid request is a
|
||||
// *ValidationError.
|
||||
func (s *Service) Subscribe(ctx context.Context, req SubscribeRequest) (Subscription, error) {
|
||||
var problems []Problem
|
||||
if reason := checkEndpoint(req.Endpoint); reason != "" {
|
||||
problems = append(problems, Problem{"endpoint", reason})
|
||||
}
|
||||
if reason := checkP256dh(req.P256dh); reason != "" {
|
||||
problems = append(problems, Problem{"keys.p256dh", reason})
|
||||
}
|
||||
if reason := checkAuth(req.Auth); reason != "" {
|
||||
problems = append(problems, Problem{"keys.auth", reason})
|
||||
}
|
||||
var ua sql.NullString
|
||||
if req.UserAgent != nil {
|
||||
if reason := checkUserAgent(*req.UserAgent); reason != "" {
|
||||
problems = append(problems, Problem{"user_agent", reason})
|
||||
}
|
||||
ua = sql.NullString{String: *req.UserAgent, Valid: true}
|
||||
}
|
||||
if len(problems) > 0 {
|
||||
return Subscription{}, &ValidationError{Problems: problems}
|
||||
}
|
||||
|
||||
now := store.Now().String()
|
||||
row, err := db.New(s.st.Writer).UpsertPushSubscription(ctx, db.UpsertPushSubscriptionParams{
|
||||
ID: store.NewID(),
|
||||
Endpoint: req.Endpoint,
|
||||
P256dh: req.P256dh,
|
||||
Auth: req.Auth,
|
||||
UserAgent: ua,
|
||||
CreatedAt: now,
|
||||
UpdatedAt: now,
|
||||
})
|
||||
if err != nil {
|
||||
return Subscription{}, fmt.Errorf("push: store subscription: %w", err)
|
||||
}
|
||||
sub := Subscription{ID: row.ID, Endpoint: row.Endpoint, CreatedAt: row.CreatedAt, UpdatedAt: row.UpdatedAt}
|
||||
if row.UserAgent.Valid {
|
||||
sub.UserAgent = &row.UserAgent.String
|
||||
}
|
||||
return sub, nil
|
||||
}
|
||||
|
||||
// Unsubscribe removes the subscription for endpoint and reports whether
|
||||
// one was stored. An endpoint that is not stored is not an error: the
|
||||
// browser may unsubscribe after a failed delivery already removed it. An
|
||||
// invalid endpoint is a *ValidationError.
|
||||
func (s *Service) Unsubscribe(ctx context.Context, endpoint string) (bool, error) {
|
||||
if reason := checkEndpoint(endpoint); reason != "" {
|
||||
return false, &ValidationError{Problems: []Problem{{"endpoint", reason}}}
|
||||
}
|
||||
n, err := db.New(s.st.Writer).DeletePushSubscription(ctx, endpoint)
|
||||
if err != nil {
|
||||
return false, fmt.Errorf("push: delete subscription: %w", err)
|
||||
}
|
||||
return n > 0, nil
|
||||
}
|
||||
|
||||
// checkEndpoint returns why endpoint is not a push service URL, or "".
|
||||
// Push services are always reached over https.
|
||||
func checkEndpoint(endpoint string) string {
|
||||
if endpoint == "" {
|
||||
return "required"
|
||||
}
|
||||
if len(endpoint) > MaxEndpointBytes {
|
||||
return fmt.Sprintf("longer than %d bytes", MaxEndpointBytes)
|
||||
}
|
||||
u, err := url.Parse(endpoint)
|
||||
if err != nil || u.Scheme != "https" || u.Host == "" || u.User != nil || u.Fragment != "" {
|
||||
return "want an absolute https:// URL without credentials or fragment"
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func checkP256dh(s string) string {
|
||||
if s == "" {
|
||||
return "required"
|
||||
}
|
||||
b, err := keyEncoding.DecodeString(s)
|
||||
if err != nil || len(b) != p256dhSize {
|
||||
return fmt.Sprintf("want the %d-byte uncompressed P-256 point in unpadded base64url", p256dhSize)
|
||||
}
|
||||
if _, err := ecdh.P256().NewPublicKey(b); err != nil {
|
||||
return "not a point on P-256"
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func checkAuth(s string) string {
|
||||
if s == "" {
|
||||
return "required"
|
||||
}
|
||||
b, err := keyEncoding.DecodeString(s)
|
||||
if err != nil || len(b) != authSize {
|
||||
return fmt.Sprintf("want %d bytes in unpadded base64url", authSize)
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func checkUserAgent(s string) string {
|
||||
switch {
|
||||
case strings.TrimSpace(s) == "":
|
||||
return "must not be blank; omit it instead"
|
||||
case len(s) > MaxUserAgentBytes:
|
||||
return fmt.Sprintf("longer than %d bytes", MaxUserAgentBytes)
|
||||
}
|
||||
return ""
|
||||
}
|
||||
@@ -1,224 +0,0 @@
|
||||
package push
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/ecdh"
|
||||
"crypto/rand"
|
||||
"encoding/base64"
|
||||
"errors"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/store"
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/vapid"
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/migrations"
|
||||
)
|
||||
|
||||
const endpoint = "https://fcm.googleapis.com/fcm/send/abc123"
|
||||
|
||||
func newTestService(t *testing.T) (*Service, *store.Store) {
|
||||
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)
|
||||
}
|
||||
pair, err := vapid.Generate()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
svc, err := NewService(st, pair.PublicKey)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return svc, st
|
||||
}
|
||||
|
||||
// browserKeys returns a p256dh and auth as a browser's
|
||||
// PushSubscription.toJSON() gives them.
|
||||
func browserKeys(t *testing.T) (string, string) {
|
||||
t.Helper()
|
||||
k, err := ecdh.P256().GenerateKey(rand.Reader)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
auth := make([]byte, 16)
|
||||
rand.Read(auth)
|
||||
return base64.RawURLEncoding.EncodeToString(k.PublicKey().Bytes()), base64.RawURLEncoding.EncodeToString(auth)
|
||||
}
|
||||
|
||||
type storedRow struct {
|
||||
id, p256dh, auth, createdAt, updatedAt string
|
||||
ua *string
|
||||
failures int
|
||||
}
|
||||
|
||||
func rows(t *testing.T, st *store.Store) map[string]storedRow {
|
||||
t.Helper()
|
||||
rs, err := st.Reader.Query(`SELECT endpoint, id, p256dh, auth, user_agent, failure_count, created_at, updated_at FROM push_subscriptions`)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer rs.Close()
|
||||
got := map[string]storedRow{}
|
||||
for rs.Next() {
|
||||
var ep string
|
||||
var r storedRow
|
||||
if err := rs.Scan(&ep, &r.id, &r.p256dh, &r.auth, &r.ua, &r.failures, &r.createdAt, &r.updatedAt); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
got[ep] = r
|
||||
}
|
||||
if err := rs.Err(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return got
|
||||
}
|
||||
|
||||
func TestNewServiceRefusesBadPublicKey(t *testing.T) {
|
||||
t.Parallel()
|
||||
_, st := newTestService(t)
|
||||
if _, err := NewService(st, "not-a-key"); err == nil {
|
||||
t.Error("NewService accepted a malformed public key")
|
||||
}
|
||||
if _, err := NewService(nil, ""); err == nil {
|
||||
t.Error("NewService accepted a nil store")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSubscribeUpsertsByEndpoint(t *testing.T) {
|
||||
t.Parallel()
|
||||
svc, st := newTestService(t)
|
||||
ctx := context.Background()
|
||||
p1, a1 := browserKeys(t)
|
||||
ua := "Mozilla/5.0 (iPad)"
|
||||
|
||||
first, err := svc.Subscribe(ctx, SubscribeRequest{Endpoint: endpoint, P256dh: p1, Auth: a1, UserAgent: &ua})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if first.ID == "" || first.Endpoint != endpoint || first.UserAgent == nil || *first.UserAgent != ua || first.CreatedAt != first.UpdatedAt {
|
||||
t.Errorf("first Subscribe = %+v", first)
|
||||
}
|
||||
if _, err := st.Writer.Exec(`UPDATE push_subscriptions SET failure_count = 3`); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// The same endpoint again, with the same keys and then rotated ones,
|
||||
// updates the one row.
|
||||
again, err := svc.Subscribe(ctx, SubscribeRequest{Endpoint: endpoint, P256dh: p1, Auth: a1, UserAgent: &ua})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
p2, a2 := browserKeys(t)
|
||||
rotated, err := svc.Subscribe(ctx, SubscribeRequest{Endpoint: endpoint, P256dh: p2, Auth: a2})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, s := range []Subscription{again, rotated} {
|
||||
if s.ID != first.ID || s.CreatedAt != first.CreatedAt {
|
||||
t.Errorf("resubscribe = %+v, want id %s and created_at %s kept", s, first.ID, first.CreatedAt)
|
||||
}
|
||||
}
|
||||
if rotated.UserAgent != nil {
|
||||
t.Errorf("resubscribe without user_agent kept %q", *rotated.UserAgent)
|
||||
}
|
||||
got := rows(t, st)
|
||||
if len(got) != 1 {
|
||||
t.Fatalf("rows = %d, want 1: %+v", len(got), got)
|
||||
}
|
||||
r := got[endpoint]
|
||||
if r.p256dh != p2 || r.auth != a2 || r.ua != nil || r.failures != 0 || r.updatedAt < r.createdAt {
|
||||
t.Errorf("stored row = %+v, want rotated keys, no user agent, failures cleared", r)
|
||||
}
|
||||
|
||||
// A second browser is a second row.
|
||||
other := endpoint + "-other"
|
||||
if _, err := svc.Subscribe(ctx, SubscribeRequest{Endpoint: other, P256dh: p1, Auth: a1}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got := rows(t, st); len(got) != 2 {
|
||||
t.Errorf("rows after a second endpoint = %d, want 2", len(got))
|
||||
}
|
||||
}
|
||||
|
||||
func TestUnsubscribe(t *testing.T) {
|
||||
t.Parallel()
|
||||
svc, st := newTestService(t)
|
||||
ctx := context.Background()
|
||||
p, a := browserKeys(t)
|
||||
for _, ep := range []string{endpoint, endpoint + "-keep"} {
|
||||
if _, err := svc.Subscribe(ctx, SubscribeRequest{Endpoint: ep, P256dh: p, Auth: a}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
removed, err := svc.Unsubscribe(ctx, endpoint)
|
||||
if err != nil || !removed {
|
||||
t.Errorf("Unsubscribe = %v, %v; want true, nil", removed, err)
|
||||
}
|
||||
got := rows(t, st)
|
||||
if _, ok := got[endpoint]; ok || len(got) != 1 {
|
||||
t.Errorf("rows after Unsubscribe = %+v, want only the other endpoint", got)
|
||||
}
|
||||
removed, err = svc.Unsubscribe(ctx, endpoint)
|
||||
if err != nil || removed {
|
||||
t.Errorf("Unsubscribe of an endpoint not stored = %v, %v; want false, nil", removed, err)
|
||||
}
|
||||
var invalid *ValidationError
|
||||
if _, err := svc.Unsubscribe(ctx, "http://push.example.com/x"); !errors.As(err, &invalid) {
|
||||
t.Errorf("Unsubscribe of an http endpoint = %v, want *ValidationError", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSubscribeValidates(t *testing.T) {
|
||||
t.Parallel()
|
||||
svc, st := newTestService(t)
|
||||
p, a := browserKeys(t)
|
||||
pair, err := vapid.Generate()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
blank, long := " ", strings.Repeat("x", MaxUserAgentBytes+1)
|
||||
offCurve := make([]byte, 65)
|
||||
offCurve[0] = 0x04
|
||||
raw := base64.RawURLEncoding.EncodeToString
|
||||
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
req SubscribeRequest
|
||||
field string
|
||||
}{
|
||||
{"missing endpoint", SubscribeRequest{P256dh: p, Auth: a}, "endpoint"},
|
||||
{"http endpoint", SubscribeRequest{Endpoint: "http://push.example.com/x", P256dh: p, Auth: a}, "endpoint"},
|
||||
{"relative endpoint", SubscribeRequest{Endpoint: "/fcm/send/x", P256dh: p, Auth: a}, "endpoint"},
|
||||
{"endpoint with credentials", SubscribeRequest{Endpoint: "https://u:p@push.example.com/x", P256dh: p, Auth: a}, "endpoint"},
|
||||
{"endpoint too long", SubscribeRequest{Endpoint: "https://push.example.com/" + strings.Repeat("x", MaxEndpointBytes), P256dh: p, Auth: a}, "endpoint"},
|
||||
{"missing p256dh", SubscribeRequest{Endpoint: endpoint, Auth: a}, "keys.p256dh"},
|
||||
{"padded p256dh", SubscribeRequest{Endpoint: endpoint, P256dh: p + "=", Auth: a}, "keys.p256dh"},
|
||||
{"short p256dh", SubscribeRequest{Endpoint: endpoint, P256dh: a, Auth: a}, "keys.p256dh"},
|
||||
{"p256dh off the curve", SubscribeRequest{Endpoint: endpoint, P256dh: raw(offCurve), Auth: a}, "keys.p256dh"},
|
||||
{"private key as p256dh", SubscribeRequest{Endpoint: endpoint, P256dh: pair.PrivateKey, Auth: a}, "keys.p256dh"},
|
||||
{"missing auth", SubscribeRequest{Endpoint: endpoint, P256dh: p}, "keys.auth"},
|
||||
{"long auth", SubscribeRequest{Endpoint: endpoint, P256dh: p, Auth: raw(make([]byte, 32))}, "keys.auth"},
|
||||
{"blank user agent", SubscribeRequest{Endpoint: endpoint, P256dh: p, Auth: a, UserAgent: &blank}, "user_agent"},
|
||||
{"long user agent", SubscribeRequest{Endpoint: endpoint, P256dh: p, Auth: a, UserAgent: &long}, "user_agent"},
|
||||
} {
|
||||
_, err := svc.Subscribe(context.Background(), tc.req)
|
||||
var invalid *ValidationError
|
||||
if !errors.As(err, &invalid) || len(invalid.Problems) != 1 || invalid.Problems[0].Field != tc.field {
|
||||
t.Errorf("%s: Subscribe error = %v, want one problem for %s", tc.name, err, tc.field)
|
||||
}
|
||||
}
|
||||
if got := rows(t, st); len(got) != 0 {
|
||||
t.Errorf("invalid requests stored %d rows", len(got))
|
||||
}
|
||||
}
|
||||
@@ -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), newTestShopping(t), newTestGoogle(t), newTestPush(t), newTestRunner(t), newTestPhotos(t), fstest.MapFS{"index.html": {Data: []byte(fixtureIndex)}})
|
||||
newTestPlan(t), newTestShopping(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), newTestShopping(t), newTestGoogle(t), newTestPush(t), runner, newTestPhotos(t), fstest.MapFS{"index.html": {Data: []byte(fixtureIndex)}})
|
||||
svc, newTestSuggest(t), newTestPlan(t), newTestShopping(t), newTestGoogle(t), runner, newTestPhotos(t), fstest.MapFS{"index.html": {Data: []byte(fixtureIndex)}})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
@@ -69,7 +69,7 @@ func newGoogleEnv(t *testing.T) *googleEnv {
|
||||
}
|
||||
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), newTestShopping(t), svc, newTestPush(t), newTestRunner(t), newTestPhotos(t),
|
||||
newTestDinners(t), newTestSuggest(t), newTestPlan(t), newTestShopping(t), svc, newTestRunner(t), newTestPhotos(t),
|
||||
fstest.MapFS{"index.html": {Data: []byte(fixtureIndex)}})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
|
||||
@@ -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), newTestShopping(t), newTestGoogle(t), newTestPush(t), newTestRunner(t), newTestPhotos(t), fstest.MapFS{"index.html": {Data: []byte(fixtureIndex)}})
|
||||
newTestSearch(t), newTestAI(t), newTestDinners(t), newTestSuggest(t), newTestPlan(t), newTestShopping(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), newTestShopping(t), newTestGoogle(t), newTestPush(t), newTestRunner(t),
|
||||
r, err = NewRouter(logger, "dev", nil, stubAuth{}, stubSessions{}, newTestSettings(t), newTestSearch(t), newTestAI(t), newTestDinners(t), newTestSuggest(t), newTestPlan(t), newTestShopping(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), newTestShopping(t), newTestGoogle(t), newTestPush(t), newTestRunner(t), newTestPhotos(t), fstest.MapFS{"index.html": {Data: []byte(fixtureIndex)}})
|
||||
newTestSearch(t), newTestAI(t), newTestDinners(t), newTestSuggest(t), newTestPlan(t), newTestShopping(t), newTestGoogle(t), newTestRunner(t), newTestPhotos(t), fstest.MapFS{"index.html": {Data: []byte(fixtureIndex)}})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
@@ -94,7 +94,7 @@ func newPlansRouterTitled(t *testing.T, title shopping.Titler) plansEnv {
|
||||
t.Fatal(err)
|
||||
}
|
||||
h, err := NewRouter(logger, "dev", nil, stubAuth{}, stubSessions{}, newTestSettings(t), newTestSearch(t), newTestAI(t),
|
||||
newTestDinners(t), newTestSuggest(t), svc, shop, googleSvc, newTestPush(t), runner, newTestPhotos(t), fstest.MapFS{"index.html": {Data: []byte(fixtureIndex)}})
|
||||
newTestDinners(t), newTestSuggest(t), svc, shop, googleSvc, runner, newTestPhotos(t), fstest.MapFS{"index.html": {Data: []byte(fixtureIndex)}})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
@@ -1,98 +0,0 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/push"
|
||||
)
|
||||
|
||||
// maxPushBody bounds a POST or DELETE /push/subscriptions body: an endpoint
|
||||
// of push.MaxEndpointBytes, the keys and a user agent of
|
||||
// push.MaxUserAgentBytes, with room for JSON escaping.
|
||||
const maxPushBody = 8 << 10
|
||||
|
||||
type vapidPublicKeyResponse struct {
|
||||
PublicKey string `json:"public_key"`
|
||||
}
|
||||
|
||||
// pushSubscriptionBody is the POST /push/subscriptions body: the
|
||||
// endpoint and keys of PushSubscription.toJSON(), without its
|
||||
// expirationTime, and an optional user_agent.
|
||||
type pushSubscriptionBody struct {
|
||||
Endpoint string `json:"endpoint"`
|
||||
Keys pushSubscriptionKeys `json:"keys"`
|
||||
UserAgent *string `json:"user_agent"`
|
||||
}
|
||||
|
||||
type pushSubscriptionKeys struct {
|
||||
P256dh string `json:"p256dh"`
|
||||
Auth string `json:"auth"`
|
||||
}
|
||||
|
||||
// pushUnsubscribeBody is the DELETE /push/subscriptions body.
|
||||
type pushUnsubscribeBody struct {
|
||||
Endpoint string `json:"endpoint"`
|
||||
}
|
||||
|
||||
// getVAPIDPublicKey answers GET /push/vapid-public-key with the key
|
||||
// browsers pass as applicationServerKey. The private key is never served.
|
||||
func getVAPIDPublicKey(logger *slog.Logger, svc *push.Service) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
writeJSON(w, r, logger, http.StatusOK, vapidPublicKeyResponse{PublicKey: svc.PublicKey()})
|
||||
}
|
||||
}
|
||||
|
||||
// postPushSubscription stores the browser's subscription by its endpoint
|
||||
// and answers 200 with it, without its keys. Posting an endpoint already
|
||||
// stored updates it.
|
||||
func postPushSubscription(logger *slog.Logger, svc *push.Service) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
body, err := decodePatch[pushSubscriptionBody](http.MaxBytesReader(w, r.Body, maxPushBody))
|
||||
if err != nil {
|
||||
writeError(w, r, logger, http.StatusBadRequest, "invalid_request", err.Error())
|
||||
return
|
||||
}
|
||||
sub, err := svc.Subscribe(r.Context(), push.SubscribeRequest{
|
||||
Endpoint: body.Endpoint,
|
||||
P256dh: body.Keys.P256dh,
|
||||
Auth: body.Keys.Auth,
|
||||
UserAgent: body.UserAgent,
|
||||
})
|
||||
if err != nil {
|
||||
writePushError(w, r, logger, "store push subscription", err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, r, logger, http.StatusOK, sub)
|
||||
}
|
||||
}
|
||||
|
||||
// deletePushSubscription removes the subscription for the body's endpoint
|
||||
// and answers 204, whether or not it was stored.
|
||||
func deletePushSubscription(logger *slog.Logger, svc *push.Service) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
body, err := decodePatch[pushUnsubscribeBody](http.MaxBytesReader(w, r.Body, maxPushBody))
|
||||
if err != nil {
|
||||
writeError(w, r, logger, http.StatusBadRequest, "invalid_request", err.Error())
|
||||
return
|
||||
}
|
||||
if _, err := svc.Unsubscribe(r.Context(), body.Endpoint); err != nil {
|
||||
writePushError(w, r, logger, "delete push subscription", err)
|
||||
return
|
||||
}
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}
|
||||
}
|
||||
|
||||
// writePushError maps a push.Service error to its API error: an invalid
|
||||
// subscription is 400 invalid_subscription, anything else 500.
|
||||
func writePushError(w http.ResponseWriter, r *http.Request, logger *slog.Logger, action string, err error) {
|
||||
var invalid *push.ValidationError
|
||||
if errors.As(err, &invalid) {
|
||||
writeError(w, r, logger, http.StatusBadRequest, "invalid_subscription", invalid.Error())
|
||||
return
|
||||
}
|
||||
logger.ErrorContext(r.Context(), action, "error", err)
|
||||
writeError(w, r, logger, http.StatusInternalServerError, "internal", "internal error")
|
||||
}
|
||||
@@ -1,284 +0,0 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"crypto/ecdh"
|
||||
"crypto/rand"
|
||||
"encoding/base64"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"io"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
"testing/fstest"
|
||||
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/push"
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/store"
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/vapid"
|
||||
)
|
||||
|
||||
const (
|
||||
vapidPublicKeyPath = "/api/v1/push/vapid-public-key"
|
||||
pushSubscriptionsPath = "/api/v1/push/subscriptions"
|
||||
testPushEndpoint = "https://updates.push.services.mozilla.com/wpush/v2/gAAAAABk"
|
||||
)
|
||||
|
||||
// newTestPush returns a push service over a migrated temp database with a
|
||||
// new VAPID key pair.
|
||||
func newTestPush(t *testing.T) *push.Service {
|
||||
t.Helper()
|
||||
svc, _, _ := newTestPushWith(t)
|
||||
return svc
|
||||
}
|
||||
|
||||
// newTestPushWith is newTestPush also returning its store and key pair.
|
||||
func newTestPushWith(t *testing.T) (*push.Service, *store.Store, vapid.KeyPair) {
|
||||
t.Helper()
|
||||
pair, err := vapid.Generate()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
st := newTestStore(t)
|
||||
svc, err := push.NewService(st, pair.PublicKey)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return svc, st, pair
|
||||
}
|
||||
|
||||
// newPushRouter returns a router serving svc.
|
||||
func newPushRouter(t *testing.T, svc *push.Service) 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), newTestShopping(t), newTestGoogle(t), svc, newTestRunner(t),
|
||||
newTestPhotos(t), fstest.MapFS{"index.html": {Data: []byte(fixtureIndex)}})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return r
|
||||
}
|
||||
|
||||
// subscriptionJSON returns a POST /push/subscriptions body for endpoint
|
||||
// with new browser keys.
|
||||
func subscriptionJSON(t *testing.T, endpoint, userAgent string) string {
|
||||
t.Helper()
|
||||
k, err := ecdh.P256().GenerateKey(rand.Reader)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
auth := make([]byte, 16)
|
||||
rand.Read(auth)
|
||||
body := map[string]any{
|
||||
"endpoint": endpoint,
|
||||
"keys": map[string]string{
|
||||
"p256dh": base64.RawURLEncoding.EncodeToString(k.PublicKey().Bytes()),
|
||||
"auth": base64.RawURLEncoding.EncodeToString(auth),
|
||||
},
|
||||
}
|
||||
if userAgent != "" {
|
||||
body["user_agent"] = userAgent
|
||||
}
|
||||
b, err := json.Marshal(body)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return string(b)
|
||||
}
|
||||
|
||||
func countPushSubscriptions(t *testing.T, st *store.Store) int {
|
||||
t.Helper()
|
||||
var n int
|
||||
if err := st.Reader.QueryRow(`SELECT count(*) FROM push_subscriptions`).Scan(&n); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return n
|
||||
}
|
||||
|
||||
func TestNewRouterRequiresPush(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), newTestShopping(t), newTestGoogle(t), nil, newTestRunner(t), newTestPhotos(t), fstest.MapFS{"index.html": {}}); err == nil {
|
||||
t.Fatal("NewRouter accepted a nil push service")
|
||||
}
|
||||
}
|
||||
|
||||
func TestPushAPI(t *testing.T) {
|
||||
t.Parallel()
|
||||
svc, st, pair := newTestPushWith(t)
|
||||
router := newPushRouter(t, svc)
|
||||
var bodies []string
|
||||
call := func(method, target, body string) *httptest.ResponseRecorder {
|
||||
t.Helper()
|
||||
var r io.Reader
|
||||
if body != "" {
|
||||
r = strings.NewReader(body)
|
||||
}
|
||||
rec := httptest.NewRecorder()
|
||||
router.ServeHTTP(rec, signedIn(method, target, r))
|
||||
bodies = append(bodies, rec.Body.String())
|
||||
return rec
|
||||
}
|
||||
decode := func(rec *httptest.ResponseRecorder) map[string]any {
|
||||
t.Helper()
|
||||
var m map[string]any
|
||||
if err := json.Unmarshal(rec.Body.Bytes(), &m); err != nil {
|
||||
t.Fatalf("decode %q: %v", rec.Body.String(), err)
|
||||
}
|
||||
return m
|
||||
}
|
||||
|
||||
rec := call(http.MethodGet, vapidPublicKeyPath, "")
|
||||
if body := decode(rec); rec.Code != http.StatusOK || len(body) != 1 || body["public_key"] != pair.PublicKey {
|
||||
t.Errorf("GET public key = %d %s, want only the public key", rec.Code, rec.Body.String())
|
||||
}
|
||||
|
||||
// Posting the same endpoint twice keeps one subscription, updated.
|
||||
rec = call(http.MethodPost, pushSubscriptionsPath, subscriptionJSON(t, testPushEndpoint, "Firefox on the kitchen tablet"))
|
||||
first := decode(rec)
|
||||
if rec.Code != http.StatusOK || first["endpoint"] != testPushEndpoint || first["user_agent"] != "Firefox on the kitchen tablet" || first["id"] == "" {
|
||||
t.Errorf("POST subscription = %d %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
if _, ok := first["keys"]; ok {
|
||||
t.Errorf("POST response holds the keys: %s", rec.Body.String())
|
||||
}
|
||||
rec = call(http.MethodPost, pushSubscriptionsPath, subscriptionJSON(t, testPushEndpoint, "Firefox 140"))
|
||||
again := decode(rec)
|
||||
if rec.Code != http.StatusOK || again["id"] != first["id"] || again["created_at"] != first["created_at"] || again["user_agent"] != "Firefox 140" {
|
||||
t.Errorf("POST same endpoint = %d %s, want the first subscription updated", rec.Code, rec.Body.String())
|
||||
}
|
||||
if n := countPushSubscriptions(t, st); n != 1 {
|
||||
t.Errorf("subscriptions after posting one endpoint twice = %d, want 1", n)
|
||||
}
|
||||
rec = call(http.MethodPost, pushSubscriptionsPath, subscriptionJSON(t, testPushEndpoint+"-phone", ""))
|
||||
if body := decode(rec); rec.Code != http.StatusOK || body["user_agent"] != nil {
|
||||
t.Errorf("POST second endpoint = %d %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
if n := countPushSubscriptions(t, st); n != 2 {
|
||||
t.Errorf("subscriptions after a second endpoint = %d, want 2", n)
|
||||
}
|
||||
|
||||
for _, bad := range []struct{ body, code string }{
|
||||
{"", "invalid_request"},
|
||||
{`{"endpoint":"` + testPushEndpoint + `","keys":{"p256dh":"x","auth":"y"},"expirationTime":null}`, "invalid_request"},
|
||||
{`{"endpoint":"` + testPushEndpoint + `","keys":{"p256dh":"x","auth":"y"},"extra":1}`, "invalid_request"},
|
||||
{`{"endpoint":"` + testPushEndpoint + `"}`, "invalid_subscription"},
|
||||
{`{"endpoint":"` + testPushEndpoint + `","keys":{"p256dh":"x","auth":"y"}}`, "invalid_subscription"},
|
||||
{subscriptionJSON(t, "http://push.example.com/x", ""), "invalid_subscription"},
|
||||
{`{"endpoint":"https://push.example.com/` + strings.Repeat("x", maxPushBody) + `"}`, "invalid_request"},
|
||||
} {
|
||||
rec := call(http.MethodPost, pushSubscriptionsPath, bad.body)
|
||||
if rec.Code != http.StatusBadRequest || !strings.Contains(rec.Body.String(), `"code":"`+bad.code+`"`) {
|
||||
t.Errorf("POST %.80s = %d %s, want 400 %s", bad.body, rec.Code, rec.Body.String(), bad.code)
|
||||
}
|
||||
}
|
||||
if n := countPushSubscriptions(t, st); n != 2 {
|
||||
t.Errorf("subscriptions after invalid posts = %d, want 2", n)
|
||||
}
|
||||
|
||||
// DELETE removes by endpoint and is 204 whether or not it was stored.
|
||||
for i := range 2 {
|
||||
rec = call(http.MethodDelete, pushSubscriptionsPath, `{"endpoint":"`+testPushEndpoint+`"}`)
|
||||
if rec.Code != http.StatusNoContent || rec.Body.Len() != 0 {
|
||||
t.Errorf("DELETE #%d = %d %s, want 204", i+1, rec.Code, rec.Body.String())
|
||||
}
|
||||
}
|
||||
if n := countPushSubscriptions(t, st); n != 1 {
|
||||
t.Errorf("subscriptions after DELETE = %d, want 1", n)
|
||||
}
|
||||
for _, bad := range []string{"", `{}`, `{"endpoint":"not a url"}`} {
|
||||
rec := call(http.MethodDelete, pushSubscriptionsPath, bad)
|
||||
if rec.Code != http.StatusBadRequest {
|
||||
t.Errorf("DELETE %q = %d %s, want 400", bad, rec.Code, rec.Body.String())
|
||||
}
|
||||
}
|
||||
if n := countPushSubscriptions(t, st); n != 1 {
|
||||
t.Errorf("subscriptions after invalid deletes = %d, want 1", n)
|
||||
}
|
||||
|
||||
// The private key never leaves the process, in any encoding.
|
||||
priv, err := base64.RawURLEncoding.DecodeString(pair.PrivateKey)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
leaks := []string{
|
||||
pair.PrivateKey,
|
||||
pair.PrivateKey[:16],
|
||||
base64.StdEncoding.EncodeToString(priv),
|
||||
base64.RawStdEncoding.EncodeToString(priv)[:16],
|
||||
hex.EncodeToString(priv),
|
||||
}
|
||||
for _, b := range bodies {
|
||||
for _, leak := range leaks {
|
||||
if strings.Contains(b, leak) {
|
||||
t.Errorf("response holds the VAPID private key: %s", b)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestPushSubscriptionsNeedCSRF(t *testing.T) {
|
||||
t.Parallel()
|
||||
svc, st, _ := newTestPushWith(t)
|
||||
router := newPushRouter(t, svc)
|
||||
if _, err := svc.Subscribe(t.Context(), push.SubscribeRequest{Endpoint: testPushEndpoint + "-stored", P256dh: mustP256dh(t), Auth: "AAAAAAAAAAAAAAAAAAAAAA"}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, tc := range []struct {
|
||||
method, body string
|
||||
}{
|
||||
{http.MethodPost, subscriptionJSON(t, testPushEndpoint, "")},
|
||||
{http.MethodDelete, `{"endpoint":"` + testPushEndpoint + `-stored"}`},
|
||||
} {
|
||||
for _, header := range []*string{nil, ptr(""), ptr("wrong-token")} {
|
||||
req := httptest.NewRequest(tc.method, pushSubscriptionsPath, strings.NewReader(tc.body))
|
||||
req.AddCookie(validSessionCookie)
|
||||
if header != nil {
|
||||
req.Header.Set(CSRFHeader, *header)
|
||||
}
|
||||
rec := httptest.NewRecorder()
|
||||
router.ServeHTTP(rec, req)
|
||||
if rec.Code != http.StatusForbidden || rec.Body.String() != csrfJSON {
|
||||
t.Errorf("%s without a valid CSRF token (%v) = %d %s, want 403 csrf", tc.method, header, rec.Code, rec.Body.String())
|
||||
}
|
||||
}
|
||||
}
|
||||
if n := countPushSubscriptions(t, st); n != 1 {
|
||||
t.Errorf("subscriptions after refused requests = %d, want the 1 stored before", n)
|
||||
}
|
||||
// GET needs no token.
|
||||
req := httptest.NewRequest(http.MethodGet, vapidPublicKeyPath, nil)
|
||||
req.AddCookie(validSessionCookie)
|
||||
rec := httptest.NewRecorder()
|
||||
router.ServeHTTP(rec, req)
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Errorf("GET public key without CSRF token = %d, want 200", rec.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPushNeedsSession(t *testing.T) {
|
||||
t.Parallel()
|
||||
router := newPushRouter(t, newTestPush(t))
|
||||
for _, method := range []string{http.MethodGet, http.MethodPost, http.MethodDelete} {
|
||||
target := pushSubscriptionsPath
|
||||
if method == http.MethodGet {
|
||||
target = vapidPublicKeyPath
|
||||
}
|
||||
rec := httptest.NewRecorder()
|
||||
router.ServeHTTP(rec, httptest.NewRequest(method, target, nil))
|
||||
if rec.Code != http.StatusUnauthorized {
|
||||
t.Errorf("%s %s without a session = %d, want 401", method, target, rec.Code)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func mustP256dh(t *testing.T) string {
|
||||
t.Helper()
|
||||
k, err := ecdh.P256().GenerateKey(rand.Reader)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return base64.RawURLEncoding.EncodeToString(k.PublicKey().Bytes())
|
||||
}
|
||||
@@ -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), newTestShopping(t), newTestGoogle(t), newTestPush(t), newTestRunner(t),
|
||||
r, err := NewRouter(logger, "dev", nil, stubAuth{}, stubSessions{}, newTestSettings(t), search, newTestAI(t), newTestDinners(t), newTestSuggest(t), newTestPlan(t), newTestShopping(t), newTestGoogle(t), newTestRunner(t),
|
||||
newTestPhotos(t), fstest.MapFS{"index.html": {Data: []byte(fixtureIndex)}})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
|
||||
@@ -18,7 +18,6 @@ import (
|
||||
"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/push"
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/recipes"
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/shopping"
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/suggest"
|
||||
@@ -56,14 +55,12 @@ type RouteMounter interface {
|
||||
// /api/v1/shopping/items/{id}; 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; pushSvc serves GET
|
||||
// /api/v1/push/vapid-public-key and POST and DELETE
|
||||
// /api/v1/push/subscriptions; runner serves
|
||||
// /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, shopSvc *shopping.Service, googleSvc *gcal.Service, pushSvc *push.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, shopSvc *shopping.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")
|
||||
}
|
||||
@@ -88,9 +85,6 @@ func NewRouter(logger *slog.Logger, version string, trusted forwarded.Trusted, a
|
||||
if googleSvc == nil {
|
||||
return nil, errors.New("server: nil Google Calendar service")
|
||||
}
|
||||
if pushSvc == nil {
|
||||
return nil, errors.New("server: nil push service")
|
||||
}
|
||||
if runner == nil {
|
||||
return nil, errors.New("server: nil job runner")
|
||||
}
|
||||
@@ -164,9 +158,6 @@ func NewRouter(logger *slog.Logger, version string, trusted forwarded.Trusted, a
|
||||
apiV1.Delete("/integrations/google", deleteGoogle(logger, googleSvc))
|
||||
apiV1.Get("/integrations/google/calendars", getGoogleCalendars(logger, googleSvc))
|
||||
apiV1.Put("/integrations/google/calendar", putGoogleCalendar(logger, googleSvc))
|
||||
apiV1.Get("/push/vapid-public-key", getVAPIDPublicKey(logger, pushSvc))
|
||||
apiV1.Post("/push/subscriptions", postPushSubscription(logger, pushSvc))
|
||||
apiV1.Delete("/push/subscriptions", deletePushSubscription(logger, pushSvc))
|
||||
apiV1.Post("/jobs", postJob(logger, runner))
|
||||
apiV1.Get("/jobs/{id}", getJob(logger, runner))
|
||||
media.Get("/photos/{name}", getPhoto(logger, photos))
|
||||
|
||||
@@ -54,7 +54,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), newTestShopping(t), newTestGoogle(t), newTestPush(t), runner, newTestPhotos(t), assets)
|
||||
r, err := NewRouter(logger, version, nil, stubAuth{}, stubSessions{}, settings, newTestSearch(t), newTestAI(t), newTestDinners(t), newTestSuggest(t), newTestPlan(t), newTestShopping(t), newTestGoogle(t), runner, newTestPhotos(t), assets)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@@ -287,7 +287,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), newTestShopping(t), newTestGoogle(t), newTestPush(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), newTestShopping(t), newTestGoogle(t), newTestRunner(t), newTestPhotos(t), fstest.MapFS{"index.html": {}}); err == nil {
|
||||
t.Fatal("NewRouter accepted a nil AI settings service")
|
||||
}
|
||||
}
|
||||
@@ -296,7 +296,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), newTestShopping(t), newTestGoogle(t), newTestPush(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), newTestShopping(t), newTestGoogle(t), newTestRunner(t), newTestPhotos(t), fstest.MapFS{"index.html": {}}); err == nil {
|
||||
t.Fatal("NewRouter accepted a nil recipe search settings service")
|
||||
}
|
||||
}
|
||||
@@ -305,7 +305,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), newTestShopping(t), newTestGoogle(t), newTestPush(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), newTestShopping(t), newTestGoogle(t), newTestRunner(t), nil, fstest.MapFS{"index.html": {}}); err == nil {
|
||||
t.Fatal("NewRouter accepted nil photo files")
|
||||
}
|
||||
}
|
||||
@@ -314,7 +314,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), newTestShopping(t), newTestGoogle(t), newTestPush(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), newTestShopping(t), newTestGoogle(t), newTestRunner(t), newTestPhotos(t), fstest.MapFS{"index.html": {}}); err == nil {
|
||||
t.Fatal("NewRouter accepted a nil settings service")
|
||||
}
|
||||
}
|
||||
@@ -323,7 +323,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), newTestShopping(t), newTestGoogle(t), newTestPush(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), newTestShopping(t), newTestGoogle(t), newTestRunner(t), newTestPhotos(t), fstest.MapFS{"index.html": {}}); err == nil {
|
||||
t.Fatal("NewRouter accepted a nil dinners service")
|
||||
}
|
||||
}
|
||||
@@ -332,7 +332,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), newTestShopping(t), newTestGoogle(t), newTestPush(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), newTestShopping(t), newTestGoogle(t), newTestRunner(t), newTestPhotos(t), fstest.MapFS{"index.html": {}}); err == nil {
|
||||
t.Fatal("NewRouter accepted a nil suggestions service")
|
||||
}
|
||||
}
|
||||
@@ -341,7 +341,7 @@ 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, newTestShopping(t), newTestGoogle(t), newTestPush(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), newTestSuggest(t), nil, newTestShopping(t), newTestGoogle(t), newTestRunner(t), newTestPhotos(t), fstest.MapFS{"index.html": {}}); err == nil {
|
||||
t.Fatal("NewRouter accepted a nil plan service")
|
||||
}
|
||||
}
|
||||
@@ -350,7 +350,7 @@ func TestNewRouterRequiresShopping(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, newTestGoogle(t), newTestPush(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), newTestSuggest(t), newTestPlan(t), nil, newTestGoogle(t), newTestRunner(t), newTestPhotos(t), fstest.MapFS{"index.html": {}}); err == nil {
|
||||
t.Fatal("NewRouter accepted a nil shopping service")
|
||||
}
|
||||
}
|
||||
@@ -359,7 +359,7 @@ 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), newTestShopping(t), nil, newTestPush(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), newTestSuggest(t), newTestPlan(t), newTestShopping(t), nil, newTestRunner(t), newTestPhotos(t), fstest.MapFS{"index.html": {}}); err == nil {
|
||||
t.Fatal("NewRouter accepted a nil Google Calendar service")
|
||||
}
|
||||
}
|
||||
@@ -368,7 +368,7 @@ 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), newTestShopping(t), newTestGoogle(t), newTestPush(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), newTestShopping(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), newTestShopping(t), newTestGoogle(t), newTestPush(t), newTestRunner(t), newTestPhotos(t), fstest.MapFS{"index.html": {Data: []byte(fixtureIndex)}})
|
||||
newTestSearch(t), newTestAI(t), newTestDinners(t), newTestSuggest(t), newTestPlan(t), newTestShopping(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), newTestShopping(t), newTestGoogle(t), newTestPush(t), runner, newTestPhotos(t), fstest.MapFS{"index.html": {Data: []byte(fixtureIndex)}})
|
||||
newTestDinners(t), svc, newTestPlan(t), newTestShopping(t), newTestGoogle(t), runner, newTestPhotos(t), fstest.MapFS{"index.html": {Data: []byte(fixtureIndex)}})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
@@ -1,87 +0,0 @@
|
||||
// Package vapid generates and validates the VAPID key pair Web Push signs
|
||||
// with (RFC 8292): a P-256 (ES256) key pair in the encoding webpush-go
|
||||
// reads. Both keys are unpadded base64url: the public key is the 65-byte
|
||||
// uncompressed point and the private key the 32-byte scalar.
|
||||
package vapid
|
||||
|
||||
import (
|
||||
"crypto/ecdh"
|
||||
"crypto/rand"
|
||||
"encoding/base64"
|
||||
"errors"
|
||||
"fmt"
|
||||
)
|
||||
|
||||
// encoding is unpadded base64url. Strict refuses non-zero trailing bits, so
|
||||
// every key has exactly one accepted spelling.
|
||||
var encoding = base64.RawURLEncoding.Strict()
|
||||
|
||||
const (
|
||||
publicKeySize = 65 // 0x04 || X || Y
|
||||
privateKeySize = 32
|
||||
)
|
||||
|
||||
// KeyPair is a VAPID key pair in its stored encoding.
|
||||
type KeyPair struct {
|
||||
PublicKey string
|
||||
PrivateKey string
|
||||
}
|
||||
|
||||
// Generate returns a new random key pair.
|
||||
func Generate() (KeyPair, error) {
|
||||
k, err := ecdh.P256().GenerateKey(rand.Reader)
|
||||
if err != nil {
|
||||
return KeyPair{}, fmt.Errorf("vapid: generate key: %w", err)
|
||||
}
|
||||
return KeyPair{
|
||||
PublicKey: encoding.EncodeToString(k.PublicKey().Bytes()),
|
||||
PrivateKey: encoding.EncodeToString(k.Bytes()),
|
||||
}, nil
|
||||
}
|
||||
|
||||
// ParsePublicKey parses an encoded public key, which must be a point on
|
||||
// P-256.
|
||||
func ParsePublicKey(s string) (*ecdh.PublicKey, error) {
|
||||
b, err := encoding.DecodeString(s)
|
||||
if err != nil {
|
||||
return nil, errors.New("not unpadded base64url")
|
||||
}
|
||||
if len(b) != publicKeySize || b[0] != 0x04 {
|
||||
return nil, fmt.Errorf("want the %d-byte uncompressed P-256 point, got %d bytes", publicKeySize, len(b))
|
||||
}
|
||||
k, err := ecdh.P256().NewPublicKey(b)
|
||||
if err != nil {
|
||||
return nil, errors.New("not a point on P-256")
|
||||
}
|
||||
return k, nil
|
||||
}
|
||||
|
||||
// ParsePrivateKey parses an encoded private key. Its errors never quote the
|
||||
// key.
|
||||
func ParsePrivateKey(s string) (*ecdh.PrivateKey, error) {
|
||||
b, err := encoding.DecodeString(s)
|
||||
if err != nil {
|
||||
return nil, errors.New("not unpadded base64url")
|
||||
}
|
||||
if len(b) != privateKeySize {
|
||||
return nil, fmt.Errorf("want a %d-byte P-256 scalar, got %d bytes", privateKeySize, len(b))
|
||||
}
|
||||
k, err := ecdh.P256().NewPrivateKey(b)
|
||||
if err != nil {
|
||||
return nil, errors.New("not a valid P-256 scalar")
|
||||
}
|
||||
return k, nil
|
||||
}
|
||||
|
||||
// ErrMismatch is returned by CheckPair when the private key's public key is
|
||||
// not the given public key.
|
||||
var ErrMismatch = errors.New("private key does not belong to the public key")
|
||||
|
||||
// CheckPair reports whether private is the private key of public, by
|
||||
// deriving the public point from private and comparing.
|
||||
func CheckPair(public *ecdh.PublicKey, private *ecdh.PrivateKey) error {
|
||||
if !private.PublicKey().Equal(public) {
|
||||
return ErrMismatch
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -1,92 +0,0 @@
|
||||
package vapid
|
||||
|
||||
import (
|
||||
"encoding/base64"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestGenerateRoundTrips(t *testing.T) {
|
||||
t.Parallel()
|
||||
a, err := Generate()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
b, err := Generate()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if a == b {
|
||||
t.Fatal("two generated key pairs are equal")
|
||||
}
|
||||
// 65 and 32 bytes in unpadded base64url.
|
||||
if len(a.PublicKey) != 87 || len(a.PrivateKey) != 43 {
|
||||
t.Errorf("key lengths = %d, %d; want 87, 43", len(a.PublicKey), len(a.PrivateKey))
|
||||
}
|
||||
pub, err := ParsePublicKey(a.PublicKey)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
priv, err := ParsePrivateKey(a.PrivateKey)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := CheckPair(pub, priv); err != nil {
|
||||
t.Errorf("CheckPair of a generated pair: %v", err)
|
||||
}
|
||||
other, err := ParsePrivateKey(b.PrivateKey)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := CheckPair(pub, other); err != ErrMismatch {
|
||||
t.Errorf("CheckPair of mismatched keys = %v, want ErrMismatch", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseRejectsMalformedKeys(t *testing.T) {
|
||||
t.Parallel()
|
||||
pair, err := Generate()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
raw := func(b []byte) string { return base64.RawURLEncoding.EncodeToString(b) }
|
||||
compressed, _ := base64.RawURLEncoding.DecodeString(pair.PublicKey)
|
||||
compressed = append([]byte{0x02}, compressed[1:33]...)
|
||||
offCurve := make([]byte, 65)
|
||||
offCurve[0] = 0x04
|
||||
offCurve[64] = 1
|
||||
order := []byte{ // the P-256 group order n, not a valid scalar
|
||||
0xff, 0xff, 0xff, 0xff, 0x00, 0x00, 0x00, 0x00, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff,
|
||||
0xbc, 0xe6, 0xfa, 0xad, 0xa7, 0x17, 0x9e, 0x84, 0xf3, 0xb9, 0xca, 0xc2, 0xfc, 0x63, 0x25, 0x51,
|
||||
}
|
||||
|
||||
for name, s := range map[string]string{
|
||||
"empty": "",
|
||||
"padded": pair.PublicKey + "=",
|
||||
"standard alpha": strings.NewReplacer("-", "+", "_", "/").Replace(pair.PublicKey) + "+/",
|
||||
"compressed": raw(compressed),
|
||||
"off curve": raw(offCurve),
|
||||
"private key": pair.PrivateKey,
|
||||
} {
|
||||
if _, err := ParsePublicKey(s); err == nil {
|
||||
t.Errorf("ParsePublicKey(%s) succeeded", name)
|
||||
}
|
||||
}
|
||||
for name, s := range map[string]string{
|
||||
"empty": "",
|
||||
"padded": pair.PrivateKey + "=",
|
||||
"PEM": "-----BEGIN EC PRIVATE KEY-----",
|
||||
"zero": raw(make([]byte, 32)),
|
||||
"group size": raw(order),
|
||||
"public key": pair.PublicKey,
|
||||
} {
|
||||
_, err := ParsePrivateKey(s)
|
||||
if err == nil {
|
||||
t.Errorf("ParsePrivateKey(%s) succeeded", name)
|
||||
continue
|
||||
}
|
||||
if s != "" && strings.Contains(err.Error(), s) {
|
||||
t.Errorf("ParsePrivateKey(%s) error quotes the key: %v", name, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,20 +0,0 @@
|
||||
-- name: UpsertPushSubscription :one
|
||||
-- Stores a browser's push subscription, keyed by its endpoint. Subscribing
|
||||
-- an endpoint already stored replaces its keys and user agent and clears
|
||||
-- its failure count, keeping its id, created_at and last_success_at.
|
||||
INSERT INTO push_subscriptions (
|
||||
id, endpoint, p256dh, auth, user_agent, failure_count, created_at, updated_at
|
||||
) VALUES (
|
||||
?, ?, ?, ?, ?, 0, ?, ?
|
||||
)
|
||||
ON CONFLICT (endpoint) DO UPDATE SET
|
||||
p256dh = excluded.p256dh,
|
||||
auth = excluded.auth,
|
||||
user_agent = excluded.user_agent,
|
||||
failure_count = 0,
|
||||
updated_at = excluded.updated_at
|
||||
RETURNING *;
|
||||
|
||||
-- name: DeletePushSubscription :execrows
|
||||
DELETE FROM push_subscriptions
|
||||
WHERE endpoint = ?;
|
||||
@@ -33,7 +33,6 @@ const SCREENS = {
|
||||
'/onboarding': true,
|
||||
'/plan': true,
|
||||
'/plan/shopping': true,
|
||||
'/plan/shopping/archive': true,
|
||||
'/discover': true,
|
||||
'/settings': true,
|
||||
'/settings/ai': true,
|
||||
@@ -131,18 +130,6 @@ describe('App routes', () => {
|
||||
expect(await screen.findByRole('checkbox', { name: 'Ground beef' })).toBeChecked()
|
||||
})
|
||||
|
||||
it('/plan/shopping/archive renders the Supply archive inside the shell', async () => {
|
||||
mockViewport(390)
|
||||
stubFetchByPath({
|
||||
...SIGNED_IN,
|
||||
'/api/v1/shopping/archive?by=week': () => jsonResponse(200, { by: 'week', periods: [] }),
|
||||
})
|
||||
renderAt('/plan/shopping/archive')
|
||||
const heading = await screen.findByRole('heading', { level: 1, name: 'Supply archive' })
|
||||
expect(screen.getByRole('main')).toContainElement(heading)
|
||||
expect(await screen.findByText('Nothing archived yet')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('/discover renders the Side quests deck inside the shell', async () => {
|
||||
mockViewport(390)
|
||||
stubFetchByPath({
|
||||
|
||||
@@ -17,7 +17,6 @@ import { RegularsSettingsPage } from './settings/RegularsSettingsPage.tsx'
|
||||
import { SettingsPage } from './settings/SettingsPage.tsx'
|
||||
import { AppShell } from './shell/AppShell.tsx'
|
||||
import { SupplyRunSync } from './shopping/api.ts'
|
||||
import { SupplyArchivePage } from './shopping/SupplyArchivePage.tsx'
|
||||
import { SupplyRunPage } from './shopping/SupplyRunPage.tsx'
|
||||
|
||||
// Every route the app serves, with the heading its placeholder page shows.
|
||||
@@ -43,7 +42,6 @@ export const routes = [
|
||||
const screens: Partial<Record<(typeof routes)[number]['path'], ReactNode>> = {
|
||||
'/plan': <PlanPage />,
|
||||
'/plan/shopping': <SupplyRunPage />,
|
||||
'/plan/shopping/archive': <SupplyArchivePage />,
|
||||
'/discover': <DiscoverPage />,
|
||||
'/settings': <SettingsPage />,
|
||||
'/settings/ai': <AISettingsPage />,
|
||||
|
||||
@@ -414,16 +414,6 @@
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
/* What became of the week's Supply run after a push. */
|
||||
.pushed {
|
||||
margin: 0;
|
||||
font-family: var(--font-body);
|
||||
font-size: 14px;
|
||||
font-weight: 700;
|
||||
text-align: center;
|
||||
color: var(--color-neutral-700);
|
||||
}
|
||||
|
||||
/* The way to the week's Supply run under the Draft board on mobile. */
|
||||
.shoppingLink {
|
||||
display: inline-flex;
|
||||
|
||||
+11
-288
@@ -1,10 +1,9 @@
|
||||
import { QueryClientProvider, type QueryClient } from '@tanstack/react-query'
|
||||
import { QueryClientProvider } from '@tanstack/react-query'
|
||||
import { act, fireEvent, render, screen, waitFor, within } from '@testing-library/react'
|
||||
import { Link, MemoryRouter, Route, Routes, useLocation } from 'react-router'
|
||||
import { MemoryRouter, Route, Routes, useLocation } from 'react-router'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi, type Mock } from 'vitest'
|
||||
import { ApiProvider, createApiClient, createQueryClient, createVerifiedSession } from '../api/index.ts'
|
||||
import { createNetworkStatus } from '../pwa/networkStatus.ts'
|
||||
import { SupplyRunPage } from '../shopping/SupplyRunPage.tsx'
|
||||
import { cacheFallbackHeader } from '../sw/routes.ts'
|
||||
import { HOUSEHOLD_SETTINGS } from '../test/ApiProviders.tsx'
|
||||
import { declaredStyle } from '../test/cssRules.ts'
|
||||
@@ -53,33 +52,9 @@ function planBody(status: string, names = NAMES) {
|
||||
}
|
||||
|
||||
// syncedNow is the plan of the week of Oct 5 as a push answers it: synced
|
||||
// at the tests' noon, with what became of the week's Supply run, made
|
||||
// unless told otherwise.
|
||||
function syncedNow(supplyRun: { status: string; error: { code: string; message: string; retryable: boolean } | null } = GENERATED) {
|
||||
return { ...planBody('synced'), calendar_synced_at: '2026-10-07T16:00:00Z', supply_run: supplyRun }
|
||||
}
|
||||
|
||||
// The Supply run outcomes a push answers with, as the server sends them.
|
||||
const GENERATED = { status: 'generated', error: null }
|
||||
const ARCHIVED = {
|
||||
status: 'archived',
|
||||
error: { code: 'archived', message: 'the Supply run is in the Supply archive', retryable: false },
|
||||
}
|
||||
const PLAN_CHANGED = {
|
||||
status: 'failed',
|
||||
error: {
|
||||
code: 'plan_changed',
|
||||
message: 'the plan is on Calendar as pushed, but it was edited before its Supply run was made, so no Supply run was made from it; push again',
|
||||
retryable: true,
|
||||
},
|
||||
}
|
||||
const SUPPLY_RUN_FAILED = {
|
||||
status: 'failed',
|
||||
error: {
|
||||
code: 'supply_run_failed',
|
||||
message: 'the plan is on Calendar, but its Supply run could not be made; make it again from the Supply run',
|
||||
retryable: true,
|
||||
},
|
||||
// at the tests' noon.
|
||||
function syncedNow() {
|
||||
return { ...planBody('synced'), calendar_synced_at: '2026-10-07T16:00:00Z' }
|
||||
}
|
||||
|
||||
// deferred is a response the test sends when it chooses.
|
||||
@@ -160,17 +135,17 @@ function planReads(fetchMock: Mock<typeof fetch>) {
|
||||
}
|
||||
|
||||
// renderPage loads /me (for the CSRF token every write needs) and renders
|
||||
// the Plan page, over queryClient, with shopping as the Supply run screen.
|
||||
async function renderPage(queryClient: QueryClient = createQueryClient(), shopping = <Opened />) {
|
||||
// the Plan page.
|
||||
async function renderPage() {
|
||||
const api = createApiClient(fakeLocation('/plan'), createNetworkStatus(window), createVerifiedSession(localStorage))
|
||||
await api.loadMe()
|
||||
return render(
|
||||
<ApiProvider client={api}>
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<QueryClientProvider client={createQueryClient()}>
|
||||
<MemoryRouter initialEntries={['/plan']}>
|
||||
<Routes>
|
||||
<Route path="/plan" element={<PlanPage />} />
|
||||
<Route path="/plan/shopping" element={shopping} />
|
||||
<Route path="/plan/shopping" element={<Opened />} />
|
||||
</Routes>
|
||||
</MemoryRouter>
|
||||
</QueryClientProvider>
|
||||
@@ -178,32 +153,10 @@ async function renderPage(queryClient: QueryClient = createQueryClient(), shoppi
|
||||
)
|
||||
}
|
||||
|
||||
// Opened stands in for the screen a link opened, saying where it is, with
|
||||
// a link back to the Plan.
|
||||
// Opened stands in for the screen a link opened, saying where it is.
|
||||
function Opened() {
|
||||
const { pathname, search } = useLocation()
|
||||
return (
|
||||
<>
|
||||
<p>Opened {pathname + search}</p>
|
||||
<Link to="/plan">Back to the Plan</Link>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
// pushesKept counts the pushes of the week of Oct 5 the mutation cache of
|
||||
// queryClient keeps.
|
||||
function pushesKept(queryClient: QueryClient) {
|
||||
return queryClient.getMutationCache().findAll({ mutationKey: ['plans', '2026-10-05', 'push'] }).length
|
||||
}
|
||||
|
||||
// SupplyRunScreen is the Supply run screen, with a link back to the Plan.
|
||||
function SupplyRunScreen() {
|
||||
return (
|
||||
<>
|
||||
<SupplyRunPage />
|
||||
<Link to="/plan">Back to the Plan</Link>
|
||||
</>
|
||||
)
|
||||
return <p>Opened {pathname + search}</p>
|
||||
}
|
||||
|
||||
// supplyRun is the Supply run of the week of Oct 5 with needed of its 32
|
||||
@@ -467,236 +420,6 @@ describe('PlanPage', () => {
|
||||
expect(planReads(fetchMock)).toBe(2)
|
||||
})
|
||||
|
||||
it("reads the week's Supply run again once a push has made it, and says it was made", async () => {
|
||||
let run = () => errorResponse(404, 'not_found')
|
||||
const fetchMock = stubServer({
|
||||
[`GET ${THIS_WEEK}`]: () => jsonResponse(200, planBody('draft')),
|
||||
[`POST ${PUSH}`]: () => {
|
||||
run = () => jsonResponse(200, supplyRun(28))
|
||||
return jsonResponse(200, syncedNow(GENERATED))
|
||||
},
|
||||
'GET /api/v1/shopping/2026-10-05': () => run(),
|
||||
})
|
||||
await renderPage()
|
||||
|
||||
expect(await screen.findByRole('link', { name: 'Shopping list' })).toBeInTheDocument()
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Looks good — push to Calendar' }))
|
||||
expect(await screen.findByText('Synced to Google Calendar just now')).toBeInTheDocument()
|
||||
expect(screen.getByRole('status')).toHaveTextContent("Made this week's Supply run from the plan.")
|
||||
// The list made is read at once, not after the cached 404 goes stale.
|
||||
expect(await screen.findByRole('link', { name: 'Shopping list · 28' })).toBeInTheDocument()
|
||||
expect(fetchMock.mock.calls.filter(([input]) => String(input) === '/api/v1/shopping/2026-10-05')).toHaveLength(2)
|
||||
expect(screen.queryByRole('alert')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('says a Supply run in the Supply archive was left as it was', async () => {
|
||||
stubServer({
|
||||
[`GET ${THIS_WEEK}`]: () => jsonResponse(200, planBody('draft')),
|
||||
[`POST ${PUSH}`]: () => jsonResponse(200, syncedNow(ARCHIVED)),
|
||||
})
|
||||
await renderPage()
|
||||
|
||||
fireEvent.click(await screen.findByRole('button', { name: 'Looks good — push to Calendar' }))
|
||||
expect(await screen.findByText('Synced to Google Calendar just now')).toBeInTheDocument()
|
||||
expect(screen.getByRole('status')).toHaveTextContent("This week's Supply run is in the Supply archive, so it was left as it was.")
|
||||
expect(screen.queryByRole('alert')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('says no Supply run was made when the plan changed after it was pushed, reads the plan again, and pushes again on Retry', async () => {
|
||||
let plan = planBody('draft')
|
||||
let push = () => {
|
||||
plan = planBody('changed', ['Lasagna', ...NAMES.slice(1)])
|
||||
return jsonResponse(200, syncedNow(PLAN_CHANGED))
|
||||
}
|
||||
const fetchMock = stubServer({
|
||||
[`GET ${THIS_WEEK}`]: () => jsonResponse(200, plan),
|
||||
[`POST ${PUSH}`]: () => push(),
|
||||
})
|
||||
await renderPage()
|
||||
|
||||
fireEvent.click(await screen.findByRole('button', { name: 'Looks good — push to Calendar' }))
|
||||
const alert = await screen.findByRole('alert')
|
||||
expect(alert).toHaveTextContent(
|
||||
'Pushed to Calendar, but the plan changed before its Supply run was made, so no shopping list was made from it; push it again.',
|
||||
)
|
||||
expect(screen.queryByRole('status')).not.toBeInTheDocument()
|
||||
// The plan edited meanwhile is the one shown, not the one pushed.
|
||||
expect(await screen.findByText('Lasagna')).toBeInTheDocument()
|
||||
expect(screen.getByText('Changes not pushed to Google Calendar')).toBeInTheDocument()
|
||||
expect(planReads(fetchMock)).toBe(2)
|
||||
|
||||
push = () => jsonResponse(200, syncedNow(GENERATED))
|
||||
fireEvent.click(within(alert).getByRole('button', { name: 'Retry' }))
|
||||
expect(await screen.findByRole('status')).toHaveTextContent("Made this week's Supply run from the plan.")
|
||||
expect(screen.queryByRole('alert')).not.toBeInTheDocument()
|
||||
expect(posts(fetchMock)).toEqual([`${PUSH} undefined`, `${PUSH} undefined`])
|
||||
})
|
||||
|
||||
it('says the Supply run could not be made after the push, and makes the shopping list again', async () => {
|
||||
let generate = () => errorResponse(500, 'internal')
|
||||
const fetchMock = stubServer({
|
||||
[`GET ${THIS_WEEK}`]: () => jsonResponse(200, planBody('draft')),
|
||||
[`POST ${PUSH}`]: () => jsonResponse(200, syncedNow(SUPPLY_RUN_FAILED)),
|
||||
'POST /api/v1/shopping/2026-10-05/generate': () => generate(),
|
||||
})
|
||||
await renderPage()
|
||||
|
||||
fireEvent.click(await screen.findByRole('button', { name: 'Looks good — push to Calendar' }))
|
||||
// Calendar has the plan, but the push is not reported a quiet success.
|
||||
expect(await screen.findByText('Synced to Google Calendar just now')).toBeInTheDocument()
|
||||
const alert = await screen.findByRole('alert')
|
||||
expect(alert).toHaveTextContent("Pushed to Calendar, but this week's Supply run couldn't be made.")
|
||||
expect(screen.queryByRole('status')).not.toBeInTheDocument()
|
||||
|
||||
fireEvent.click(within(alert).getByRole('button', { name: 'Make the shopping list' }))
|
||||
expect(
|
||||
await screen.findByText("Pushed to Calendar, but this week's Supply run couldn't be made. Couldn't make the shopping list. internal message"),
|
||||
).toBeInTheDocument()
|
||||
|
||||
generate = () => jsonResponse(200, { ...supplyRun(28), skipped: [] })
|
||||
fireEvent.click(within(screen.getByRole('alert')).getByRole('button', { name: 'Make the shopping list' }))
|
||||
expect(await screen.findByRole('status')).toHaveTextContent("Made this week's Supply run from the plan.")
|
||||
expect(screen.queryByRole('alert')).not.toBeInTheDocument()
|
||||
expect(screen.getByRole('link', { name: 'Shopping list · 28' })).toBeInTheDocument()
|
||||
expect(posts(fetchMock)).toEqual([
|
||||
`${PUSH} undefined`,
|
||||
'/api/v1/shopping/2026-10-05/generate undefined',
|
||||
'/api/v1/shopping/2026-10-05/generate undefined',
|
||||
])
|
||||
})
|
||||
|
||||
it('no longer says the Supply run could not be made once the shopping list is made, after leaving the Plan and coming back', async () => {
|
||||
stubServer({
|
||||
[`GET ${THIS_WEEK}`]: () => jsonResponse(200, planBody('draft')),
|
||||
[`POST ${PUSH}`]: () => jsonResponse(200, syncedNow(SUPPLY_RUN_FAILED)),
|
||||
'POST /api/v1/shopping/2026-10-05/generate': () => jsonResponse(200, { ...supplyRun(28), skipped: [] }),
|
||||
})
|
||||
await renderPage()
|
||||
|
||||
fireEvent.click(await screen.findByRole('button', { name: 'Looks good — push to Calendar' }))
|
||||
const alert = await screen.findByRole('alert')
|
||||
expect(alert).toHaveTextContent("Pushed to Calendar, but this week's Supply run couldn't be made.")
|
||||
|
||||
// A Supply run never made is still said after leaving the Plan and
|
||||
// coming back.
|
||||
fireEvent.click(screen.getByRole('link', { name: 'Shopping list' }))
|
||||
fireEvent.click(await screen.findByRole('link', { name: 'Back to the Plan' }))
|
||||
expect(await screen.findByRole('alert')).toHaveTextContent("Pushed to Calendar, but this week's Supply run couldn't be made.")
|
||||
|
||||
fireEvent.click(within(screen.getByRole('alert')).getByRole('button', { name: 'Make the shopping list' }))
|
||||
expect(await screen.findByRole('status')).toHaveTextContent("Made this week's Supply run from the plan.")
|
||||
|
||||
// Once made, it is not said again to be unmade.
|
||||
fireEvent.click(screen.getByRole('link', { name: 'Shopping list · 28' }))
|
||||
fireEvent.click(await screen.findByRole('link', { name: 'Back to the Plan' }))
|
||||
expect(await screen.findByRole('link', { name: 'Shopping list · 28' })).toBeInTheDocument()
|
||||
expect(screen.queryByRole('alert')).not.toBeInTheDocument()
|
||||
expect(screen.queryByRole('button', { name: 'Make the shopping list' })).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('still says the Supply run could not be made when making the shopping list fails after leaving the week', async () => {
|
||||
const made = deferred()
|
||||
stubServer({
|
||||
[`GET ${THIS_WEEK}`]: () => jsonResponse(200, planBody('draft')),
|
||||
[`GET ${NEXT_WEEK}`]: () => errorResponse(404, 'not_found'),
|
||||
[`POST ${PUSH}`]: () => jsonResponse(200, syncedNow(SUPPLY_RUN_FAILED)),
|
||||
'POST /api/v1/shopping/2026-10-05/generate': () => made.response,
|
||||
})
|
||||
await renderPage()
|
||||
|
||||
fireEvent.click(await screen.findByRole('button', { name: 'Looks good — push to Calendar' }))
|
||||
fireEvent.click(within(await screen.findByRole('alert')).getByRole('button', { name: 'Make the shopping list' }))
|
||||
expect(await screen.findByRole('alert')).toHaveTextContent('Making the shopping list…')
|
||||
|
||||
// The week is left while the shopping list is being made, which fails.
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Next week' }))
|
||||
expect(await screen.findByRole('button', { name: 'Draft this week' })).toBeInTheDocument()
|
||||
await act(async () => made.send(errorResponse(500, 'internal')))
|
||||
|
||||
// Coming back, the Supply run is still said to be unmade, and can be
|
||||
// made again.
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Previous week' }))
|
||||
const alert = await screen.findByRole('alert')
|
||||
expect(alert).toHaveTextContent("Pushed to Calendar, but this week's Supply run couldn't be made.")
|
||||
expect(within(alert).getByRole('button', { name: 'Make the shopping list' })).toBeEnabled()
|
||||
})
|
||||
|
||||
it('no longer says the Supply run could not be made once the shopping list is made after leaving the week', async () => {
|
||||
const made = deferred()
|
||||
stubServer({
|
||||
[`GET ${THIS_WEEK}`]: () => jsonResponse(200, planBody('draft')),
|
||||
[`GET ${NEXT_WEEK}`]: () => errorResponse(404, 'not_found'),
|
||||
[`POST ${PUSH}`]: () => jsonResponse(200, syncedNow(SUPPLY_RUN_FAILED)),
|
||||
'POST /api/v1/shopping/2026-10-05/generate': () => made.response,
|
||||
})
|
||||
const queryClient = createQueryClient()
|
||||
await renderPage(queryClient)
|
||||
|
||||
fireEvent.click(await screen.findByRole('button', { name: 'Looks good — push to Calendar' }))
|
||||
fireEvent.click(within(await screen.findByRole('alert')).getByRole('button', { name: 'Make the shopping list' }))
|
||||
expect(await screen.findByRole('alert')).toHaveTextContent('Making the shopping list…')
|
||||
|
||||
// The push is kept while the week is left, until the list is made: it
|
||||
// is then settled, not merely swept with the week.
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Next week' }))
|
||||
expect(await screen.findByRole('button', { name: 'Draft this week' })).toBeInTheDocument()
|
||||
expect(pushesKept(queryClient)).toBe(1)
|
||||
await act(async () => made.send(jsonResponse(200, { ...supplyRun(28), skipped: [] })))
|
||||
expect(pushesKept(queryClient)).toBe(0)
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Previous week' }))
|
||||
expect(await screen.findByText('Beef tacos')).toBeInTheDocument()
|
||||
expect(screen.queryByRole('alert')).not.toBeInTheDocument()
|
||||
expect(screen.queryByRole('button', { name: 'Make the shopping list' })).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('no longer says the Supply run could not be made once the shopping list is made on the Supply run screen', async () => {
|
||||
stubServer({
|
||||
[`GET ${THIS_WEEK}`]: () => jsonResponse(200, planBody('draft')),
|
||||
[`POST ${PUSH}`]: () => jsonResponse(200, syncedNow(SUPPLY_RUN_FAILED)),
|
||||
'POST /api/v1/shopping/2026-10-05/generate': () => jsonResponse(200, { ...supplyRun(28), skipped: [] }),
|
||||
})
|
||||
const queryClient = createQueryClient()
|
||||
await renderPage(queryClient, <SupplyRunScreen />)
|
||||
|
||||
fireEvent.click(await screen.findByRole('button', { name: 'Looks good — push to Calendar' }))
|
||||
expect(await screen.findByRole('alert')).toHaveTextContent("Pushed to Calendar, but this week's Supply run couldn't be made.")
|
||||
|
||||
// The list is made on the Supply run screen, not from the Plan's alert.
|
||||
fireEvent.click(screen.getByRole('link', { name: 'Shopping list' }))
|
||||
fireEvent.click(await screen.findByRole('button', { name: 'Make the shopping list' }))
|
||||
await waitFor(() => expect(screen.queryByRole('button', { name: 'Make the shopping list' })).not.toBeInTheDocument())
|
||||
expect(pushesKept(queryClient)).toBe(0)
|
||||
|
||||
fireEvent.click(screen.getByRole('link', { name: 'Back to the Plan' }))
|
||||
expect(await screen.findByRole('link', { name: 'Shopping list · 28' })).toBeInTheDocument()
|
||||
expect(screen.queryByRole('alert')).not.toBeInTheDocument()
|
||||
expect(screen.queryByRole('button', { name: 'Make the shopping list' })).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('still says the Supply run could not be made when making it on the Supply run screen fails', async () => {
|
||||
stubServer({
|
||||
[`GET ${THIS_WEEK}`]: () => jsonResponse(200, planBody('draft')),
|
||||
[`POST ${PUSH}`]: () => jsonResponse(200, syncedNow(SUPPLY_RUN_FAILED)),
|
||||
'POST /api/v1/shopping/2026-10-05/generate': () => errorResponse(500, 'internal'),
|
||||
})
|
||||
const queryClient = createQueryClient()
|
||||
await renderPage(queryClient, <SupplyRunScreen />)
|
||||
|
||||
fireEvent.click(await screen.findByRole('button', { name: 'Looks good — push to Calendar' }))
|
||||
expect(await screen.findByRole('alert')).toHaveTextContent("Pushed to Calendar, but this week's Supply run couldn't be made.")
|
||||
|
||||
fireEvent.click(screen.getByRole('link', { name: 'Shopping list' }))
|
||||
fireEvent.click(await screen.findByRole('button', { name: 'Make the shopping list' }))
|
||||
expect(await screen.findByRole('alert')).toHaveTextContent("Couldn't make the shopping list.")
|
||||
expect(pushesKept(queryClient)).toBe(1)
|
||||
|
||||
fireEvent.click(screen.getByRole('link', { name: 'Back to the Plan' }))
|
||||
const alert = await screen.findByRole('alert')
|
||||
expect(alert).toHaveTextContent("Pushed to Calendar, but this week's Supply run couldn't be made.")
|
||||
expect(within(alert).getByRole('button', { name: 'Make the shopping list' })).toBeEnabled()
|
||||
})
|
||||
|
||||
it('pushes nothing again until the changed plan is read and shown', async () => {
|
||||
let read: () => Response | Promise<Response> = () => jsonResponse(200, planBody('draft'))
|
||||
let push = () => errorResponse(409, 'plan_changed')
|
||||
|
||||
@@ -7,7 +7,7 @@ import { QUEST_LOG_KEY } from '../discover/api.ts'
|
||||
import { Confirm } from '../settings/LeaveGuard.tsx'
|
||||
import { methodLabels } from '../settings/RegularsSettingsPage.tsx'
|
||||
import { formatClockTime, useHouseholdSettings } from '../settings/api.ts'
|
||||
import { supplyRunScreen, useGenerateSupplyRun, useSupplyRun } from '../shopping/api.ts'
|
||||
import { supplyRunScreen, useSupplyRun } from '../shopping/api.ts'
|
||||
import { useBreakpoint, type Breakpoint } from '../shell/useBreakpoint.ts'
|
||||
import { Button, type ButtonVariant } from '../ui/Button.tsx'
|
||||
import buttonStyles from '../ui/Button.module.css'
|
||||
@@ -31,7 +31,6 @@ import {
|
||||
type PlanDay,
|
||||
type PlanDinner,
|
||||
type PlanStatus,
|
||||
type PushedSupplyRun,
|
||||
type Swap,
|
||||
} from './api.ts'
|
||||
import { DragAnnouncer, DROP_DATE, DropBoard, PlanDragProvider, useDropTarget } from './drag.tsx'
|
||||
@@ -89,13 +88,6 @@ const syncStatuses: Record<PlanStatus, string> = {
|
||||
changed: 'Changes not pushed to Google Calendar',
|
||||
}
|
||||
|
||||
// supplyRunOutcomes says what became of the week's Supply run after a push
|
||||
// that made it or left it as it was.
|
||||
const supplyRunOutcomes: Record<Exclude<PushedSupplyRun['status'], 'failed'>, string> = {
|
||||
generated: "Made this week's Supply run from the plan.",
|
||||
archived: "This week's Supply run is in the Supply archive, so it was left as it was.",
|
||||
}
|
||||
|
||||
// duration is minutes as the plan manager's cards say it: "45 min", "6 hr",
|
||||
// "1 hr 30 min", with no-break spaces so a narrow card wraps the line
|
||||
// between the method and the duration, never inside the duration.
|
||||
@@ -246,9 +238,7 @@ function SkeletonTiles({ label, layout, children }: { label: string; layout: Bre
|
||||
//
|
||||
// Push to Calendar, on either layout, sends the plan shown and shows it as
|
||||
// synced once the server answers; a failure says why, linking to the Google
|
||||
// Calendar settings when only they can fix it. What became of the week's
|
||||
// Supply run is said too: made, left as it was in the Supply archive, or
|
||||
// not made, with the way to make it.
|
||||
// Calendar settings when only they can fix it.
|
||||
function WeekBoard({
|
||||
weekStart,
|
||||
timezone,
|
||||
@@ -269,8 +259,6 @@ function WeekBoard({
|
||||
const swap = useSwapDay(weekStart)
|
||||
const push = usePushToCalendar(weekStart)
|
||||
const run = useSupplyRun(weekStart)
|
||||
// Makes the week's Supply run again after a push could not make it.
|
||||
const generate = useGenerateSupplyRun(weekStart)
|
||||
const pushing = push.pending
|
||||
// The date of the day being swapped, while the swap sheet is open.
|
||||
const [swappingDate, setSwappingDate] = useState<string | null>(null)
|
||||
@@ -340,7 +328,6 @@ function WeekBoard({
|
||||
if (!canPush) return
|
||||
swap.dismiss()
|
||||
push.reset()
|
||||
generate.reset()
|
||||
push.mutate()
|
||||
}
|
||||
|
||||
@@ -352,7 +339,6 @@ function WeekBoard({
|
||||
setJobId(null)
|
||||
swap.dismiss()
|
||||
push.reset()
|
||||
generate.reset()
|
||||
mutate(
|
||||
{ weekStart, confirm },
|
||||
{
|
||||
@@ -371,33 +357,8 @@ function WeekBoard({
|
||||
else start(false)
|
||||
}
|
||||
|
||||
const retryPush = cached ? () => void plan.refetch() : pushToCalendar
|
||||
// makeShoppingList makes the week's Supply run again after a push could
|
||||
// not make it. Once made, here or on the Supply run screen, the push is
|
||||
// settled (useGenerateSupplyRun). A failure is the mutation's state, which
|
||||
// generate.error reports.
|
||||
function makeShoppingList() {
|
||||
generate.mutate()
|
||||
}
|
||||
// A push's Supply run made again on the Plan stands for the push's, which
|
||||
// is settled.
|
||||
const supplyRun: PushedSupplyRun | null = generate.isSuccess ? { status: 'generated', error: null } : push.supplyRun
|
||||
const failure = push.error !== null ? (
|
||||
<PushFailure error={push.error} cached={cached} onRetry={retryPush} />
|
||||
) : supplyRun?.status === 'failed' ? (
|
||||
supplyRun.error.code === 'plan_changed' ? (
|
||||
<ErrorBanner onRetry={retryPush}>
|
||||
Pushed to Calendar, but the plan changed before its Supply run was made, so no shopping list was made from it; push
|
||||
it again.
|
||||
</ErrorBanner>
|
||||
) : (
|
||||
<ErrorBanner onRetry={generate.isPending ? undefined : makeShoppingList} retryLabel="Make the shopping list">
|
||||
Pushed to Calendar, but this week's Supply run couldn't be made.
|
||||
{generate.isPending
|
||||
? ' Making the shopping list…'
|
||||
: generate.isError && ` Couldn't make the shopping list. ${generate.error.message}`}
|
||||
</ErrorBanner>
|
||||
)
|
||||
<PushFailure error={push.error} cached={cached} onRetry={cached ? () => void plan.refetch() : pushToCalendar} />
|
||||
) : swap.failed !== null ? (
|
||||
<ErrorBanner onRetry={() => swapIn(swap.failed!.swap)}>
|
||||
Couldn't swap {weekdayName(swap.failed.swap.date)}'s dinner. {swap.failed.error.message}
|
||||
@@ -415,11 +376,6 @@ function WeekBoard({
|
||||
) : (
|
||||
<ErrorBanner onRetry={job.poll}>Couldn't check on the draft. {job.error.message}</ErrorBanner>
|
||||
)
|
||||
const pushed = supplyRun !== null && supplyRun.status !== 'failed' && (
|
||||
<p role="status" className={styles.pushed}>
|
||||
{supplyRunOutcomes[supplyRun.status]}
|
||||
</p>
|
||||
)
|
||||
// A failed read of the week's Supply run is shown, with Retry, after the
|
||||
// Draft board's Shopping list link or under the plan manager's board; the
|
||||
// Shopping list action still opens the Supply run. The count is that of a
|
||||
@@ -435,7 +391,6 @@ function WeekBoard({
|
||||
function swapIn(chosen: Swap) {
|
||||
setSwappingDate(null)
|
||||
push.reset()
|
||||
generate.reset()
|
||||
swap.mutate(chosen)
|
||||
}
|
||||
// dropIn swaps in a dinner dragged onto a day. A drag is not picked up,
|
||||
@@ -607,7 +562,6 @@ function WeekBoard({
|
||||
{header}
|
||||
{body}
|
||||
{failure}
|
||||
{pushed}
|
||||
{layout !== 'mobile' && supplyRunFailure}
|
||||
<Confirm
|
||||
open={confirming}
|
||||
|
||||
@@ -10,21 +10,6 @@ import { planKey, usePlan, usePushToCalendar, useSwapDay, type Plan, type PushTo
|
||||
|
||||
const WEEK = '2026-10-05'
|
||||
const PLAN = `/api/v1/plans/${WEEK}`
|
||||
|
||||
// GENERATED is a push answer's Supply run when the push made it.
|
||||
const GENERATED = { supply_run: { status: 'generated', error: null } }
|
||||
|
||||
// SUPPLY_RUN_FAILED is a push answer's Supply run when it could not be made.
|
||||
const SUPPLY_RUN_FAILED = {
|
||||
supply_run: {
|
||||
status: 'failed',
|
||||
error: {
|
||||
code: 'supply_run_failed',
|
||||
message: 'the plan is on Calendar, but its Supply run could not be made; make it again from the Supply run',
|
||||
retryable: true,
|
||||
},
|
||||
},
|
||||
}
|
||||
const MONDAY = `${PLAN}/days/2026-10-05`
|
||||
const FRIDAY = `${PLAN}/days/2026-10-09`
|
||||
const DAYS = ['2026-10-05', '2026-10-06', '2026-10-07', '2026-10-08', '2026-10-09', '2026-10-10', '2026-10-11']
|
||||
@@ -333,7 +318,7 @@ describe('week mutations', () => {
|
||||
|
||||
// The push succeeds while the third week is shown: it is left behind,
|
||||
// and the week's pending swap is not.
|
||||
await act(async () => nextPush.send(jsonResponse(200, { ...planBody(), week_start: NEXT, ...GENERATED })))
|
||||
await act(async () => nextPush.send(jsonResponse(200, { ...planBody(), week_start: NEXT })))
|
||||
await waitFor(() => expect(weekMutations(queryClient, NEXT)).toEqual([]))
|
||||
expect(weekMutations(queryClient, WEEK)).toEqual(['swap pending', 'swap success'])
|
||||
|
||||
@@ -353,43 +338,6 @@ describe('week mutations', () => {
|
||||
expect(weekMutations(queryClient, LATER)).toEqual([])
|
||||
})
|
||||
|
||||
it('keeps a push whose Supply run was not made, settled in a week no longer shown, to be shown on coming back', async () => {
|
||||
const nextPush = deferred()
|
||||
stubServer({
|
||||
[`GET ${PLAN}`]: () => jsonResponse(200, planBody()),
|
||||
[`GET /api/v1/plans/${NEXT}`]: () => jsonResponse(200, { ...planBody(), week_start: NEXT }),
|
||||
[`POST /api/v1/plans/${NEXT}/push-calendar`]: () => nextPush.reply,
|
||||
})
|
||||
const api = createApiClient(fakeLocation('/plan'), createNetworkStatus(window), createVerifiedSession(localStorage))
|
||||
await api.loadMe()
|
||||
const queryClient = createQueryClient()
|
||||
const wrapper = ({ children }: { children: ReactNode }) => (
|
||||
<ApiProvider client={api}>
|
||||
<QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
|
||||
</ApiProvider>
|
||||
)
|
||||
const hook = renderHook(({ week }) => ({ plan: usePlan(week), push: usePushToCalendar(week) }), {
|
||||
wrapper,
|
||||
initialProps: { week: NEXT },
|
||||
})
|
||||
await waitFor(() => expect(hook.result.current.plan.isSuccess).toBe(true))
|
||||
|
||||
// The push is pushed to Calendar while another week is shown, but its
|
||||
// Supply run is not made: it stays, though it succeeded.
|
||||
act(() => hook.result.current.push.mutate())
|
||||
hook.rerender({ week: WEEK })
|
||||
await waitFor(() => expect(hook.result.current.plan.isSuccess).toBe(true))
|
||||
const synced = { ...planBody(), week_start: NEXT, status: 'synced', calendar_synced_at: '2026-10-08T06:00:00Z' }
|
||||
await act(async () => nextPush.send(jsonResponse(200, { ...synced, ...SUPPLY_RUN_FAILED })))
|
||||
await waitFor(() => expect(weekMutations(queryClient, NEXT)).toEqual(['push success']))
|
||||
|
||||
// Coming back mounts the week's board again, as the Plan's does.
|
||||
hook.unmount()
|
||||
const back = renderHook(() => usePushToCalendar(NEXT), { wrapper })
|
||||
expect(back.result.current.error).toBeNull()
|
||||
expect(back.result.current.supplyRun).toEqual(SUPPLY_RUN_FAILED.supply_run)
|
||||
})
|
||||
|
||||
it('leaves behind what settles once Plan is left for another route, and keeps what is pending', async () => {
|
||||
const friday = deferred()
|
||||
const monday = deferred()
|
||||
@@ -446,7 +394,7 @@ describe('week mutations', () => {
|
||||
// The push succeeds: the plan as synced is recorded, and the push is
|
||||
// left behind.
|
||||
const synced = { ...planBody(), status: 'synced', calendar_synced_at: '2026-10-08T06:00:00Z' }
|
||||
await act(async () => push.send(jsonResponse(200, { ...synced, ...GENERATED })))
|
||||
await act(async () => push.send(jsonResponse(200, synced)))
|
||||
await waitFor(() => expect(weekMutations(queryClient, WEEK)).toEqual([]))
|
||||
expect(queryClient.getQueryData<Plan>(planKey(WEEK))).toMatchObject({ status: 'synced', calendar_synced_at: new Date('2026-10-08T06:00:00Z') })
|
||||
})
|
||||
|
||||
+11
-120
@@ -5,7 +5,6 @@ import { isRecord } from '../api/errors.ts'
|
||||
import { METHODS, oneOf, photoId, type Method } from '../dinners/api.ts'
|
||||
import { QUEST_LOG_KEY } from '../discover/api.ts'
|
||||
import { noteGoogleFailure } from '../settings/calendarApi.ts'
|
||||
import { supplyRunKey } from '../shopping/api.ts'
|
||||
|
||||
export const PLANS_PATH = '/api/v1/plans'
|
||||
|
||||
@@ -115,56 +114,6 @@ function parsePlan(where: string, weekStart: string, body: unknown, cached: bool
|
||||
}
|
||||
}
|
||||
|
||||
// SupplyRunError is the API error a Push to Calendar's Supply run carries
|
||||
// when it was not generated.
|
||||
export type SupplyRunError<C extends string> = { code: C; message: string; retryable: boolean }
|
||||
|
||||
// PushedSupplyRun says whether a Push to Calendar made the week's Supply
|
||||
// run: generated when it did; archived when the week's Supply run is in the
|
||||
// Supply archive and was left as it was; or failed when it was not made.
|
||||
// A failed one's error is plan_changed when the plan was edited after it
|
||||
// was pushed, and pushing again makes it, or supply_run_failed when it
|
||||
// could not be made, and making the shopping list again does.
|
||||
export type PushedSupplyRun =
|
||||
| { status: 'generated'; error: null }
|
||||
| { status: 'archived'; error: SupplyRunError<'archived'> }
|
||||
| { status: 'failed'; error: SupplyRunError<'plan_changed' | 'supply_run_failed'> }
|
||||
|
||||
// PushedPlan is the POST /api/v1/plans/{weekStart}/push-calendar answer:
|
||||
// the plan as synced, and what became of the week's Supply run.
|
||||
export type PushedPlan = { plan: Plan; supply_run: PushedSupplyRun }
|
||||
|
||||
const PUSHED_SUPPLY_RUN_STATUSES = ['generated', 'archived', 'failed'] as const
|
||||
const FAILED_SUPPLY_RUN_CODES = ['plan_changed', 'supply_run_failed'] as const
|
||||
|
||||
function parseSupplyRunError<C extends string>(where: string, codes: readonly C[], body: unknown): SupplyRunError<C> {
|
||||
if (!isRecord(body) || typeof body.message !== 'string' || typeof body.retryable !== 'boolean') {
|
||||
throw new Error(`${where}: supply_run has an error that is not an API error`)
|
||||
}
|
||||
return { code: oneOf(where, 'supply_run.error.code', codes, body.code), message: body.message, retryable: body.retryable }
|
||||
}
|
||||
|
||||
function parsePushedSupplyRun(where: string, body: unknown): PushedSupplyRun {
|
||||
if (!isRecord(body)) throw new Error(`${where}: response has no supply_run`)
|
||||
const status = oneOf(where, 'supply_run.status', PUSHED_SUPPLY_RUN_STATUSES, body.status)
|
||||
switch (status) {
|
||||
case 'generated':
|
||||
if (body.error !== null) throw new Error(`${where}: a generated supply_run has an error`)
|
||||
return { status, error: null }
|
||||
case 'archived':
|
||||
return { status, error: parseSupplyRunError(where, ['archived'] as const, body.error) }
|
||||
case 'failed':
|
||||
return { status, error: parseSupplyRunError(where, FAILED_SUPPLY_RUN_CODES, body.error) }
|
||||
}
|
||||
}
|
||||
|
||||
function parsePushedPlan(where: string, weekStart: string, body: unknown): PushedPlan {
|
||||
return {
|
||||
plan: parsePlan(where, weekStart, body, false),
|
||||
supply_run: parsePushedSupplyRun(where, isRecord(body) ? body.supply_run : undefined),
|
||||
}
|
||||
}
|
||||
|
||||
// parseDraftResult checks a draft_week job's result, which useJob passes on
|
||||
// unread, is the draft of the week starting on weekStart.
|
||||
export function parseDraftResult(where: string, weekStart: string, body: unknown): DraftResult {
|
||||
@@ -229,19 +178,6 @@ function sameDay(a: PlanDay, b: PlanDay): boolean {
|
||||
)
|
||||
}
|
||||
|
||||
// failed says whether a swap or push failed: a push whose Supply run was
|
||||
// not made failed too, though Calendar has the plan.
|
||||
function failed(mutation: Mutation): boolean {
|
||||
return mutation.state.status === 'error' || unmade(mutation)
|
||||
}
|
||||
|
||||
// unmade says whether a push succeeded but its Supply run was not made: the
|
||||
// week is on Calendar with no shopping list until one is made.
|
||||
function unmade(mutation: Mutation): boolean {
|
||||
const data = mutation.state.data as PushedPlan | undefined
|
||||
return mutation.state.status === 'success' && weekMutation(mutation)?.kind === 'push' && data?.supply_run.status === 'failed'
|
||||
}
|
||||
|
||||
// weekMutation is the kind and week of a swap or push of a week the mutation
|
||||
// cache keeps for it, or null for any other mutation.
|
||||
function weekMutation(mutation: Mutation): { kind: 'swap' | 'push'; week: string } | null {
|
||||
@@ -267,13 +203,9 @@ const weeksShown = new WeakMap<QueryClient, WeeksShown>()
|
||||
// earlier pending one must see, stay across leaving its week and coming
|
||||
// back. When one settles, the same is done, except that a week's latest,
|
||||
// when it failed, stays until a week is next shown, so that coming straight
|
||||
// back to it shows the failure. A week's latest push whose Supply run was
|
||||
// not made stays whatever is shown: the week has no shopping list until one
|
||||
// is made, which settles the push, or a push sent since replaces it, and
|
||||
// making it may still be under way when the week is left. The cache holds
|
||||
// at most the weeks shown's, those of weeks left pending, one push of each
|
||||
// week whose Supply run was not made, and one failure of each week whose
|
||||
// last pending one settled since a week was last shown.
|
||||
// back to it shows the failure. The cache holds at most the weeks shown's,
|
||||
// those of weeks left pending, and one failure of each week whose last
|
||||
// pending one settled since a week was last shown.
|
||||
function keepWeekMutations(queryClient: QueryClient): WeeksShown {
|
||||
const kept = weeksShown.get(queryClient)
|
||||
if (kept !== undefined) return kept
|
||||
@@ -293,7 +225,7 @@ function keepWeekMutations(queryClient: QueryClient): WeeksShown {
|
||||
if (week.some((mutation) => mutation.state.status === 'pending')) continue
|
||||
const latest = week.at(-1)
|
||||
for (const mutation of week) {
|
||||
if (mutation === latest && (unmade(mutation) || (settled && failed(mutation)))) continue
|
||||
if (settled && mutation === latest && mutation.state.status === 'error') continue
|
||||
mutations.remove(mutation)
|
||||
}
|
||||
}
|
||||
@@ -431,12 +363,10 @@ function pushKey(weekStart: string) {
|
||||
}
|
||||
|
||||
// PushToCalendar is the push of one week, as the mutation cache holds it:
|
||||
// whether it is pending, why the latest failed, if it failed, and what
|
||||
// became of the week's Supply run, if it succeeded.
|
||||
// whether it is pending, and why the latest failed, if it failed.
|
||||
export type PushToCalendar = {
|
||||
pending: boolean
|
||||
error: Error | null
|
||||
supplyRun: PushedSupplyRun | null
|
||||
mutate: () => void
|
||||
reset: () => void
|
||||
}
|
||||
@@ -450,19 +380,10 @@ export type PushToCalendar = {
|
||||
// the one shown. One Google refused the grant for reads the Google Calendar
|
||||
// connection again, which is now reconnect required.
|
||||
//
|
||||
// Once pushed, the server makes the week's Supply run from the plan pushed
|
||||
// and answers with its outcome, which supplyRun reports: a failed one is
|
||||
// never a quiet success. The week's Supply run is read again, whatever the
|
||||
// outcome, so the list shown is the server's. A Supply run not made because
|
||||
// the plan was edited after it was pushed reads the plan again too, as a
|
||||
// push refused for that does.
|
||||
//
|
||||
// The week's pushes live in the mutation cache, not in the component, as its
|
||||
// swaps do, so leaving the week and coming back keeps one pending. reset
|
||||
// leaves settled pushes behind; it is refused while one is pending. A push
|
||||
// whose Supply run was not made is left behind once the list is made
|
||||
// (settleUnmadePushes). Settled pushes of a week no longer shown are left
|
||||
// behind too, but for the latest when its Supply run was not made
|
||||
// leaves settled pushes behind; it is refused while one is pending. Settled
|
||||
// pushes of a week no longer shown are left behind too
|
||||
// (keepWeekMutations).
|
||||
export function usePushToCalendar(weekStart: string): PushToCalendar {
|
||||
const { apiFetch } = useApi()
|
||||
@@ -474,17 +395,12 @@ export function usePushToCalendar(weekStart: string): PushToCalendar {
|
||||
// The week's pushes, in the order sent.
|
||||
const pushes = useMutationState({
|
||||
filters: { mutationKey },
|
||||
select: (mutation) => ({
|
||||
mutationId: mutation.mutationId,
|
||||
status: mutation.state.status,
|
||||
error: mutation.state.error,
|
||||
data: mutation.state.data as PushedPlan | undefined,
|
||||
}),
|
||||
select: (mutation) => ({ status: mutation.state.status, error: mutation.state.error }),
|
||||
})
|
||||
const latest = pushes.at(-1)
|
||||
|
||||
function mutate() {
|
||||
const mutation = mutations.build<PushedPlan, Error, void, unknown>(queryClient, {
|
||||
const mutation = mutations.build<Plan, Error, void, unknown>(queryClient, {
|
||||
mutationKey,
|
||||
// Kept until reset or settled in a week not shown (keepWeekMutations):
|
||||
// a push pending in a week left must still be found when it is shown
|
||||
@@ -492,15 +408,9 @@ export function usePushToCalendar(weekStart: string): PushToCalendar {
|
||||
gcTime: Infinity,
|
||||
mutationFn: async () => {
|
||||
const path = `${planPath(weekStart)}/push-calendar`
|
||||
return parsePushedPlan(`POST ${path}`, weekStart, await apiFetch<unknown>(path, { method: 'POST' }))
|
||||
},
|
||||
onSuccess: async ({ plan, supply_run }) => {
|
||||
queryClient.setQueryData<Plan>(key, plan)
|
||||
void queryClient.invalidateQueries({ queryKey: supplyRunKey(weekStart) })
|
||||
if (supply_run.error?.code === 'plan_changed') {
|
||||
await queryClient.invalidateQueries({ queryKey: key, refetchType: 'all' })
|
||||
}
|
||||
return parsePlan(`POST ${path}`, weekStart, await apiFetch<unknown>(path, { method: 'POST' }), false)
|
||||
},
|
||||
onSuccess: (plan) => queryClient.setQueryData<Plan>(key, plan),
|
||||
onError: async (error) => {
|
||||
noteGoogleFailure(queryClient, error)
|
||||
if (error instanceof ApiError && error.code === 'plan_changed') {
|
||||
@@ -523,26 +433,7 @@ export function usePushToCalendar(weekStart: string): PushToCalendar {
|
||||
return {
|
||||
pending: pushes.some((push) => push.status === 'pending'),
|
||||
error: latest?.status === 'error' ? latest.error! : null,
|
||||
supplyRun: latest?.status === 'success' ? latest.data!.supply_run : null,
|
||||
mutate,
|
||||
reset,
|
||||
}
|
||||
}
|
||||
|
||||
// settleUnmadePushes is called as the Supply run of the week starting on
|
||||
// weekStart starts being made, from the Plan or the Supply run screen
|
||||
// (useGenerateSupplyRun). The function it answers with is called once the
|
||||
// list is made: it leaves behind the week's pushes sent before whose Supply
|
||||
// run was not made, so the Plan does not say again that it is unmade, even
|
||||
// if the Plan was left meanwhile. A push sent since is kept, and a list not
|
||||
// made leaves them as they are.
|
||||
export function settleUnmadePushes(queryClient: QueryClient, weekStart: string): () => void {
|
||||
const mutations = queryClient.getMutationCache()
|
||||
const mutationKey = pushKey(weekStart)
|
||||
const sent = new Set(mutations.findAll({ mutationKey }))
|
||||
return () => {
|
||||
for (const mutation of mutations.findAll({ mutationKey })) {
|
||||
if (sent.has(mutation) && unmade(mutation)) mutations.remove(mutation)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,144 +0,0 @@
|
||||
/* Supply archive · weekly posts (mock 1m, and the right panel of 2f). */
|
||||
.archive {
|
||||
box-sizing: border-box;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 14px;
|
||||
max-width: 560px;
|
||||
padding-top: 8px;
|
||||
}
|
||||
|
||||
.header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.title {
|
||||
margin: 0;
|
||||
font-family: var(--font-heading);
|
||||
font-size: 24px;
|
||||
font-weight: 400;
|
||||
line-height: 1.1;
|
||||
}
|
||||
|
||||
.periods {
|
||||
align-self: flex-start;
|
||||
}
|
||||
|
||||
.period {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
margin-top: 6px;
|
||||
}
|
||||
|
||||
.heading {
|
||||
margin: 0;
|
||||
font-family: var(--font-heading);
|
||||
font-size: 18px;
|
||||
font-weight: 400;
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
.posts {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
list-style: none;
|
||||
}
|
||||
|
||||
.post {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
padding: 16px;
|
||||
border-radius: 24px;
|
||||
background: var(--color-surface);
|
||||
color: var(--color-text);
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.post:focus-visible {
|
||||
outline: 3px solid var(--color-accent);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
/* The current week's post: grape-100 with a grape-300 ring. */
|
||||
.current {
|
||||
background: var(--color-accent-100);
|
||||
box-shadow: inset 0 0 0 2px var(--color-accent-300);
|
||||
}
|
||||
|
||||
/* The week label as the API writes it, capitalised here. */
|
||||
.kicker {
|
||||
font-family: var(--font-body);
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.08em;
|
||||
text-transform: uppercase;
|
||||
color: var(--color-neutral-700);
|
||||
}
|
||||
|
||||
.current .kicker {
|
||||
color: var(--color-accent-700);
|
||||
}
|
||||
|
||||
.postTitle {
|
||||
font-family: var(--font-heading);
|
||||
font-size: 18px;
|
||||
line-height: 1.15;
|
||||
}
|
||||
|
||||
.meta {
|
||||
font-family: var(--font-body);
|
||||
font-size: 13px;
|
||||
color: var(--color-neutral-800);
|
||||
}
|
||||
|
||||
.chips {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.chip {
|
||||
padding: 3px 10px;
|
||||
border-radius: var(--radius-pill);
|
||||
background: var(--color-bg);
|
||||
font-family: var(--font-body);
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.older {
|
||||
align-self: center;
|
||||
}
|
||||
|
||||
.skeletonPost {
|
||||
height: 112px;
|
||||
border-radius: 24px;
|
||||
}
|
||||
|
||||
/* Desktop (mock 2f): the week's list at the left, the archive in a 340 px
|
||||
panel at the right. */
|
||||
.split {
|
||||
display: flex;
|
||||
gap: 24px;
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.list {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.panel {
|
||||
flex: none;
|
||||
box-sizing: border-box;
|
||||
width: 340px;
|
||||
padding-left: 24px;
|
||||
border-left: 1px solid var(--color-divider);
|
||||
}
|
||||
@@ -1,422 +0,0 @@
|
||||
import { QueryClientProvider } from '@tanstack/react-query'
|
||||
import { fireEvent, render, screen, waitFor, within } from '@testing-library/react'
|
||||
import { MemoryRouter, Route, Routes } from 'react-router'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { ApiProvider, createApiClient, createQueryClient, createVerifiedSession } from '../api/index.ts'
|
||||
import { createNetworkStatus } from '../pwa/networkStatus.ts'
|
||||
import { HOUSEHOLD_SETTINGS } from '../test/ApiProviders.tsx'
|
||||
import { errorResponse, fakeLocation, jsonResponse, ME } from '../test/fetchStub.ts'
|
||||
import { mockViewport } from '../test/matchMedia.ts'
|
||||
import type { ArchivePost } from './api.ts'
|
||||
import { PAGE, SupplyArchivePage } from './SupplyArchivePage.tsx'
|
||||
import styles from './SupplyArchivePage.module.css'
|
||||
|
||||
const ARCHIVE = (by: string) => `/api/v1/shopping/archive?by=${by}`
|
||||
|
||||
// post is one Supply archive post as GET /api/v1/shopping/archive sends it.
|
||||
function post(weekStart: string, label: string, overrides: Partial<ArchivePost> = {}): ArchivePost {
|
||||
return {
|
||||
weekStart,
|
||||
label,
|
||||
title: null,
|
||||
dinnerCount: 7,
|
||||
itemCount: 23,
|
||||
onHandCount: 4,
|
||||
dinners: ['Beef tacos', 'Tikka masala', 'Pizza', 'Pot roast', 'Orzo', 'Chili', 'Salmon'],
|
||||
current: false,
|
||||
...overrides,
|
||||
}
|
||||
}
|
||||
|
||||
// The posts of mock 1m: this week's titled post, then the weeks across the
|
||||
// September–October boundary and the 2025–2026 one, the last untitled.
|
||||
const WEEK_41 = post('2026-10-05', 'Week 41 · Oct 5 – 11', { title: 'Tacos, tikka & a salmon finale', current: true })
|
||||
const WEEK_40 = post('2026-09-28', 'Week 40 · Sep 28 – Oct 4', { title: 'Pizza, pot roast & the great orzo trial', itemCount: 26 })
|
||||
const WEEK_39 = post('2026-09-21', 'Week 39 · Sep 21 – 27', { title: 'Chili season opens', dinnerCount: 1, itemCount: 1, onHandCount: 0, dinners: ['Chili'] })
|
||||
const WEEK_1 = post('2025-12-29', 'Week 1 · Dec 29 – Jan 4')
|
||||
|
||||
// The archive as the server groups it: by week each period is one ISO week
|
||||
// headed by its label; by month and year a week belongs to the period of its
|
||||
// Monday, so week 40 is September's and week 1 is 2025's.
|
||||
const BY = {
|
||||
week: (
|
||||
[
|
||||
['2026-W41', WEEK_41],
|
||||
['2026-W40', WEEK_40],
|
||||
['2026-W39', WEEK_39],
|
||||
['2026-W01', WEEK_1],
|
||||
] as const
|
||||
).map(([key, p]) => ({ key, heading: p.label, posts: [p] })),
|
||||
month: [
|
||||
{ key: '2026-10', heading: 'October 2026', posts: [WEEK_41] },
|
||||
{ key: '2026-09', heading: 'September 2026', posts: [WEEK_40, WEEK_39] },
|
||||
{ key: '2025-12', heading: 'December 2025', posts: [WEEK_1] },
|
||||
],
|
||||
year: [
|
||||
{ key: '2026', heading: '2026', posts: [WEEK_41, WEEK_40, WEEK_39] },
|
||||
{ key: '2025', heading: '2025', posts: [WEEK_1] },
|
||||
],
|
||||
}
|
||||
|
||||
function archive(by: keyof typeof BY) {
|
||||
return () => jsonResponse(200, { by, periods: BY[by] })
|
||||
}
|
||||
|
||||
type Responder = (init: RequestInit | undefined) => Response | Promise<Response>
|
||||
|
||||
// stubServer answers "METHOD path" requests from responders, with /me and
|
||||
// the household settings; any other request fails the test.
|
||||
function stubServer(responders: Record<string, Responder>) {
|
||||
const all: Record<string, Responder> = {
|
||||
'GET /api/v1/me': () => jsonResponse(200, ME),
|
||||
'GET /api/v1/settings/household': () => jsonResponse(200, HOUSEHOLD_SETTINGS),
|
||||
...responders,
|
||||
}
|
||||
const fetchMock = vi.fn<typeof fetch>((input, init) => {
|
||||
const key = `${init?.method ?? 'GET'} ${String(input)}`
|
||||
const respond = Object.hasOwn(all, key) ? all[key] : undefined
|
||||
if (respond === undefined) throw new Error(`unexpected fetch: ${key}`)
|
||||
return Promise.resolve(respond(init))
|
||||
})
|
||||
vi.stubGlobal('fetch', fetchMock)
|
||||
return fetchMock
|
||||
}
|
||||
|
||||
// renderPage loads /me (for the CSRF token any write would need, so a
|
||||
// missing PATCH is not for want of it) and renders the Supply archive at path.
|
||||
async function renderPage(path = '/plan/shopping/archive') {
|
||||
const api = createApiClient(fakeLocation(path), createNetworkStatus(window), createVerifiedSession(localStorage))
|
||||
await api.loadMe()
|
||||
return render(
|
||||
<ApiProvider client={api}>
|
||||
<QueryClientProvider client={createQueryClient()}>
|
||||
<MemoryRouter initialEntries={[path]}>
|
||||
<Routes>
|
||||
<Route path="/plan/shopping/archive" element={<SupplyArchivePage />} />
|
||||
<Route path="/plan/shopping" element={<p>Supply run screen</p>} />
|
||||
</Routes>
|
||||
</MemoryRouter>
|
||||
</QueryClientProvider>
|
||||
</ApiProvider>,
|
||||
)
|
||||
}
|
||||
|
||||
// postCard is the post titled title, a link.
|
||||
function postCard(title: string) {
|
||||
return screen.getByText(title).closest('a')!
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
mockViewport(390)
|
||||
vi.useFakeTimers({ toFake: ['Date'] })
|
||||
vi.setSystemTime(new Date('2026-10-07T16:00:00Z'))
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers()
|
||||
vi.unstubAllGlobals()
|
||||
Reflect.deleteProperty(window, 'matchMedia')
|
||||
})
|
||||
|
||||
describe('SupplyArchivePage', () => {
|
||||
it('renders mock 1m by week: one post per week with its kicker, title, counts and dinner chips', async () => {
|
||||
stubServer({ [`GET ${ARCHIVE('week')}`]: archive('week') })
|
||||
await renderPage()
|
||||
|
||||
expect(screen.getByRole('heading', { level: 1, name: 'Supply archive' })).toBeInTheDocument()
|
||||
expect(screen.getByRole('radio', { name: 'Week' })).toBeChecked()
|
||||
const card = await screen.findByText('Pizza, pot roast & the great orzo trial')
|
||||
const post40 = card.closest('a')!
|
||||
expect(within(post40).getByText('Week 40 · Sep 28 – Oct 4')).toHaveClass(styles.kicker!)
|
||||
expect(post40).toHaveTextContent('7 dinners · 26 items · 4 already on hand')
|
||||
expect(within(post40).getByText('Beef tacos')).toHaveClass(styles.chip!)
|
||||
expect(within(post40).getByText('Tikka masala')).toHaveClass(styles.chip!)
|
||||
expect(within(post40).getByText('+5')).toHaveClass(styles.chip!)
|
||||
expect(within(post40).queryByText('Pizza')).not.toBeInTheDocument()
|
||||
expect(postCard('Chili season opens')).toHaveTextContent('1 dinner · 1 item · 0 already on hand')
|
||||
expect(within(postCard('Chili season opens')).queryByText(/^\+/)).not.toBeInTheDocument()
|
||||
|
||||
expect(screen.getAllByRole('link').map((link) => link.getAttribute('href'))).toEqual([
|
||||
'/plan/shopping/archive?week=2026-10-05',
|
||||
'/plan/shopping/archive?week=2026-09-28',
|
||||
'/plan/shopping/archive?week=2026-09-21',
|
||||
'/plan/shopping/archive?week=2025-12-29',
|
||||
])
|
||||
})
|
||||
|
||||
it('renders by week the periods and headings the server sends, as sent', async () => {
|
||||
stubServer({ [`GET ${ARCHIVE('week')}`]: archive('week') })
|
||||
await renderPage()
|
||||
await screen.findByText('Chili season opens')
|
||||
|
||||
expect(screen.getAllByRole('heading', { level: 2 }).map((h) => h.textContent)).toEqual(BY.week.map((period) => period.heading))
|
||||
expect(screen.getByRole('heading', { level: 2, name: WEEK_40.label })).toHaveClass(styles.heading!)
|
||||
for (const period of BY.week) {
|
||||
const section = screen.getByRole('region', { name: period.heading })
|
||||
expect(within(section).getAllByRole('link').map((link) => link.getAttribute('href'))).toEqual(
|
||||
period.posts.map((p) => `/plan/shopping/archive?week=${p.weekStart}`),
|
||||
)
|
||||
}
|
||||
})
|
||||
|
||||
it('capitalises the week label kicker in CSS from the label as the API writes it', async () => {
|
||||
stubServer({ [`GET ${ARCHIVE('week')}`]: archive('week') })
|
||||
await renderPage()
|
||||
await screen.findByText('Chili season opens')
|
||||
expect(within(postCard('Chili season opens')).getByText('Week 39 · Sep 21 – 27')).toHaveClass(styles.kicker!)
|
||||
})
|
||||
|
||||
it.each([
|
||||
['Month', 'month', ['October 2026', 'September 2026', 'December 2025']],
|
||||
['Year', 'year', ['2026', '2025']],
|
||||
] as const)('groups by %s under the headings the server sends, across the month and year boundaries', async (label, by, headings) => {
|
||||
const fetchMock = stubServer({ [`GET ${ARCHIVE('week')}`]: archive('week'), [`GET ${ARCHIVE(by)}`]: archive(by) })
|
||||
await renderPage()
|
||||
await screen.findByText('Chili season opens')
|
||||
|
||||
fireEvent.click(screen.getByRole('radio', { name: label }))
|
||||
|
||||
await waitFor(() => expect(screen.getAllByRole('heading', { level: 2 }).map((h) => h.textContent)).toEqual(headings))
|
||||
expect(screen.getByRole('heading', { level: 2, name: headings[0] })).toHaveClass(styles.heading!)
|
||||
expect(fetchMock.mock.calls.map(([input]) => String(input))).toContain(ARCHIVE(by))
|
||||
for (const period of BY[by]) {
|
||||
const section = screen.getByRole('region', { name: period.heading })
|
||||
expect(within(section).getAllByRole('link').map((link) => link.getAttribute('href'))).toEqual(
|
||||
period.posts.map((p) => `/plan/shopping/archive?week=${p.weekStart}`),
|
||||
)
|
||||
}
|
||||
})
|
||||
|
||||
it('highlights the current week’s post, by the API’s current, and no other', async () => {
|
||||
stubServer({ [`GET ${ARCHIVE('week')}`]: archive('week') })
|
||||
await renderPage()
|
||||
|
||||
const current = (await screen.findByText('Tacos, tikka & a salmon finale')).closest('a')!
|
||||
expect(current).toHaveClass(styles.current!)
|
||||
expect(within(current).getByText('Week 41 · Oct 5 – 11 · Current')).toBeInTheDocument()
|
||||
for (const title of ['Pizza, pot roast & the great orzo trial', 'Chili season opens']) {
|
||||
expect(postCard(title)).not.toHaveClass(styles.current!)
|
||||
expect(postCard(title)).not.toHaveTextContent('Current')
|
||||
}
|
||||
})
|
||||
|
||||
it('follows current from the API, not today’s date', async () => {
|
||||
const past = { ...WEEK_40, current: true }
|
||||
stubServer({ [`GET ${ARCHIVE('week')}`]: () => jsonResponse(200, { by: 'week', periods: [{ key: 'w', heading: past.label, posts: [{ ...WEEK_41, current: false }, past] }] }) })
|
||||
await renderPage()
|
||||
|
||||
expect(await screen.findByText('Week 40 · Sep 28 – Oct 4 · Current')).toBeInTheDocument()
|
||||
expect(postCard('Tacos, tikka & a salmon finale')).not.toHaveClass(styles.current!)
|
||||
})
|
||||
|
||||
it('titles an untitled post with its week label', async () => {
|
||||
stubServer({ [`GET ${ARCHIVE('week')}`]: archive('week') })
|
||||
await renderPage()
|
||||
|
||||
await screen.findByText('Chili season opens')
|
||||
const untitled = screen.getByRole('link', { name: /Week 1 · Dec 29 – Jan 4/ })
|
||||
const label = within(untitled).getAllByText('Week 1 · Dec 29 – Jan 4')
|
||||
expect(label.map((el) => el.className)).toEqual([styles.kicker, styles.postTitle])
|
||||
})
|
||||
|
||||
it(`shows ${PAGE} posts and "Load older" shows the next ${PAGE}, none twice and none skipped`, async () => {
|
||||
const weeks = Array.from({ length: 2 * PAGE + 3 }, (_, i) => {
|
||||
const monday = new Date(Date.UTC(2026, 9, 5 - 7 * i)).toISOString().slice(0, 10)
|
||||
return post(monday, `Week of ${monday}`, { title: `Post ${i}` })
|
||||
})
|
||||
// By month, so the cut falls inside a period and its heading is shown once.
|
||||
const periods = [
|
||||
{ key: 'a', heading: 'Newer', posts: weeks.slice(0, PAGE + 2) },
|
||||
{ key: 'b', heading: 'Older', posts: weeks.slice(PAGE + 2) },
|
||||
]
|
||||
stubServer({
|
||||
[`GET ${ARCHIVE('week')}`]: archive('week'),
|
||||
[`GET ${ARCHIVE('month')}`]: () => jsonResponse(200, { by: 'month', periods }),
|
||||
})
|
||||
await renderPage()
|
||||
await screen.findByText('Chili season opens')
|
||||
fireEvent.click(screen.getByRole('radio', { name: 'Month' }))
|
||||
|
||||
const titles = () => screen.getAllByRole('link').map((link) => within(link).getByText(/^Post /).textContent)
|
||||
await screen.findByText('Post 0')
|
||||
expect(titles()).toEqual(weeks.slice(0, PAGE).map((p) => p.title))
|
||||
expect(screen.getAllByRole('heading', { level: 2 }).map((h) => h.textContent)).toEqual(['Newer'])
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Load older' }))
|
||||
expect(titles()).toEqual(weeks.slice(0, 2 * PAGE).map((p) => p.title))
|
||||
expect(screen.getAllByRole('heading', { level: 2 }).map((h) => h.textContent)).toEqual(['Newer', 'Older'])
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Load older' }))
|
||||
expect(titles()).toEqual(weeks.map((p) => p.title))
|
||||
expect(screen.queryByRole('button', { name: 'Load older' })).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('offers no "Load older" when every post is shown', async () => {
|
||||
stubServer({ [`GET ${ARCHIVE('week')}`]: archive('week') })
|
||||
await renderPage()
|
||||
await screen.findByText('Chili season opens')
|
||||
expect(screen.queryByRole('button', { name: 'Load older' })).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('shows skeleton posts while the archive loads', async () => {
|
||||
stubServer({ [`GET ${ARCHIVE('week')}`]: () => new Promise<Response>(() => {}) })
|
||||
await renderPage()
|
||||
expect(screen.getByLabelText('Supply archive loading')).toHaveAttribute('aria-busy', 'true')
|
||||
expect(screen.getAllByTestId('skeleton-post')).toHaveLength(3)
|
||||
})
|
||||
|
||||
it('says so when nothing is archived yet', async () => {
|
||||
stubServer({ [`GET ${ARCHIVE('week')}`]: () => jsonResponse(200, { by: 'week', periods: [] }) })
|
||||
await renderPage()
|
||||
expect(await screen.findByText('Nothing archived yet')).toBeInTheDocument()
|
||||
expect(screen.queryByRole('button', { name: 'Load older' })).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('reports a failed load and retries it', async () => {
|
||||
let reads = 0
|
||||
stubServer({
|
||||
[`GET ${ARCHIVE('week')}`]: () => (++reads === 1 ? errorResponse(500, 'internal') : archive('week')()),
|
||||
})
|
||||
await renderPage()
|
||||
|
||||
const alert = await screen.findByRole('alert')
|
||||
expect(alert).toHaveTextContent("Couldn't load the Supply archive.")
|
||||
fireEvent.click(within(alert).getByRole('button', { name: 'Retry' }))
|
||||
expect(await screen.findByText('Chili season opens')).toBeInTheDocument()
|
||||
expect(reads).toBe(2)
|
||||
})
|
||||
|
||||
it('reports a response that is not the archive by the period asked for', async () => {
|
||||
stubServer({ [`GET ${ARCHIVE('week')}`]: () => jsonResponse(200, { by: 'month', periods: [] }) })
|
||||
await renderPage()
|
||||
expect(await screen.findByRole('alert')).toHaveTextContent(`GET ${ARCHIVE('week')}: response is not the Supply archive by week`)
|
||||
})
|
||||
|
||||
it('goes back to the Supply run', async () => {
|
||||
stubServer({ [`GET ${ARCHIVE('week')}`]: archive('week') })
|
||||
await renderPage()
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Back to the Supply run' }))
|
||||
expect(await screen.findByText('Supply run screen')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
// The frozen Supply run of week 40, as GET /api/v1/shopping/{weekStart}
|
||||
// sends it.
|
||||
const FROZEN = {
|
||||
weekStart: '2026-09-28',
|
||||
label: 'Week 40 · Sep 28 – Oct 4',
|
||||
title: 'Pizza, pot roast & the great orzo trial',
|
||||
status: 'archived',
|
||||
sent_to_chat_at: null,
|
||||
needed_count: 1,
|
||||
total_count: 2,
|
||||
groups: [
|
||||
{
|
||||
dinner: { id: 'd1', name: 'Friday pizza' },
|
||||
date: '2026-10-02',
|
||||
items: [
|
||||
{ id: 'i1', name: 'Pizza dough', qty: '2 balls', needed: true },
|
||||
{ id: 'i2', name: 'Mozzarella', qty: '1 lb', needed: false },
|
||||
],
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
// The current week's Supply run, week 41, still current: the API's archive
|
||||
// includes it, highlighted.
|
||||
const CURRENT = {
|
||||
...FROZEN,
|
||||
weekStart: '2026-10-05',
|
||||
label: 'Week 41 · Oct 5 – 11',
|
||||
title: 'Tacos, tikka & a salmon finale',
|
||||
status: 'current',
|
||||
groups: [
|
||||
{
|
||||
dinner: { id: 'd2', name: 'Beef tacos' },
|
||||
date: '2026-10-05',
|
||||
items: [
|
||||
{ id: 'i3', name: 'Corn tortillas', qty: '12', needed: true },
|
||||
{ id: 'i4', name: 'Limes', qty: '3', needed: false },
|
||||
],
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
describe('SupplyArchivePage frozen post', () => {
|
||||
it('opens a tapped post’s frozen list read-only, tagged Archived, sending no PATCH', async () => {
|
||||
const fetchMock = stubServer({
|
||||
[`GET ${ARCHIVE('week')}`]: archive('week'),
|
||||
'GET /api/v1/shopping/2026-09-28': () => jsonResponse(200, FROZEN),
|
||||
})
|
||||
await renderPage()
|
||||
|
||||
fireEvent.click(await screen.findByText('Pizza, pot roast & the great orzo trial'))
|
||||
|
||||
expect(await screen.findByText('Archived')).toBeInTheDocument()
|
||||
expect(screen.getByRole('heading', { level: 1, name: /Shopping list/ })).toBeInTheDocument()
|
||||
expect(screen.getByText('Pizza dough')).toBeInTheDocument()
|
||||
expect(screen.queryAllByRole('checkbox')).toEqual([])
|
||||
fireEvent.click(screen.getByText('Pizza dough'))
|
||||
fireEvent.click(screen.getByText('Mozzarella'))
|
||||
expect(fetchMock.mock.calls.filter(([, init]) => init?.method !== undefined && init.method !== 'GET')).toEqual([])
|
||||
|
||||
// The list links back to the archive.
|
||||
fireEvent.click(screen.getByRole('link', { name: 'Supply archive' }))
|
||||
expect(await screen.findByRole('heading', { level: 1, name: 'Supply archive' })).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('opens the current week’s post read-only too, tagged Archived, with no checkbox, nothing to send and no PATCH', async () => {
|
||||
const fetchMock = stubServer({
|
||||
[`GET ${ARCHIVE('week')}`]: archive('week'),
|
||||
'GET /api/v1/shopping/2026-10-05': () => jsonResponse(200, CURRENT),
|
||||
})
|
||||
await renderPage()
|
||||
|
||||
fireEvent.click(await screen.findByText('Tacos, tikka & a salmon finale'))
|
||||
|
||||
expect(await screen.findByText('Archived')).toBeInTheDocument()
|
||||
expect(screen.getByText("This week's list as it stands, read-only in the Supply archive.")).toBeInTheDocument()
|
||||
expect(screen.getByText('Corn tortillas')).toBeInTheDocument()
|
||||
expect(screen.queryAllByRole('checkbox')).toEqual([])
|
||||
fireEvent.click(screen.getByText('Corn tortillas'))
|
||||
fireEvent.click(screen.getByText('Limes'))
|
||||
expect(fetchMock.mock.calls.filter(([, init]) => init?.method !== undefined && init.method !== 'GET')).toEqual([])
|
||||
})
|
||||
|
||||
it('on desktop opens the current week’s post read-only, and with no post open shows this week’s Supply run to change', async () => {
|
||||
mockViewport(1440)
|
||||
const fetchMock = stubServer({
|
||||
[`GET ${ARCHIVE('week')}`]: archive('week'),
|
||||
'GET /api/v1/shopping/2026-10-05': () => jsonResponse(200, CURRENT),
|
||||
})
|
||||
const view = await renderPage('/plan/shopping/archive?week=2026-10-05')
|
||||
|
||||
expect(await screen.findByText('Archived')).toBeInTheDocument()
|
||||
expect(screen.getByText('Corn tortillas')).toBeInTheDocument()
|
||||
expect(screen.queryAllByRole('checkbox')).toEqual([])
|
||||
expect(screen.queryByText('Send to Google Chat')).not.toBeInTheDocument()
|
||||
fireEvent.click(screen.getByText('Corn tortillas'))
|
||||
expect(fetchMock.mock.calls.filter(([, init]) => init?.method !== undefined && init.method !== 'GET')).toEqual([])
|
||||
view.unmount()
|
||||
|
||||
await renderPage()
|
||||
expect(await screen.findByRole('checkbox', { name: 'Corn tortillas' })).toBeInTheDocument()
|
||||
expect(screen.queryByText('Archived')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('on desktop (mock 2f) shows the archive as a panel beside the open post’s list', async () => {
|
||||
mockViewport(1440)
|
||||
stubServer({
|
||||
[`GET ${ARCHIVE('week')}`]: archive('week'),
|
||||
'GET /api/v1/shopping/2026-09-28': () => jsonResponse(200, FROZEN),
|
||||
})
|
||||
await renderPage('/plan/shopping/archive?week=2026-09-28')
|
||||
|
||||
const panel = screen.getByRole('complementary', { name: 'Supply archive' })
|
||||
expect(await within(panel).findByText('Chili season opens')).toBeInTheDocument()
|
||||
expect(within(panel).queryByRole('button', { name: 'Back to the Supply run' })).not.toBeInTheDocument()
|
||||
expect(await screen.findByText('Archived')).toBeInTheDocument()
|
||||
expect(panel).not.toContainElement(screen.getByText('Pizza dough'))
|
||||
expect(screen.queryAllByRole('checkbox')).toEqual([])
|
||||
})
|
||||
})
|
||||
@@ -1,183 +0,0 @@
|
||||
import { ChevronLeft } from 'lucide-react'
|
||||
import { useState } from 'react'
|
||||
import { Link, useNavigate, useSearchParams } from 'react-router'
|
||||
import { useBreakpoint } from '../shell/useBreakpoint.ts'
|
||||
import { Button } from '../ui/Button.tsx'
|
||||
import { EmptyState } from '../ui/EmptyState.tsx'
|
||||
import { ErrorBanner } from '../ui/ErrorBanner.tsx'
|
||||
import { Icon } from '../ui/Icon.tsx'
|
||||
import { IconButton } from '../ui/IconButton.tsx'
|
||||
import { Segmented } from '../ui/Segmented.tsx'
|
||||
import { Skeleton } from '../ui/Skeleton.tsx'
|
||||
import { useSupplyArchive, WEEK_PARAM, type ArchiveGroup, type ArchivePeriod, type ArchivePost } from './api.ts'
|
||||
import styles from './SupplyArchivePage.module.css'
|
||||
import { SUPPLY_ARCHIVE, SupplyRunPage } from './SupplyRunPage.tsx'
|
||||
|
||||
// PAGE is how many posts the Supply archive shows at first, and how many
|
||||
// more each "Load older" shows.
|
||||
export const PAGE = 8
|
||||
|
||||
// CHIPS is how many of a post's dinners it names; a "+N" chip counts the rest.
|
||||
const CHIPS = 2
|
||||
|
||||
const PERIODS = [
|
||||
{ value: 'week', label: 'Week' },
|
||||
{ value: 'month', label: 'Month' },
|
||||
{ value: 'year', label: 'Year' },
|
||||
] as const satisfies readonly { value: ArchivePeriod; label: string }[]
|
||||
|
||||
// archivePost is the Supply archive screen opened on the post of the week
|
||||
// starting on Monday weekStart.
|
||||
export function archivePost(weekStart: string): string {
|
||||
return `${SUPPLY_ARCHIVE}?${new URLSearchParams({ [WEEK_PARAM]: weekStart })}`
|
||||
}
|
||||
|
||||
function plural(n: number, one: string, many: string): string {
|
||||
return `${n} ${n === 1 ? one : many}`
|
||||
}
|
||||
|
||||
// PostCard is one week's Supply run (mock 1m): the week label as its kicker,
|
||||
// the AI's title — the week label while it has none — the counts and its
|
||||
// first dinners as chips. The current week's post is ringed in grape. It
|
||||
// opens the week's list, read-only.
|
||||
function PostCard({ post }: { post: ArchivePost }) {
|
||||
const rest = post.dinners.length - CHIPS
|
||||
return (
|
||||
<Link to={archivePost(post.weekStart)} className={[styles.post, post.current && styles.current].filter(Boolean).join(' ')}>
|
||||
<span className={styles.kicker}>
|
||||
{post.label}
|
||||
{post.current && ' · Current'}
|
||||
</span>
|
||||
<span className={styles.postTitle}>{post.title ?? post.label}</span>
|
||||
<span className={styles.meta}>
|
||||
{plural(post.dinnerCount, 'dinner', 'dinners')} · {plural(post.itemCount, 'item', 'items')} · {post.onHandCount} already on
|
||||
hand
|
||||
</span>
|
||||
{post.dinners.length > 0 && (
|
||||
<span className={styles.chips}>
|
||||
{post.dinners.slice(0, CHIPS).map((name, i) => (
|
||||
<span key={i} className={styles.chip}>
|
||||
{name}
|
||||
</span>
|
||||
))}
|
||||
{rest > 0 && <span className={styles.chip}>+{rest}</span>}
|
||||
</span>
|
||||
)}
|
||||
</Link>
|
||||
)
|
||||
}
|
||||
|
||||
function Posts({ posts }: { posts: ArchivePost[] }) {
|
||||
return (
|
||||
<ul className={styles.posts}>
|
||||
{posts.map((post) => (
|
||||
<li key={post.weekStart}>
|
||||
<PostCard post={post} />
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)
|
||||
}
|
||||
|
||||
// shownGroups is groups cut to their first shown posts, newest first, without
|
||||
// the periods left with none.
|
||||
function shownGroups(groups: ArchiveGroup[], shown: number): ArchiveGroup[] {
|
||||
const out: ArchiveGroup[] = []
|
||||
let left = shown
|
||||
for (const group of groups) {
|
||||
if (left === 0) break
|
||||
const posts = group.posts.slice(0, left)
|
||||
out.push({ ...group, posts })
|
||||
left -= posts.length
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// ArchivePosts is the Supply archive as the server groups it by by: one post
|
||||
// per week, under the Bagel heading of each period the server sends — by
|
||||
// week, the week's label. It shows PAGE posts and "Load older" shows PAGE
|
||||
// more.
|
||||
function ArchivePosts({ by }: { by: ArchivePeriod }) {
|
||||
const archive = useSupplyArchive(by)
|
||||
const [shown, setShown] = useState(PAGE)
|
||||
|
||||
if (archive.isError) {
|
||||
return <ErrorBanner onRetry={() => void archive.refetch()}>Couldn't load the Supply archive. {archive.error.message}</ErrorBanner>
|
||||
}
|
||||
if (archive.isPending) {
|
||||
return (
|
||||
<div aria-busy="true" aria-label="Supply archive loading" className={styles.posts}>
|
||||
{Array.from({ length: 3 }, (_, i) => (
|
||||
<Skeleton key={i} data-testid="skeleton-post" className={styles.skeletonPost} />
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
const total = archive.data.reduce((n, group) => n + group.posts.length, 0)
|
||||
if (total === 0) {
|
||||
return <EmptyState title="Nothing archived yet">Each week's Supply run becomes a post here once it's made.</EmptyState>
|
||||
}
|
||||
const groups = shownGroups(archive.data, shown)
|
||||
return (
|
||||
<>
|
||||
{groups.map((group) => (
|
||||
<section key={group.key} aria-label={group.heading} className={styles.period}>
|
||||
<h2 className={styles.heading}>{group.heading}</h2>
|
||||
<Posts posts={group.posts} />
|
||||
</section>
|
||||
))}
|
||||
{shown < total && (
|
||||
<Button variant="outline" className={styles.older} onClick={() => setShown(shown + PAGE)}>
|
||||
Load older
|
||||
</Button>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
// SupplyArchive is the Supply archive (mock 1m, and the right panel of 2f):
|
||||
// the Week | Month | Year control over the posts. On mobile and tablet a
|
||||
// back button returns to the Supply run.
|
||||
function SupplyArchive({ panel }: { panel: boolean }) {
|
||||
const navigate = useNavigate()
|
||||
const [by, setBy] = useState<ArchivePeriod>('week')
|
||||
return (
|
||||
<div className={styles.archive}>
|
||||
<header className={styles.header}>
|
||||
{!panel && (
|
||||
<IconButton label="Back to the Supply run" onClick={() => void navigate('/plan/shopping')}>
|
||||
<Icon icon={ChevronLeft} size="nav" />
|
||||
</IconButton>
|
||||
)}
|
||||
<h1 className={styles.title}>Supply archive</h1>
|
||||
</header>
|
||||
<Segmented label="Group the Supply archive by" options={PERIODS} value={by} onValueChange={setBy} className={styles.periods} />
|
||||
<ArchivePosts key={by} by={by} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// SupplyArchivePage is /plan/shopping/archive. A post opens it on its week
|
||||
// (?week=), whose list the Supply run screen shows read-only and tagged
|
||||
// Archived, whatever its status: even the current week's post is only
|
||||
// viewed here, never changed. On desktop (mock 2f) the archive is a panel at
|
||||
// the right of that list, this week's editable Supply run when no post is
|
||||
// open; on mobile and tablet it is the archive or, with a post open, the
|
||||
// post's list.
|
||||
export function SupplyArchivePage() {
|
||||
const layout = useBreakpoint()
|
||||
const [params] = useSearchParams()
|
||||
if (layout === 'desktop') {
|
||||
return (
|
||||
<div className={styles.split}>
|
||||
<div className={styles.list}>
|
||||
<SupplyRunPage readOnly={params.has(WEEK_PARAM)} />
|
||||
</div>
|
||||
<aside className={styles.panel} aria-label="Supply archive">
|
||||
<SupplyArchive panel />
|
||||
</aside>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
return params.has(WEEK_PARAM) ? <SupplyRunPage readOnly /> : <SupplyArchive panel={false} />
|
||||
}
|
||||
@@ -40,8 +40,8 @@ export function sentAt(at: Date, timezone: string): string {
|
||||
|
||||
// ItemRow is one ingredient: on a current Supply run a checkbox, checked
|
||||
// (grape) while it is to buy; unchecked, the name is struck through and
|
||||
// "have it" stands in for the quantity. A read-only Supply run shows the
|
||||
// same marks. An item toggled while the server cannot be reached
|
||||
// "have it" stands in for the quantity. An archived Supply run shows the
|
||||
// same marks, read-only. An item toggled while the server cannot be reached
|
||||
// says it will sync.
|
||||
function ItemRow({ item, toggles }: { item: SupplyRunItem; toggles: Toggles | null }) {
|
||||
const name = (
|
||||
@@ -121,13 +121,12 @@ function ArchiveLink({ layout }: { layout: Breakpoint }) {
|
||||
// one card per dinner, stacked on mobile and in three masonry columns from
|
||||
// tablet up, where Archive and Send to Google Chat are actions in the
|
||||
// header. On a current Supply run each item toggles at once (useToggles); a
|
||||
// toggle refused puts the item back and says why. An archived one, or any
|
||||
// opened from the Supply archive (readOnly), is read-only and tagged
|
||||
// Archived, and a toggle it refused is not offered again.
|
||||
function SupplyRunList({ weekStart, timezone, layout, readOnly }: WeekProps) {
|
||||
// toggle refused puts the item back and says why. An archived one is
|
||||
// read-only, and a toggle it refused is not offered again.
|
||||
function SupplyRunList({ weekStart, timezone, layout }: { weekStart: string; timezone: string; layout: Breakpoint }) {
|
||||
const toggles = useToggles(weekStart)
|
||||
const run = toggles.shown
|
||||
const viewOnly = readOnly || run.status === 'archived'
|
||||
const archived = run.status === 'archived'
|
||||
const wide = layout !== 'mobile'
|
||||
const failed = toggles.failed
|
||||
const failedItem = failed && run.groups.flatMap((group) => group.items).find((item) => item.id === failed.toggle.id)
|
||||
@@ -139,23 +138,21 @@ function SupplyRunList({ weekStart, timezone, layout, readOnly }: WeekProps) {
|
||||
<p className={styles.kicker}>Supply run · {weekLabel(weekStart).dates}</p>
|
||||
<div className={styles.titleRow}>
|
||||
<h1 className={styles.title}>Shopping list</h1>
|
||||
{viewOnly && <Tag tone="grape">Archived</Tag>}
|
||||
{archived && <Tag tone="grape">Archived</Tag>}
|
||||
<p className={styles.count}>
|
||||
{run.needed_count} / {run.total_count}
|
||||
</p>
|
||||
</div>
|
||||
<p className={styles.helper}>
|
||||
{run.status === 'archived'
|
||||
{archived
|
||||
? 'This list is in the Supply archive, as it was when the week ended.'
|
||||
: viewOnly
|
||||
? "This week's list as it stands, read-only in the Supply archive."
|
||||
: "Uncheck anything you already have — it won't be counted."}
|
||||
: "Uncheck anything you already have — it won't be counted."}
|
||||
</p>
|
||||
</div>
|
||||
{wide && (
|
||||
<div className={styles.actions}>
|
||||
<ArchiveLink layout={layout} />
|
||||
{!viewOnly && (
|
||||
{!archived && (
|
||||
<Unavailable hint={CHAT_HINT} below className={styles.action}>
|
||||
<Icon icon={MessageSquare} size="inline" />
|
||||
Send to Google Chat
|
||||
@@ -165,7 +162,7 @@ function SupplyRunList({ weekStart, timezone, layout, readOnly }: WeekProps) {
|
||||
)}
|
||||
</header>
|
||||
{failed !== null && (
|
||||
<ErrorBanner onRetry={viewOnly ? undefined : () => toggles.toggle(failed.toggle)}>
|
||||
<ErrorBanner onRetry={archived ? undefined : () => toggles.toggle(failed.toggle)}>
|
||||
Couldn't mark {failedItem ? failedItem.name : 'an item'} {failed.toggle.needed ? 'to buy' : '“have it”'}.{' '}
|
||||
{failed.error.message}
|
||||
</ErrorBanner>
|
||||
@@ -176,7 +173,7 @@ function SupplyRunList({ weekStart, timezone, layout, readOnly }: WeekProps) {
|
||||
) : (
|
||||
<div className={styles.groups} data-testid="supply-run-groups">
|
||||
{run.groups.map((group) => (
|
||||
<GroupCard key={`${group.date} ${group.dinner.id}`} group={group} toggles={viewOnly ? null : toggles} />
|
||||
<GroupCard key={`${group.date} ${group.dinner.id}`} group={group} toggles={archived ? null : toggles} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
@@ -204,12 +201,9 @@ function SkeletonGroups({ layout }: { layout: Breakpoint }) {
|
||||
)
|
||||
}
|
||||
|
||||
type WeekProps = { weekStart: string; timezone: string; layout: Breakpoint; readOnly: boolean }
|
||||
|
||||
// WeekSupplyRun is the Supply run of the week starting on weekStart, or,
|
||||
// when the week has none yet, the offer to make it from the week's plan —
|
||||
// not offered read-only.
|
||||
function WeekSupplyRun({ weekStart, timezone, layout, readOnly }: WeekProps) {
|
||||
// when the week has none yet, the offer to make it from the week's plan.
|
||||
function WeekSupplyRun({ weekStart, timezone, layout }: { weekStart: string; timezone: string; layout: Breakpoint }) {
|
||||
const run = useSupplyRun(weekStart)
|
||||
const generate = useGenerateSupplyRun(weekStart)
|
||||
|
||||
@@ -222,42 +216,36 @@ function WeekSupplyRun({ weekStart, timezone, layout, readOnly }: WeekProps) {
|
||||
if (run.data === null) {
|
||||
return (
|
||||
<div className={`${styles.page} ${styles[layout]}`}>
|
||||
{readOnly ? (
|
||||
<EmptyState title="No shopping list">This week has no Supply run in the Supply archive.</EmptyState>
|
||||
) : (
|
||||
<EmptyState title="No shopping list yet" action={{ label: 'Make the shopping list', onClick: () => generate.mutate() }}>
|
||||
I'll gather the ingredients of this week's planned dinners into a Supply run.
|
||||
</EmptyState>
|
||||
)}
|
||||
<EmptyState title="No shopping list yet" action={{ label: 'Make the shopping list', onClick: () => generate.mutate() }}>
|
||||
I'll gather the ingredients of this week's planned dinners into a Supply run.
|
||||
</EmptyState>
|
||||
{generate.isError && (
|
||||
<ErrorBanner onRetry={() => generate.mutate()}>Couldn't make the shopping list. {generate.error.message}</ErrorBanner>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
return <SupplyRunList weekStart={weekStart} timezone={timezone} layout={layout} readOnly={readOnly} />
|
||||
return <SupplyRunList weekStart={weekStart} timezone={timezone} layout={layout} />
|
||||
}
|
||||
|
||||
// SupplyRunPage is the Supply run (mock 1l on mobile, 2b on tablet, 2f on
|
||||
// desktop) of the week the Plan opens on — next week once this week's
|
||||
// plan_draft time has come, otherwise this week — or of the week chosen by
|
||||
// ?week=. Opened from the Supply archive it is readOnly: tagged Archived,
|
||||
// with no checkbox and nothing to make or send, whatever the week. Offline,
|
||||
// where the settings cannot be read, it chooses the week by the schedule
|
||||
// saved on this device.
|
||||
export function SupplyRunPage({ readOnly = false }: { readOnly?: boolean }) {
|
||||
// ?week=, such as a week in the Supply archive. Offline, where the settings
|
||||
// cannot be read, it chooses the week by the schedule saved on this device.
|
||||
export function SupplyRunPage() {
|
||||
const problem = useSavedProblem()
|
||||
return (
|
||||
<>
|
||||
{problem !== null && (
|
||||
<ErrorBanner>Changes to the shopping list can't be kept on this device while offline. {problem.message}</ErrorBanner>
|
||||
)}
|
||||
<ChosenSupplyRun readOnly={readOnly} />
|
||||
<ChosenSupplyRun />
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
function ChosenSupplyRun({ readOnly }: { readOnly: boolean }) {
|
||||
function ChosenSupplyRun() {
|
||||
const layout = useBreakpoint()
|
||||
const settings = useHouseholdSettings()
|
||||
const [params] = useSearchParams()
|
||||
@@ -276,7 +264,7 @@ function ChosenSupplyRun({ readOnly }: { readOnly: boolean }) {
|
||||
}
|
||||
if (!schedule) return <SkeletonGroups layout={layout} />
|
||||
const week = chosen ?? defaultWeek(schedule, new Date())
|
||||
return <WeekSupplyRun key={week} weekStart={week} timezone={schedule.timezone} layout={layout} readOnly={readOnly} />
|
||||
return <WeekSupplyRun key={week} weekStart={week} timezone={schedule.timezone} layout={layout} />
|
||||
}
|
||||
|
||||
// isMonday says whether date is a real YYYY-MM-DD Monday.
|
||||
|
||||
+2
-82
@@ -12,7 +12,6 @@ import { ApiError, useApi, type ApiClient } from '../api/index.ts'
|
||||
import { isRecord } from '../api/errors.ts'
|
||||
import { meQueryKey } from '../AuthBootstrap.tsx'
|
||||
import { oneOf } from '../dinners/api.ts'
|
||||
import { settleUnmadePushes } from '../plan/api.ts'
|
||||
import type { NetworkStatus } from '../pwa/networkStatus.ts'
|
||||
import { HOUSEHOLD_SETTINGS_KEY, type HouseholdSettings } from '../settings/api.ts'
|
||||
import {
|
||||
@@ -188,21 +187,16 @@ export function useSupplyRun(weekStart: string) {
|
||||
|
||||
// useGenerateSupplyRun makes the week's Supply run from its plan (POST
|
||||
// /api/v1/shopping/{weekStart}/generate); the Supply run it answers with is
|
||||
// the one shown. Once made, from whichever screen, a push of the week whose
|
||||
// Supply run was not made is settled (settleUnmadePushes).
|
||||
// the one shown.
|
||||
export function useGenerateSupplyRun(weekStart: string) {
|
||||
const { apiFetch } = useApi()
|
||||
const queryClient = useQueryClient()
|
||||
return useMutation({
|
||||
onMutate: () => settleUnmadePushes(queryClient, weekStart),
|
||||
mutationFn: async (): Promise<SupplyRun> => {
|
||||
const path = `${supplyRunPath(weekStart)}/generate`
|
||||
return parseSupplyRun(`POST ${path}`, weekStart, await apiFetch<unknown>(path, { method: 'POST' }))
|
||||
},
|
||||
onSuccess: (run, _, settle) => {
|
||||
queryClient.setQueryData<SupplyRun | null>(supplyRunKey(weekStart), run)
|
||||
settle()
|
||||
},
|
||||
onSuccess: (run) => queryClient.setQueryData<SupplyRun | null>(supplyRunKey(weekStart), run),
|
||||
})
|
||||
}
|
||||
|
||||
@@ -503,77 +497,3 @@ function shown(run: SupplyRun, pending: Toggle[]): SupplyRun {
|
||||
}))
|
||||
return { ...run, needed_count: needed, groups }
|
||||
}
|
||||
|
||||
export const ARCHIVE_PERIODS = ['week', 'month', 'year'] as const
|
||||
|
||||
// ArchivePeriod is what the Supply archive is grouped by.
|
||||
export type ArchivePeriod = (typeof ARCHIVE_PERIODS)[number]
|
||||
|
||||
// ArchivePost is one week's Supply run in the Supply archive: label is the
|
||||
// week label, "Week 41 · Oct 5 – 11", which stands in for title while it is
|
||||
// null; onHandCount counts the items marked "have it", and dinners names the
|
||||
// dinners in day order. current is true for the household's current week.
|
||||
export type ArchivePost = {
|
||||
weekStart: string
|
||||
label: string
|
||||
title: string | null
|
||||
dinnerCount: number
|
||||
itemCount: number
|
||||
onHandCount: number
|
||||
dinners: string[]
|
||||
current: boolean
|
||||
}
|
||||
|
||||
// ArchiveGroup is one period of the Supply archive — a week, a month
|
||||
// ("October 2026") or a year ("2026") — and its posts, newest first.
|
||||
export type ArchiveGroup = { key: string; heading: string; posts: ArchivePost[] }
|
||||
|
||||
function parsePost(where: string, body: unknown): ArchivePost {
|
||||
if (
|
||||
!isRecord(body) ||
|
||||
typeof body.weekStart !== 'string' ||
|
||||
typeof body.label !== 'string' ||
|
||||
(body.title !== null && typeof body.title !== 'string') ||
|
||||
!Array.isArray(body.dinners) ||
|
||||
!body.dinners.every((name) => typeof name === 'string') ||
|
||||
typeof body.current !== 'boolean'
|
||||
) {
|
||||
throw new Error(`${where}: response has a post that is not a Supply archive post`)
|
||||
}
|
||||
return {
|
||||
weekStart: body.weekStart,
|
||||
label: body.label,
|
||||
title: body.title,
|
||||
dinnerCount: count(where, 'dinnerCount', body.dinnerCount),
|
||||
itemCount: count(where, 'itemCount', body.itemCount),
|
||||
onHandCount: count(where, 'onHandCount', body.onHandCount),
|
||||
dinners: body.dinners as string[],
|
||||
current: body.current,
|
||||
}
|
||||
}
|
||||
|
||||
function parseArchiveGroup(where: string, body: unknown): ArchiveGroup {
|
||||
if (!isRecord(body) || typeof body.key !== 'string' || typeof body.heading !== 'string' || !Array.isArray(body.posts)) {
|
||||
throw new Error(`${where}: response has a period that is not a Supply archive period`)
|
||||
}
|
||||
return { key: body.key, heading: body.heading, posts: body.posts.map((post) => parsePost(where, post)) }
|
||||
}
|
||||
|
||||
// useSupplyArchive is the Supply archive grouped by by, newest first (GET
|
||||
// /api/v1/shopping/archive?by=week|month|year): the server groups it in the
|
||||
// household's zone, so it is shown as answered, never grouped again here.
|
||||
export function useSupplyArchive(by: ArchivePeriod) {
|
||||
const { apiRead } = useApi()
|
||||
return useQuery({
|
||||
queryKey: ['supply archive', by] as const,
|
||||
queryFn: async (): Promise<ArchiveGroup[]> => {
|
||||
const path = `${SHOPPING_PATH}/archive?${new URLSearchParams({ by })}`
|
||||
const where = `GET ${path}`
|
||||
const { body } = await apiRead<unknown>(path)
|
||||
if (!isRecord(body) || body.by !== by || !Array.isArray(body.periods)) {
|
||||
throw new Error(`${where}: response is not the Supply archive by ${by}`)
|
||||
}
|
||||
return body.periods.map((period) => parseArchiveGroup(where, period))
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { QueryClientProvider } from '@tanstack/react-query'
|
||||
import { act, fireEvent, render, screen, waitFor, within } from '@testing-library/react'
|
||||
import { MemoryRouter } from 'react-router'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi, type Mock, type MockInstance } from 'vitest'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi, type Mock } from 'vitest'
|
||||
import { ApiProvider, createApiClient, createQueryClient, createVerifiedSession } from '../api/index.ts'
|
||||
import { createNetworkStatus, type NetworkWindow } from '../pwa/networkStatus.ts'
|
||||
import { HOUSEHOLD_SETTINGS } from '../test/ApiProviders.tsx'
|
||||
@@ -211,17 +211,6 @@ async function advance(ms: number) {
|
||||
await act(() => vi.advanceTimersByTimeAsync(ms))
|
||||
}
|
||||
|
||||
// The page's setTimeout, watched: retrySecondTry waits for a timer to be set.
|
||||
let timers: MockInstance<typeof setTimeout>
|
||||
|
||||
// retrySecondTry jumps past the wait before a toggle's second try — RETRY_FIRST
|
||||
// doubled by its failed first try — once that wait's timer is set, so the jump
|
||||
// never lands before the timer it is meant to fire.
|
||||
async function retrySecondTry() {
|
||||
await waitFor(() => expect(timers).toHaveBeenCalledWith(expect.any(Function), 2 * RETRY_FIRST))
|
||||
await advance(2 * RETRY_FIRST)
|
||||
}
|
||||
|
||||
function checkbox(name: string) {
|
||||
return screen.getByRole('checkbox', { name: new RegExp(`^${name}`) })
|
||||
}
|
||||
@@ -238,7 +227,6 @@ beforeEach(() => {
|
||||
// a wait (advance).
|
||||
vi.useFakeTimers({ toFake: ['Date', 'setTimeout', 'clearTimeout'], shouldAdvanceTime: true })
|
||||
vi.setSystemTime(new Date('2026-10-07T16:00:00Z'))
|
||||
timers = vi.spyOn(globalThis, 'setTimeout')
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
@@ -471,7 +459,7 @@ describe('offline toggle queue, replay', () => {
|
||||
expect(patches(server.fetchMock)).toHaveLength(1)
|
||||
expect(queue()).toEqual(['i3 true', 'i1 false'])
|
||||
|
||||
await retrySecondTry()
|
||||
await advance(RETRY_FIRST)
|
||||
await waitFor(() => expect(queue()).toEqual([]))
|
||||
expect(patches(server.fetchMock)).toEqual([
|
||||
`${ITEM('i3')} {"needed":true}`,
|
||||
@@ -504,7 +492,7 @@ describe('offline toggle queue, replay', () => {
|
||||
expect(patches(server.fetchMock)).toEqual([])
|
||||
|
||||
// The next try sends a fresh /me, which the server answers.
|
||||
await retrySecondTry()
|
||||
await advance(RETRY_FIRST)
|
||||
await waitFor(() => expect(queue()).toEqual([]))
|
||||
expect(mes(server.fetchMock)).toBe(3)
|
||||
expect(patches(server.fetchMock)).toEqual([`${ITEM('i3')} {"needed":true}`, `${ITEM('i1')} {"needed":false}`])
|
||||
@@ -544,7 +532,7 @@ describe('offline toggle queue, replay', () => {
|
||||
expect(queue()).toEqual(['i3 true', 'i1 false'])
|
||||
|
||||
// The next try is refused again and sends a fresh refresh, which answers.
|
||||
await retrySecondTry()
|
||||
await advance(RETRY_FIRST)
|
||||
await waitFor(() => expect(queue()).toEqual([]))
|
||||
expect(patches(server.fetchMock)).toEqual([
|
||||
`${ITEM('i3')} {"needed":true}`,
|
||||
@@ -567,10 +555,8 @@ describe('offline toggle queue, replay', () => {
|
||||
fireEvent.click(checkbox('Corn tortillas'))
|
||||
expect(await within(row('Corn tortillas')).findByText('will sync')).toBeInTheDocument()
|
||||
reachable = true
|
||||
expect(patches(fetchMock)).toEqual([])
|
||||
|
||||
// Nothing says the server is back: only the wait's timer tries it again.
|
||||
await retrySecondTry()
|
||||
await advance(RETRY_FIRST)
|
||||
await waitFor(() => expect(queue()).toEqual([]))
|
||||
// The first try failed at GET /api/v1/me, before its PATCH.
|
||||
expect(patches(fetchMock)).toEqual([`${ITEM('i2')} {"needed":false}`])
|
||||
|
||||
Reference in New Issue
Block a user