Compare commits
7
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6963eb9b20 | ||
|
|
7a2f80cc09 | ||
|
|
839eb61f38 | ||
|
|
cafea18651 | ||
|
|
067e85bd54 | ||
|
|
3ba2be05e9 | ||
|
|
cb451baed4 |
@@ -11,61 +11,14 @@ 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).
|
||||
|
||||
## Quick start
|
||||
## Running it locally
|
||||
|
||||
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).
|
||||
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.
|
||||
|
||||
## Common commands
|
||||
|
||||
@@ -78,7 +31,8 @@ household settings instead).
|
||||
|
||||
## Where to read next
|
||||
|
||||
- [AGENTS.md](AGENTS.md) — house rules, approved dependencies, conventions.
|
||||
- [AGENTS.md](AGENTS.md) — house rules, approved dependencies, the quality
|
||||
floor every change must pass, repository layout, 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,6 +19,7 @@ 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"
|
||||
)
|
||||
|
||||
@@ -59,6 +60,17 @@ 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 {
|
||||
|
||||
+7
-1
@@ -25,6 +25,7 @@ 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"
|
||||
@@ -138,6 +139,11 @@ 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
|
||||
@@ -154,7 +160,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, runner, photos, frontend.Assets())
|
||||
router, err := server.NewRouter(logger, version, cfg.TrustedProxy, authFlow, sessions, settings, search, ai, dinnerSvc, suggestSvc, planSvc, shopSvc, googleSvc, pushSvc, runner, photos, frontend.Assets())
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
// 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
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
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")
|
||||
}
|
||||
}
|
||||
+32
-31
@@ -1,20 +1,16 @@
|
||||
# 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.
|
||||
# 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".
|
||||
#
|
||||
# Secrets are never set here. They live in deploy/secrets/ (never committed),
|
||||
# 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)
|
||||
# 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".
|
||||
#
|
||||
# deploy/compose.yaml mounts them at /run/secrets/<name> and points the
|
||||
# *_FILE variables at them.
|
||||
@@ -42,8 +38,7 @@ 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). Confirm the household's zone before the first
|
||||
# deploy.
|
||||
# household settings instead).
|
||||
MEALQUEST_DEFAULT_TZ=America/New_York
|
||||
|
||||
# --- Authentik OIDC ----------------------------------------------------------
|
||||
@@ -57,12 +52,25 @@ MEALQUEST_OIDC_ALLOWED_GROUPS=mealquest-admin
|
||||
|
||||
# --- Google Calendar (Push to Calendar) ------------------------------------
|
||||
|
||||
# Client ID of the Google OAuth web client (deploy/RUNBOOK.md "Google
|
||||
# Calendar OAuth client"). Required: the service will not start without it
|
||||
# and the client secret file. Its authorized redirect URI is
|
||||
# <MEALQUEST_PUBLIC_URL origin>/api/v1/integrations/google/callback.
|
||||
# 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.
|
||||
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
|
||||
@@ -78,17 +86,10 @@ 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 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>
|
||||
# See CTXD project mealquest, RUNBOOKS.MD "Recipe search (SearXNG)".
|
||||
|
||||
-1417
File diff suppressed because it is too large
Load Diff
@@ -1,8 +1,7 @@
|
||||
# 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'
|
||||
# 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".
|
||||
#
|
||||
# Loopback only, never a LAN address. Never used in production: the
|
||||
# systemd unit starts deploy/compose.yaml alone.
|
||||
|
||||
+14
-12
@@ -1,18 +1,16 @@
|
||||
# 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
|
||||
# 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".
|
||||
#
|
||||
# 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 (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).
|
||||
# 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).
|
||||
name: mealquest
|
||||
|
||||
services:
|
||||
@@ -33,11 +31,13 @@ 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.
|
||||
#
|
||||
# Restore: follow "Restore the database" in deploy/RUNBOOK.md (stop the
|
||||
# stack, move the database with its -wal and -shm aside, restore, check).
|
||||
# Restoring the database: CTXD project mealquest, RUNBOOKS.MD "Restore
|
||||
# the database".
|
||||
litestream:
|
||||
image: litestream/litestream:0.5.17
|
||||
user: "10001:10001"
|
||||
@@ -132,6 +132,8 @@ 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,8 +5,9 @@
|
||||
# are one-way: a failure here fails this unit visibly and never fails
|
||||
# docker.service.
|
||||
#
|
||||
# Start MealQuest only after this unit has succeeded:
|
||||
# systemctl start mealquest-firewall && docker compose up -d
|
||||
# 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".
|
||||
[Unit]
|
||||
Description=Restrict MealQuest port 8090 to Traefik (DOCKER-USER)
|
||||
After=docker.service
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
# Runs the nightly MealQuest media backup (/usr/local/sbin/mealquest-media-backup).
|
||||
# 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
|
||||
# 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".
|
||||
#
|
||||
# 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,8 +15,9 @@
|
||||
# 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 (deploy/RUNBOOK.md). If it
|
||||
# is missing the job fails; it is never treated as "nothing to back up".
|
||||
# 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".
|
||||
#
|
||||
# rsync mirrors into .../mealquest-backups/media/ only: the target ends in
|
||||
# a slash, so --delete removes files from that directory and nothing
|
||||
|
||||
@@ -43,6 +43,7 @@ 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. |
|
||||
@@ -53,6 +54,7 @@ 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. |
|
||||
@@ -657,6 +659,31 @@ 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,9 +3,11 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"crypto/ecdh"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net"
|
||||
"net/mail"
|
||||
"net/url"
|
||||
"os"
|
||||
"strconv"
|
||||
@@ -14,6 +16,7 @@ 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.
|
||||
@@ -32,6 +35,10 @@ 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"
|
||||
|
||||
@@ -70,6 +77,9 @@ 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.
|
||||
@@ -93,6 +103,20 @@ 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 {
|
||||
@@ -122,6 +146,8 @@ 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),
|
||||
|
||||
@@ -387,6 +413,61 @@ 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,8 +14,24 @@ 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()
|
||||
|
||||
@@ -37,6 +53,16 @@ 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{
|
||||
@@ -53,6 +79,10 @@ 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 {
|
||||
@@ -102,8 +132,108 @@ 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"}),
|
||||
@@ -446,6 +576,7 @@ 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",
|
||||
@@ -461,6 +592,10 @@ 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",
|
||||
@@ -500,6 +635,9 @@ 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)
|
||||
}
|
||||
@@ -538,7 +676,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}
|
||||
cfg := Config{SecretKey: s, OIDCClientSecret: s, GoogleClientSecret: s, VAPID: VAPID{PrivateKey: 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)
|
||||
@@ -547,6 +685,38 @@ 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()
|
||||
|
||||
|
||||
@@ -0,0 +1,77 @@
|
||||
// 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,6 +62,7 @@ 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
|
||||
@@ -276,6 +277,10 @@ 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
|
||||
|
||||
@@ -0,0 +1,203 @@
|
||||
// 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 ""
|
||||
}
|
||||
@@ -0,0 +1,224 @@
|
||||
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), newTestRunner(t), newTestPhotos(t), fstest.MapFS{"index.html": {Data: []byte(fixtureIndex)}})
|
||||
newTestPlan(t), newTestShopping(t), newTestGoogle(t), newTestPush(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), runner, newTestPhotos(t), fstest.MapFS{"index.html": {Data: []byte(fixtureIndex)}})
|
||||
svc, newTestSuggest(t), newTestPlan(t), newTestShopping(t), newTestGoogle(t), newTestPush(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, newTestRunner(t), newTestPhotos(t),
|
||||
newTestDinners(t), newTestSuggest(t), newTestPlan(t), newTestShopping(t), svc, newTestPush(t), 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), 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), newTestPush(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), 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), newTestPush(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), 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), newTestPush(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, runner, newTestPhotos(t), fstest.MapFS{"index.html": {Data: []byte(fixtureIndex)}})
|
||||
newTestDinners(t), newTestSuggest(t), svc, shop, googleSvc, newTestPush(t), runner, newTestPhotos(t), fstest.MapFS{"index.html": {Data: []byte(fixtureIndex)}})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,98 @@
|
||||
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")
|
||||
}
|
||||
@@ -0,0 +1,284 @@
|
||||
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), 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), newTestPush(t), newTestRunner(t),
|
||||
newTestPhotos(t), fstest.MapFS{"index.html": {Data: []byte(fixtureIndex)}})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
|
||||
@@ -18,6 +18,7 @@ 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"
|
||||
@@ -55,12 +56,14 @@ 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; runner serves
|
||||
// /api/v1/integrations/google; pushSvc serves GET
|
||||
// /api/v1/push/vapid-public-key and POST and DELETE
|
||||
// /api/v1/push/subscriptions; 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, 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, pushSvc *push.Service, runner *jobs.Runner, photos PhotoFiles, assets fs.FS) (http.Handler, error) {
|
||||
if settings == nil {
|
||||
return nil, errors.New("server: nil household settings service")
|
||||
}
|
||||
@@ -85,6 +88,9 @@ 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")
|
||||
}
|
||||
@@ -158,6 +164,9 @@ 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), 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), newTestPush(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), 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), newTestPush(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), 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), newTestPush(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), 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), newTestPush(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), 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), newTestPush(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), 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), newTestPush(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), 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), newTestPush(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), 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), newTestPush(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), 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), newTestPush(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, 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, newTestPush(t), 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), 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), newTestPush(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), 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), newTestPush(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), runner, newTestPhotos(t), fstest.MapFS{"index.html": {Data: []byte(fixtureIndex)}})
|
||||
newTestDinners(t), svc, newTestPlan(t), newTestShopping(t), newTestGoogle(t), newTestPush(t), runner, newTestPhotos(t), fstest.MapFS{"index.html": {Data: []byte(fixtureIndex)}})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,87 @@
|
||||
// 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
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
-- 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,6 +33,7 @@ const SCREENS = {
|
||||
'/onboarding': true,
|
||||
'/plan': true,
|
||||
'/plan/shopping': true,
|
||||
'/plan/shopping/archive': true,
|
||||
'/discover': true,
|
||||
'/settings': true,
|
||||
'/settings/ai': true,
|
||||
@@ -130,6 +131,18 @@ 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,6 +17,7 @@ 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.
|
||||
@@ -42,6 +43,7 @@ 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,6 +414,16 @@
|
||||
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;
|
||||
|
||||
+288
-11
@@ -1,9 +1,10 @@
|
||||
import { QueryClientProvider } from '@tanstack/react-query'
|
||||
import { QueryClientProvider, type QueryClient } from '@tanstack/react-query'
|
||||
import { act, fireEvent, render, screen, waitFor, within } from '@testing-library/react'
|
||||
import { MemoryRouter, Route, Routes, useLocation } from 'react-router'
|
||||
import { Link, 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'
|
||||
@@ -52,9 +53,33 @@ function planBody(status: string, names = NAMES) {
|
||||
}
|
||||
|
||||
// syncedNow is the plan of the week of Oct 5 as a push answers it: synced
|
||||
// at the tests' noon.
|
||||
function syncedNow() {
|
||||
return { ...planBody('synced'), calendar_synced_at: '2026-10-07T16:00:00Z' }
|
||||
// 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,
|
||||
},
|
||||
}
|
||||
|
||||
// deferred is a response the test sends when it chooses.
|
||||
@@ -135,17 +160,17 @@ function planReads(fetchMock: Mock<typeof fetch>) {
|
||||
}
|
||||
|
||||
// renderPage loads /me (for the CSRF token every write needs) and renders
|
||||
// the Plan page.
|
||||
async function renderPage() {
|
||||
// the Plan page, over queryClient, with shopping as the Supply run screen.
|
||||
async function renderPage(queryClient: QueryClient = createQueryClient(), shopping = <Opened />) {
|
||||
const api = createApiClient(fakeLocation('/plan'), createNetworkStatus(window), createVerifiedSession(localStorage))
|
||||
await api.loadMe()
|
||||
return render(
|
||||
<ApiProvider client={api}>
|
||||
<QueryClientProvider client={createQueryClient()}>
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<MemoryRouter initialEntries={['/plan']}>
|
||||
<Routes>
|
||||
<Route path="/plan" element={<PlanPage />} />
|
||||
<Route path="/plan/shopping" element={<Opened />} />
|
||||
<Route path="/plan/shopping" element={shopping} />
|
||||
</Routes>
|
||||
</MemoryRouter>
|
||||
</QueryClientProvider>
|
||||
@@ -153,10 +178,32 @@ async function renderPage() {
|
||||
)
|
||||
}
|
||||
|
||||
// Opened stands in for the screen a link opened, saying where it is.
|
||||
// Opened stands in for the screen a link opened, saying where it is, with
|
||||
// a link back to the Plan.
|
||||
function Opened() {
|
||||
const { pathname, search } = useLocation()
|
||||
return <p>Opened {pathname + search}</p>
|
||||
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>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
// supplyRun is the Supply run of the week of Oct 5 with needed of its 32
|
||||
@@ -420,6 +467,236 @@ 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, useSupplyRun } from '../shopping/api.ts'
|
||||
import { supplyRunScreen, useGenerateSupplyRun, 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,6 +31,7 @@ import {
|
||||
type PlanDay,
|
||||
type PlanDinner,
|
||||
type PlanStatus,
|
||||
type PushedSupplyRun,
|
||||
type Swap,
|
||||
} from './api.ts'
|
||||
import { DragAnnouncer, DROP_DATE, DropBoard, PlanDragProvider, useDropTarget } from './drag.tsx'
|
||||
@@ -88,6 +89,13 @@ 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.
|
||||
@@ -238,7 +246,9 @@ 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.
|
||||
// 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.
|
||||
function WeekBoard({
|
||||
weekStart,
|
||||
timezone,
|
||||
@@ -259,6 +269,8 @@ 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)
|
||||
@@ -328,6 +340,7 @@ function WeekBoard({
|
||||
if (!canPush) return
|
||||
swap.dismiss()
|
||||
push.reset()
|
||||
generate.reset()
|
||||
push.mutate()
|
||||
}
|
||||
|
||||
@@ -339,6 +352,7 @@ function WeekBoard({
|
||||
setJobId(null)
|
||||
swap.dismiss()
|
||||
push.reset()
|
||||
generate.reset()
|
||||
mutate(
|
||||
{ weekStart, confirm },
|
||||
{
|
||||
@@ -357,8 +371,33 @@ 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={cached ? () => void plan.refetch() : pushToCalendar} />
|
||||
<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>
|
||||
)
|
||||
) : swap.failed !== null ? (
|
||||
<ErrorBanner onRetry={() => swapIn(swap.failed!.swap)}>
|
||||
Couldn't swap {weekdayName(swap.failed.swap.date)}'s dinner. {swap.failed.error.message}
|
||||
@@ -376,6 +415,11 @@ 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
|
||||
@@ -391,6 +435,7 @@ 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,
|
||||
@@ -562,6 +607,7 @@ function WeekBoard({
|
||||
{header}
|
||||
{body}
|
||||
{failure}
|
||||
{pushed}
|
||||
{layout !== 'mobile' && supplyRunFailure}
|
||||
<Confirm
|
||||
open={confirming}
|
||||
|
||||
@@ -10,6 +10,21 @@ 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']
|
||||
@@ -318,7 +333,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 })))
|
||||
await act(async () => nextPush.send(jsonResponse(200, { ...planBody(), week_start: NEXT, ...GENERATED })))
|
||||
await waitFor(() => expect(weekMutations(queryClient, NEXT)).toEqual([]))
|
||||
expect(weekMutations(queryClient, WEEK)).toEqual(['swap pending', 'swap success'])
|
||||
|
||||
@@ -338,6 +353,43 @@ 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()
|
||||
@@ -394,7 +446,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)))
|
||||
await act(async () => push.send(jsonResponse(200, { ...synced, ...GENERATED })))
|
||||
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') })
|
||||
})
|
||||
|
||||
+120
-11
@@ -5,6 +5,7 @@ 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'
|
||||
|
||||
@@ -114,6 +115,56 @@ 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 {
|
||||
@@ -178,6 +229,19 @@ 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 {
|
||||
@@ -203,9 +267,13 @@ 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. 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.
|
||||
// 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.
|
||||
function keepWeekMutations(queryClient: QueryClient): WeeksShown {
|
||||
const kept = weeksShown.get(queryClient)
|
||||
if (kept !== undefined) return kept
|
||||
@@ -225,7 +293,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 (settled && mutation === latest && mutation.state.status === 'error') continue
|
||||
if (mutation === latest && (unmade(mutation) || (settled && failed(mutation)))) continue
|
||||
mutations.remove(mutation)
|
||||
}
|
||||
}
|
||||
@@ -363,10 +431,12 @@ function pushKey(weekStart: string) {
|
||||
}
|
||||
|
||||
// PushToCalendar is the push of one week, as the mutation cache holds it:
|
||||
// whether it is pending, and why the latest failed, if it failed.
|
||||
// whether it is pending, why the latest failed, if it failed, and what
|
||||
// became of the week's Supply run, if it succeeded.
|
||||
export type PushToCalendar = {
|
||||
pending: boolean
|
||||
error: Error | null
|
||||
supplyRun: PushedSupplyRun | null
|
||||
mutate: () => void
|
||||
reset: () => void
|
||||
}
|
||||
@@ -380,10 +450,19 @@ 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. Settled
|
||||
// pushes of a week no longer shown are left behind too
|
||||
// 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
|
||||
// (keepWeekMutations).
|
||||
export function usePushToCalendar(weekStart: string): PushToCalendar {
|
||||
const { apiFetch } = useApi()
|
||||
@@ -395,12 +474,17 @@ export function usePushToCalendar(weekStart: string): PushToCalendar {
|
||||
// The week's pushes, in the order sent.
|
||||
const pushes = useMutationState({
|
||||
filters: { mutationKey },
|
||||
select: (mutation) => ({ status: mutation.state.status, error: mutation.state.error }),
|
||||
select: (mutation) => ({
|
||||
mutationId: mutation.mutationId,
|
||||
status: mutation.state.status,
|
||||
error: mutation.state.error,
|
||||
data: mutation.state.data as PushedPlan | undefined,
|
||||
}),
|
||||
})
|
||||
const latest = pushes.at(-1)
|
||||
|
||||
function mutate() {
|
||||
const mutation = mutations.build<Plan, Error, void, unknown>(queryClient, {
|
||||
const mutation = mutations.build<PushedPlan, 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
|
||||
@@ -408,9 +492,15 @@ export function usePushToCalendar(weekStart: string): PushToCalendar {
|
||||
gcTime: Infinity,
|
||||
mutationFn: async () => {
|
||||
const path = `${planPath(weekStart)}/push-calendar`
|
||||
return parsePlan(`POST ${path}`, weekStart, await apiFetch<unknown>(path, { method: 'POST' }), false)
|
||||
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' })
|
||||
}
|
||||
},
|
||||
onSuccess: (plan) => queryClient.setQueryData<Plan>(key, plan),
|
||||
onError: async (error) => {
|
||||
noteGoogleFailure(queryClient, error)
|
||||
if (error instanceof ApiError && error.code === 'plan_changed') {
|
||||
@@ -433,7 +523,26 @@ 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,231 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import {
|
||||
APP_BADGE,
|
||||
APP_ICON,
|
||||
GENERIC_NOTICE,
|
||||
appPath,
|
||||
clickPath,
|
||||
openNotification,
|
||||
parsePushPayload,
|
||||
planClick,
|
||||
showPush,
|
||||
type ClickWindow,
|
||||
} from './notification.ts'
|
||||
|
||||
const origin = 'https://mealquest.example'
|
||||
|
||||
describe('appPath', () => {
|
||||
it('keeps a same-origin path with its query and fragment', () => {
|
||||
expect(appPath('/plan/shopping?week=2026-10-05#tuesday', origin)).toBe('/plan/shopping?week=2026-10-05#tuesday')
|
||||
})
|
||||
|
||||
it.each([
|
||||
'https://evil.example/plan',
|
||||
'//evil.example/plan',
|
||||
'/\\evil.example/plan',
|
||||
'javascript:alert(1)',
|
||||
'plan',
|
||||
'',
|
||||
])('refuses %s', (raw) => {
|
||||
expect(appPath(raw, origin)).toBeNull()
|
||||
})
|
||||
|
||||
it('resolves dot segments and keeps the result on origin', () => {
|
||||
expect(appPath('/plan/../discover', origin)).toBe('/discover')
|
||||
})
|
||||
|
||||
// Each of these starts with a single / but resolves to a path starting with
|
||||
// //, which a browser reads as another host.
|
||||
it.each([
|
||||
'/..//evil.example/path',
|
||||
'/plan/..//evil.example/path',
|
||||
'/..\\\\evil.example/path',
|
||||
'/./\\evil.example',
|
||||
'/.\\/evil.example',
|
||||
'/%2e%2e//evil.example/path',
|
||||
'/.\t.//evil.example/path',
|
||||
'/..\n//evil.example',
|
||||
])('refuses %j, which resolves to a protocol-relative path', (raw) => {
|
||||
expect(appPath(raw, origin)).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('parsePushPayload', () => {
|
||||
it('reads title, body, tag and url', () => {
|
||||
const text = JSON.stringify({ title: 'Plan ready', body: 'Your Draft board is ready.', tag: 'plan_ready', url: '/plan' })
|
||||
expect(parsePushPayload(text, origin)).toEqual({
|
||||
notice: { title: 'Plan ready', body: 'Your Draft board is ready.', tag: 'plan_ready', url: '/plan' },
|
||||
problem: null,
|
||||
})
|
||||
})
|
||||
|
||||
it('opens home with an empty body and no tag when only a title is sent', () => {
|
||||
expect(parsePushPayload(JSON.stringify({ title: 'Hi' }), origin)).toEqual({
|
||||
notice: { title: 'Hi', body: '', url: '/' },
|
||||
problem: null,
|
||||
})
|
||||
})
|
||||
|
||||
it('ignores fields it does not know', () => {
|
||||
const parsed = parsePushPayload(JSON.stringify({ title: 'Hi', event: 'plan_ready' }), origin)
|
||||
expect(parsed.problem).toBeNull()
|
||||
expect(parsed.notice).toEqual({ title: 'Hi', body: '', url: '/' })
|
||||
})
|
||||
|
||||
it.each([
|
||||
[null, 'push message has no payload'],
|
||||
['', 'push message has no payload'],
|
||||
['\u0000ÿ not json', 'push payload is not JSON'],
|
||||
['[1,2]', 'push payload is not a JSON object'],
|
||||
['null', 'push payload is not a JSON object'],
|
||||
['{"body":"no title"}', 'push payload has no title'],
|
||||
['{"title":" "}', 'push payload has no title'],
|
||||
['{"title":42}', 'push payload has no title'],
|
||||
['{"title":"Hi","body":7}', 'push payload body is not a string'],
|
||||
['{"title":"Hi","tag":""}', 'push payload tag is not a non-empty string'],
|
||||
['{"title":"Hi","url":"https://evil.example/"}', 'push payload url is not a same-origin path'],
|
||||
['{"title":"Hi","url":5}', 'push payload url is not a same-origin path'],
|
||||
])('shows the generic notice for %j', (text, problem) => {
|
||||
expect(parsePushPayload(text, origin)).toEqual({ notice: GENERIC_NOTICE, problem })
|
||||
})
|
||||
})
|
||||
|
||||
describe('showPush', () => {
|
||||
it('shows the payload with the app icon and badge, its tag and its url as data', async () => {
|
||||
const registration = { showNotification: vi.fn(async () => {}) }
|
||||
const warn = vi.fn()
|
||||
await showPush(
|
||||
JSON.stringify({ title: 'Supply run ready', body: 'Time to shop.', tag: 'list_ready', url: '/plan/shopping' }),
|
||||
registration,
|
||||
origin,
|
||||
warn,
|
||||
)
|
||||
expect(registration.showNotification).toHaveBeenCalledWith('Supply run ready', {
|
||||
body: 'Time to shop.',
|
||||
tag: 'list_ready',
|
||||
icon: APP_ICON,
|
||||
badge: APP_BADGE,
|
||||
data: { url: '/plan/shopping' },
|
||||
})
|
||||
expect(warn).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('shows the generic notification for a malformed payload and logs why, without throwing', async () => {
|
||||
const registration = { showNotification: vi.fn(async () => {}) }
|
||||
const warn = vi.fn()
|
||||
await expect(showPush('{not json', registration, origin, warn)).resolves.toBeUndefined()
|
||||
expect(registration.showNotification).toHaveBeenCalledWith('MealQuest', {
|
||||
body: GENERIC_NOTICE.body,
|
||||
icon: APP_ICON,
|
||||
badge: APP_BADGE,
|
||||
data: { url: '/' },
|
||||
})
|
||||
expect(warn).toHaveBeenCalledWith('MealQuest: push payload is not JSON; showing the generic notification')
|
||||
})
|
||||
})
|
||||
|
||||
describe('clickPath', () => {
|
||||
it('opens the url the notification carries', () => {
|
||||
expect(clickPath({ url: '/discover' }, origin)).toBe('/discover')
|
||||
})
|
||||
|
||||
it.each([undefined, null, 'x', {}, { url: 'https://evil.example/' }])('opens home for data %j', (data) => {
|
||||
expect(clickPath(data, origin)).toBe('/')
|
||||
})
|
||||
})
|
||||
|
||||
describe('planClick', () => {
|
||||
it('refuses a path that resolves off origin', () => {
|
||||
expect(() => planClick([], '//evil.example/path', origin)).toThrow(/resolves off origin/)
|
||||
})
|
||||
|
||||
const at = (path: string, focused = false) => ({ url: origin + path, focused })
|
||||
|
||||
it('opens a new window when no MealQuest window is open', () => {
|
||||
expect(planClick([], '/plan', origin)).toEqual({ action: 'open', url: `${origin}/plan` })
|
||||
})
|
||||
|
||||
it('ignores windows on another origin', () => {
|
||||
const other = { url: 'https://other.example/plan', focused: true }
|
||||
expect(planClick([other], '/plan', origin)).toEqual({ action: 'open', url: `${origin}/plan` })
|
||||
})
|
||||
|
||||
it('focuses the focused MealQuest window and navigates it', () => {
|
||||
const first = at('/')
|
||||
const focused = at('/discover', true)
|
||||
expect(planClick([first, focused], '/plan', origin)).toEqual({
|
||||
action: 'focus',
|
||||
window: focused,
|
||||
url: `${origin}/plan`,
|
||||
navigate: true,
|
||||
})
|
||||
})
|
||||
|
||||
it('focuses the first MealQuest window when none is focused', () => {
|
||||
const first = at('/')
|
||||
expect(planClick([first, at('/discover')], '/plan', origin)).toMatchObject({ action: 'focus', window: first })
|
||||
})
|
||||
|
||||
it('does not reload a window already at the url', () => {
|
||||
expect(planClick([at('/plan')], '/plan', origin)).toMatchObject({ action: 'focus', navigate: false })
|
||||
})
|
||||
})
|
||||
|
||||
describe('openNotification', () => {
|
||||
function window(path: string, focused = false) {
|
||||
return {
|
||||
url: origin + path,
|
||||
focused,
|
||||
focus: vi.fn(async () => {}),
|
||||
navigate: vi.fn(async () => {}),
|
||||
} satisfies ClickWindow
|
||||
}
|
||||
|
||||
function clients(windows: ClickWindow[]) {
|
||||
return { matchAll: vi.fn(async () => windows), openWindow: vi.fn(async () => null) }
|
||||
}
|
||||
|
||||
it('focuses an open window, then navigates it to the url', async () => {
|
||||
const win = window('/', true)
|
||||
const all = clients([win])
|
||||
await openNotification({ url: '/plan/shopping' }, all, origin)
|
||||
expect(all.matchAll).toHaveBeenCalledWith({ type: 'window' })
|
||||
expect(win.focus).toHaveBeenCalled()
|
||||
expect(win.navigate).toHaveBeenCalledWith(`${origin}/plan/shopping`)
|
||||
expect(win.focus.mock.invocationCallOrder[0]).toBeLessThan(win.navigate.mock.invocationCallOrder[0]!)
|
||||
expect(all.openWindow).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('opens a new window at the url when none is open', async () => {
|
||||
const all = clients([])
|
||||
await openNotification({ url: '/discover' }, all, origin)
|
||||
expect(all.openWindow).toHaveBeenCalledWith(`${origin}/discover`)
|
||||
})
|
||||
|
||||
it('never opens a url off origin, even from tampered data', async () => {
|
||||
const all = clients([])
|
||||
await openNotification({ url: 'https://evil.example/' }, all, origin)
|
||||
expect(all.openWindow).toHaveBeenCalledWith(`${origin}/`)
|
||||
})
|
||||
|
||||
it.each(['/..//evil.example/path', '/..\\\\evil.example/path', '/%2e%2e//evil.example/path', '/.\t.//evil.example/path'])(
|
||||
'never leaves origin for %j, which resolves to a protocol-relative path',
|
||||
async (url) => {
|
||||
const win = window('/plan', true)
|
||||
const all = clients([win])
|
||||
await openNotification({ url }, all, origin)
|
||||
expect(win.navigate).toHaveBeenCalledWith(`${origin}/`)
|
||||
expect(all.openWindow).not.toHaveBeenCalled()
|
||||
|
||||
const none = clients([])
|
||||
await openNotification({ url }, none, origin)
|
||||
expect(none.openWindow).toHaveBeenCalledWith(`${origin}/`)
|
||||
},
|
||||
)
|
||||
|
||||
it('still opens an internal path that uses dot segments', async () => {
|
||||
const all = clients([])
|
||||
await openNotification({ url: '/plan/../discover' }, all, origin)
|
||||
expect(all.openWindow).toHaveBeenCalledWith(`${origin}/discover`)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,174 @@
|
||||
// Web Push notifications as the service worker shows them, and where a tap on
|
||||
// one takes the household. The worker's push and notificationclick listeners
|
||||
// (src/sw.ts) only adapt these to the worker's globals, so every decision runs
|
||||
// under test without a real ServiceWorkerGlobalScope. This file is checked
|
||||
// with both the DOM and the WebWorker libraries, so it names only the narrow
|
||||
// shapes it uses.
|
||||
|
||||
// APP_ICON and APP_BADGE are the app icon from the web manifest.
|
||||
export const APP_ICON = '/icons/icon-192.png'
|
||||
export const APP_BADGE = '/icons/icon-192.png'
|
||||
|
||||
// PushNotice is a notification to show: its title, body and tag, and the
|
||||
// same-origin path a tap opens.
|
||||
export type PushNotice = { title: string; body: string; tag?: string; url: string }
|
||||
|
||||
// GENERIC_NOTICE is shown for a push whose payload is not a notice: the
|
||||
// household still learns that MealQuest has something for them.
|
||||
export const GENERIC_NOTICE: PushNotice = {
|
||||
title: 'MealQuest',
|
||||
body: "There's something new in MealQuest.",
|
||||
url: '/',
|
||||
}
|
||||
|
||||
// ParsedPush is the notice to show for a push, and why its payload was not
|
||||
// used, when it was not.
|
||||
export type ParsedPush = { notice: PushNotice; problem: string | null }
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === 'object' && value !== null && !Array.isArray(value)
|
||||
}
|
||||
|
||||
// appPath returns raw as a path on origin (path, query and fragment), or null
|
||||
// when raw is not a path-absolute URL that stays on origin. A payload never
|
||||
// sends the household to another site. The path is checked after the parser
|
||||
// has resolved it: dot segments (also as %2e), backslashes and stripped tabs
|
||||
// and newlines can all turn an innocent-looking path into one starting with
|
||||
// //, which a browser would read as another host.
|
||||
export function appPath(raw: string, origin: string): string | null {
|
||||
if (!raw.startsWith('/') || raw.startsWith('//')) return null
|
||||
let url: URL
|
||||
try {
|
||||
url = new URL(raw, origin)
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
if (url.origin !== origin || url.pathname.startsWith('//')) return null
|
||||
return url.pathname + url.search + url.hash
|
||||
}
|
||||
|
||||
// parsePushPayload reads a push message's payload, which may be any bytes:
|
||||
// a JSON object with a non-empty string title, and optionally a string body,
|
||||
// a non-empty string tag and a url that is a same-origin path. Other fields
|
||||
// are ignored. Anything else — no payload, not JSON, a missing title, a
|
||||
// field of the wrong type, a url off origin — is shown as GENERIC_NOTICE,
|
||||
// with the problem for the worker to log. It never throws.
|
||||
export function parsePushPayload(text: string | null, origin: string): ParsedPush {
|
||||
const generic = (problem: string): ParsedPush => ({ notice: GENERIC_NOTICE, problem })
|
||||
if (text === null || text === '') return generic('push message has no payload')
|
||||
let payload: unknown
|
||||
try {
|
||||
payload = JSON.parse(text)
|
||||
} catch {
|
||||
return generic('push payload is not JSON')
|
||||
}
|
||||
if (!isRecord(payload)) return generic('push payload is not a JSON object')
|
||||
const { title, body, tag, url } = payload
|
||||
if (typeof title !== 'string' || title.trim() === '') return generic('push payload has no title')
|
||||
if (body !== undefined && typeof body !== 'string') return generic('push payload body is not a string')
|
||||
if (tag !== undefined && (typeof tag !== 'string' || tag === '')) {
|
||||
return generic('push payload tag is not a non-empty string')
|
||||
}
|
||||
let path = '/'
|
||||
if (url !== undefined) {
|
||||
const safe = typeof url === 'string' ? appPath(url, origin) : null
|
||||
if (safe === null) return generic('push payload url is not a same-origin path')
|
||||
path = safe
|
||||
}
|
||||
const notice: PushNotice = { title, body: body ?? '', url: path }
|
||||
if (tag !== undefined) notice.tag = tag
|
||||
return { notice, problem: null }
|
||||
}
|
||||
|
||||
// NoticeData is the data a shown notification carries for its click.
|
||||
export type NoticeData = { url: string }
|
||||
|
||||
// notificationOptions is what showNotification is given for notice: its body
|
||||
// and tag, the app icon and badge, and the path to open as its data.
|
||||
export function notificationOptions(notice: PushNotice): NotificationOptions & { data: NoticeData } {
|
||||
const options: NotificationOptions & { data: NoticeData } = {
|
||||
body: notice.body,
|
||||
icon: APP_ICON,
|
||||
badge: APP_BADGE,
|
||||
data: { url: notice.url },
|
||||
}
|
||||
if (notice.tag !== undefined) options.tag = notice.tag
|
||||
return options
|
||||
}
|
||||
|
||||
export type NotificationShower = {
|
||||
showNotification(title: string, options: NotificationOptions): Promise<void>
|
||||
}
|
||||
|
||||
// showPush shows the notification for a push message's payload text,
|
||||
// reporting through warn why a payload was not used.
|
||||
export function showPush(
|
||||
text: string | null,
|
||||
registration: NotificationShower,
|
||||
origin: string,
|
||||
warn: (message: string) => void,
|
||||
): Promise<void> {
|
||||
const { notice, problem } = parsePushPayload(text, origin)
|
||||
if (problem !== null) warn(`MealQuest: ${problem}; showing the generic notification`)
|
||||
return registration.showNotification(notice.title, notificationOptions(notice))
|
||||
}
|
||||
|
||||
// clickPath is the same-origin path a tapped notification opens: the url in
|
||||
// its data, which showPush put there, or the app's home when the data is not
|
||||
// ours (a notification shown by an older worker).
|
||||
export function clickPath(data: unknown, origin: string): string {
|
||||
if (isRecord(data) && typeof data.url === 'string') {
|
||||
const path = appPath(data.url, origin)
|
||||
if (path !== null) return path
|
||||
}
|
||||
return '/'
|
||||
}
|
||||
|
||||
// AppWindow is the part of a window client a click reads.
|
||||
export type AppWindow = { url: string; focused: boolean }
|
||||
|
||||
// ClickPlan is what a notification tap does: focus an open MealQuest window
|
||||
// and navigate it to url, or open a new window at url. url is absolute.
|
||||
export type ClickPlan<W> = { action: 'focus'; window: W; url: string; navigate: boolean } | { action: 'open'; url: string }
|
||||
|
||||
// planClick decides a tap on a notification that opens path: the focused
|
||||
// MealQuest window if there is one, else the first open one, navigated to
|
||||
// path unless it is already there; with none open, a new window. A path that
|
||||
// resolves off origin is an error: a tap never leaves MealQuest.
|
||||
export function planClick<W extends AppWindow>(windows: readonly W[], path: string, origin: string): ClickPlan<W> {
|
||||
const resolved = new URL(path, origin)
|
||||
if (resolved.origin !== origin) {
|
||||
throw new Error(`MealQuest: notification path ${JSON.stringify(path)} resolves off origin to ${resolved.origin}`)
|
||||
}
|
||||
const url = resolved.href
|
||||
const ours = windows.filter((w) => URL.canParse(w.url) && new URL(w.url).origin === origin)
|
||||
const target = ours.find((w) => w.focused) ?? ours[0]
|
||||
if (target === undefined) return { action: 'open', url }
|
||||
return { action: 'focus', window: target, url, navigate: target.url !== url }
|
||||
}
|
||||
|
||||
export type ClickWindow = AppWindow & {
|
||||
focus(): Promise<unknown>
|
||||
navigate(url: string): Promise<unknown>
|
||||
}
|
||||
|
||||
export type ClickClients<W extends ClickWindow> = {
|
||||
matchAll(options: { type: 'window' }): Promise<readonly W[]>
|
||||
openWindow(url: string): Promise<unknown>
|
||||
}
|
||||
|
||||
// openNotification carries out a tap on a notification with data: focus
|
||||
// first, while the tap still allows it, then navigate.
|
||||
export async function openNotification<W extends ClickWindow>(
|
||||
data: unknown,
|
||||
clients: ClickClients<W>,
|
||||
origin: string,
|
||||
): Promise<void> {
|
||||
const plan = planClick(await clients.matchAll({ type: 'window' }), clickPath(data, origin), origin)
|
||||
if (plan.action === 'open') {
|
||||
await clients.openWindow(plan.url)
|
||||
return
|
||||
}
|
||||
await plan.window.focus()
|
||||
if (plan.navigate) await plan.window.navigate(plan.url)
|
||||
}
|
||||
@@ -0,0 +1,308 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { ApiError, type ApiRequest } from '../api/index.ts'
|
||||
import {
|
||||
SUBSCRIPTIONS_PATH,
|
||||
VAPID_PUBLIC_KEY_PATH,
|
||||
decodeVapidKey,
|
||||
encodeBase64url,
|
||||
PermissionGestureError,
|
||||
subscribePush,
|
||||
subscriptionBody,
|
||||
unsubscribePush,
|
||||
type BrowserPushSubscription,
|
||||
type PushEnvironment,
|
||||
} from './subscribe.ts'
|
||||
|
||||
function bytes(length: number, first: number): Uint8Array<ArrayBuffer> {
|
||||
const out = new Uint8Array(new ArrayBuffer(length))
|
||||
for (let i = 0; i < length; i++) out[i] = (first + i) % 256
|
||||
return out
|
||||
}
|
||||
|
||||
const vapidKey = bytes(65, 4)
|
||||
const vapidKeyText = encodeBase64url(vapidKey.buffer)
|
||||
const p256dh = bytes(65, 4).buffer
|
||||
const auth = bytes(16, 200).buffer
|
||||
const endpoint = 'https://push.example/send/abc'
|
||||
|
||||
function subscription(key: ArrayBuffer | null = vapidKey.buffer, at = endpoint) {
|
||||
return {
|
||||
endpoint: at,
|
||||
options: { applicationServerKey: key },
|
||||
getKey: (name: 'p256dh' | 'auth') => (name === 'p256dh' ? p256dh : auth),
|
||||
unsubscribe: vi.fn(async () => true),
|
||||
} satisfies BrowserPushSubscription
|
||||
}
|
||||
|
||||
type Setup = {
|
||||
permission?: NotificationPermission
|
||||
answer?: NotificationPermission
|
||||
active?: boolean
|
||||
current?: BrowserPushSubscription | null
|
||||
registered?: boolean
|
||||
subscribeError?: unknown
|
||||
}
|
||||
|
||||
function setup({
|
||||
permission = 'default',
|
||||
answer = 'granted',
|
||||
active = true,
|
||||
current = null,
|
||||
registered = true,
|
||||
subscribeError,
|
||||
}: Setup = {}) {
|
||||
const created = subscription()
|
||||
const pushManager = {
|
||||
getSubscription: vi.fn(async () => current),
|
||||
subscribe: vi.fn(async () => {
|
||||
if (subscribeError !== undefined) throw subscribeError
|
||||
return created
|
||||
}),
|
||||
}
|
||||
const notification = { permission, requestPermission: vi.fn(async () => answer) }
|
||||
const env: PushEnvironment = {
|
||||
serviceWorker: { getRegistration: vi.fn(async () => (registered ? { pushManager } : undefined)) },
|
||||
pushManager: true,
|
||||
notification,
|
||||
userActivation: { isActive: active },
|
||||
userAgent: 'Mozilla/5.0 Test',
|
||||
}
|
||||
const calls: { path: string; request?: ApiRequest }[] = []
|
||||
const api = {
|
||||
apiFetch: vi.fn(async (path: string, request?: ApiRequest) => {
|
||||
calls.push({ path, request })
|
||||
if (path === VAPID_PUBLIC_KEY_PATH) return { public_key: vapidKeyText }
|
||||
return undefined
|
||||
}) as <T>(path: string, request?: ApiRequest) => Promise<T>,
|
||||
}
|
||||
return { env, api, calls, pushManager, notification, created }
|
||||
}
|
||||
|
||||
describe('decodeVapidKey', () => {
|
||||
it('decodes the unpadded base64url point', () => {
|
||||
expect(decodeVapidKey(vapidKeyText)).toEqual(vapidKey)
|
||||
})
|
||||
|
||||
it.each(['', 'abc=', 'not base64!', encodeBase64url(bytes(64, 4).buffer), encodeBase64url(bytes(65, 2).buffer)])(
|
||||
'refuses %j',
|
||||
(key) => {
|
||||
expect(() => decodeVapidKey(key)).toThrow(/65-byte uncompressed P-256 point/)
|
||||
},
|
||||
)
|
||||
})
|
||||
|
||||
describe('subscriptionBody', () => {
|
||||
it('sends exactly the endpoint, the keys in unpadded base64url and the user agent', () => {
|
||||
const body = subscriptionBody(subscription(), 'Mozilla/5.0 Test')
|
||||
expect(body).toEqual({
|
||||
endpoint,
|
||||
keys: { p256dh: encodeBase64url(p256dh), auth: encodeBase64url(auth) },
|
||||
user_agent: 'Mozilla/5.0 Test',
|
||||
})
|
||||
expect(body.keys.auth).toMatch(/^[A-Za-z0-9_-]{22}$/)
|
||||
expect(JSON.stringify(body)).not.toContain('expirationTime')
|
||||
})
|
||||
|
||||
it.each(['', ' ', 'x'.repeat(513)])('leaves out a blank or too-long user agent', (userAgent) => {
|
||||
expect(subscriptionBody(subscription(), userAgent)).not.toHaveProperty('user_agent')
|
||||
})
|
||||
|
||||
it('refuses a subscription without keys', () => {
|
||||
const keyless = { ...subscription(), getKey: () => null }
|
||||
expect(() => subscriptionBody(keyless, 'ua')).toThrow(/without its p256dh and auth keys/)
|
||||
})
|
||||
})
|
||||
|
||||
describe('subscribePush', () => {
|
||||
it.each([
|
||||
['serviceWorker', { serviceWorker: undefined }],
|
||||
['PushManager', { pushManager: false }],
|
||||
['Notification', { notification: undefined }],
|
||||
])('answers unsupported without %s, asking nothing', async (_, missing) => {
|
||||
const { env, api, notification } = setup()
|
||||
expect(await subscribePush(api, { ...env, ...missing })).toBe('unsupported')
|
||||
expect(notification.requestPermission).not.toHaveBeenCalled()
|
||||
expect(api.apiFetch).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('asks for permission during a tap, subscribes with the server key and stores the subscription', async () => {
|
||||
const { env, api, calls, pushManager, notification } = setup()
|
||||
expect(await subscribePush(api, env)).toBe('subscribed')
|
||||
expect(notification.requestPermission).toHaveBeenCalledTimes(1)
|
||||
expect(pushManager.subscribe).toHaveBeenCalledWith({ userVisibleOnly: true, applicationServerKey: vapidKey })
|
||||
expect(calls).toEqual([
|
||||
{ path: VAPID_PUBLIC_KEY_PATH, request: undefined },
|
||||
{
|
||||
path: SUBSCRIPTIONS_PATH,
|
||||
request: {
|
||||
method: 'POST',
|
||||
body: {
|
||||
endpoint,
|
||||
keys: { p256dh: encodeBase64url(p256dh), auth: encodeBase64url(auth) },
|
||||
user_agent: 'Mozilla/5.0 Test',
|
||||
},
|
||||
},
|
||||
},
|
||||
])
|
||||
})
|
||||
|
||||
it('asks for permission before awaiting anything, so the tap still counts', async () => {
|
||||
const { env, api, notification } = setup()
|
||||
const pending = subscribePush(api, env)
|
||||
expect(notification.requestPermission).toHaveBeenCalledTimes(1)
|
||||
expect(api.apiFetch).not.toHaveBeenCalled()
|
||||
await pending
|
||||
})
|
||||
|
||||
it('refuses to ask for permission outside a tap', async () => {
|
||||
const { env, api, notification } = setup({ active: false })
|
||||
const error = await subscribePush(api, env).catch((e: unknown) => e)
|
||||
expect(error).toBeInstanceOf(PermissionGestureError)
|
||||
expect(error).toMatchObject({ reason: 'inactive', message: expect.stringMatching(/only be asked for from a tap/) })
|
||||
expect(notification.requestPermission).not.toHaveBeenCalled()
|
||||
expect(api.apiFetch).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('refuses to ask for permission where the browser cannot say whether a tap is active', async () => {
|
||||
const { env, api, notification } = setup()
|
||||
const error = await subscribePush(api, { ...env, userActivation: undefined }).catch((e: unknown) => e)
|
||||
expect(error).toBeInstanceOf(PermissionGestureError)
|
||||
expect(error).toMatchObject({ reason: 'unobservable', message: expect.stringMatching(/no navigator.userActivation/) })
|
||||
expect(notification.requestPermission).not.toHaveBeenCalled()
|
||||
expect(api.apiFetch).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('does not need to observe a tap once permission is granted', async () => {
|
||||
const { env, api, notification } = setup({ permission: 'granted' })
|
||||
expect(await subscribePush(api, { ...env, userActivation: undefined })).toBe('subscribed')
|
||||
expect(notification.requestPermission).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('does not ask again once permission is granted, even outside a tap', async () => {
|
||||
const { env, api, notification } = setup({ permission: 'granted', active: false })
|
||||
expect(await subscribePush(api, env)).toBe('subscribed')
|
||||
expect(notification.requestPermission).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('reports a permission denied earlier without asking or subscribing', async () => {
|
||||
const { env, api, notification, pushManager } = setup({ permission: 'denied' })
|
||||
expect(await subscribePush(api, env)).toBe('denied')
|
||||
expect(notification.requestPermission).not.toHaveBeenCalled()
|
||||
expect(pushManager.subscribe).not.toHaveBeenCalled()
|
||||
expect(api.apiFetch).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('reports a permission denied when asked, without subscribing', async () => {
|
||||
const { env, api, pushManager } = setup({ answer: 'denied' })
|
||||
expect(await subscribePush(api, env)).toBe('denied')
|
||||
expect(pushManager.subscribe).not.toHaveBeenCalled()
|
||||
expect(api.apiFetch).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('reports a dismissed prompt', async () => {
|
||||
const { env, api, pushManager } = setup({ answer: 'default' })
|
||||
expect(await subscribePush(api, env)).toBe('dismissed')
|
||||
expect(pushManager.subscribe).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('reports denied when the browser refuses the subscription for permission', async () => {
|
||||
const { env, api, calls } = setup({ subscribeError: new DOMException('Registration failed - permission denied', 'NotAllowedError') })
|
||||
expect(await subscribePush(api, env)).toBe('denied')
|
||||
expect(calls.map((c) => c.path)).toEqual([VAPID_PUBLIC_KEY_PATH])
|
||||
})
|
||||
|
||||
it('rejects with an Error, not a raw DOMException, when the browser cannot subscribe', async () => {
|
||||
const cause = new DOMException('push service not available', 'AbortError')
|
||||
const { env, api } = setup({ subscribeError: cause })
|
||||
const error = await subscribePush(api, env).catch((e: unknown) => e)
|
||||
expect(error).not.toBeInstanceOf(DOMException)
|
||||
expect(error).toBeInstanceOf(Error)
|
||||
expect((error as Error).message).toBe('Web Push: the browser could not subscribe: push service not available')
|
||||
expect((error as Error).cause).toBe(cause)
|
||||
})
|
||||
|
||||
it('rejects when no service worker is registered', async () => {
|
||||
const { env, api } = setup({ registered: false })
|
||||
await expect(subscribePush(api, env)).rejects.toThrow(/no service worker is registered/)
|
||||
})
|
||||
|
||||
it('stores an existing subscription made with the same key again, without resubscribing', async () => {
|
||||
const current = subscription()
|
||||
const { env, api, calls, pushManager } = setup({ permission: 'granted', current })
|
||||
expect(await subscribePush(api, env)).toBe('subscribed')
|
||||
expect(pushManager.subscribe).not.toHaveBeenCalled()
|
||||
expect(current.unsubscribe).not.toHaveBeenCalled()
|
||||
expect(calls.map((c) => c.request?.method ?? 'GET')).toEqual(['GET', 'POST'])
|
||||
})
|
||||
|
||||
it('replaces a subscription made with an older key', async () => {
|
||||
const old = subscription(bytes(65, 9).buffer, 'https://push.example/send/old')
|
||||
const { env, api, calls, pushManager } = setup({ permission: 'granted', current: old })
|
||||
expect(await subscribePush(api, env)).toBe('subscribed')
|
||||
expect(old.unsubscribe).toHaveBeenCalled()
|
||||
expect(pushManager.subscribe).toHaveBeenCalled()
|
||||
expect(calls.map((c) => [c.request?.method ?? 'GET', c.request?.body])).toEqual([
|
||||
['GET', undefined],
|
||||
['DELETE', { endpoint: 'https://push.example/send/old' }],
|
||||
['POST', expect.objectContaining({ endpoint })],
|
||||
])
|
||||
})
|
||||
|
||||
it('passes on an API failure storing the subscription', async () => {
|
||||
const { env, api } = setup()
|
||||
const refused = new ApiError(400, 'invalid_subscription', 'endpoint: want an absolute https:// URL', false)
|
||||
vi.mocked(api.apiFetch).mockImplementation(async (path: string) => {
|
||||
if (path === VAPID_PUBLIC_KEY_PATH) return { public_key: vapidKeyText } as never
|
||||
throw refused
|
||||
})
|
||||
await expect(subscribePush(api, env)).rejects.toBe(refused)
|
||||
})
|
||||
|
||||
it('rejects a VAPID key response without a public_key', async () => {
|
||||
const { env, api } = setup()
|
||||
vi.mocked(api.apiFetch).mockResolvedValue({} as never)
|
||||
await expect(subscribePush(api, env)).rejects.toThrow(`GET ${VAPID_PUBLIC_KEY_PATH}: response has no public_key`)
|
||||
})
|
||||
})
|
||||
|
||||
describe('unsubscribePush', () => {
|
||||
it('deletes the subscription on the server, then in the browser', async () => {
|
||||
const current = subscription()
|
||||
const { env, api, calls } = setup({ current })
|
||||
expect(await unsubscribePush(api, env)).toBe('unsubscribed')
|
||||
expect(calls).toEqual([{ path: SUBSCRIPTIONS_PATH, request: { method: 'DELETE', body: { endpoint } } }])
|
||||
expect(current.unsubscribe).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('answers not-subscribed without calling the server when there is no subscription', async () => {
|
||||
const { env, api } = setup({ current: null })
|
||||
expect(await unsubscribePush(api, env)).toBe('not-subscribed')
|
||||
expect(api.apiFetch).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('answers not-subscribed when no service worker is registered', async () => {
|
||||
const { env, api } = setup({ registered: false })
|
||||
expect(await unsubscribePush(api, env)).toBe('not-subscribed')
|
||||
expect(api.apiFetch).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('answers unsupported without Web Push', async () => {
|
||||
const { env, api } = setup()
|
||||
expect(await unsubscribePush(api, { ...env, pushManager: false })).toBe('unsupported')
|
||||
})
|
||||
|
||||
it('keeps the browser subscribed when the server delete fails, so trying again works', async () => {
|
||||
const current = subscription()
|
||||
const { env, api } = setup({ current })
|
||||
vi.mocked(api.apiFetch).mockRejectedValue(new TypeError('Failed to fetch'))
|
||||
await expect(unsubscribePush(api, env)).rejects.toThrow('Failed to fetch')
|
||||
expect(current.unsubscribe).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('rejects when the browser does not remove the subscription', async () => {
|
||||
const current = subscription()
|
||||
current.unsubscribe.mockResolvedValue(false)
|
||||
const { env, api } = setup({ current })
|
||||
await expect(unsubscribePush(api, env)).rejects.toThrow(/did not remove its push subscription/)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,209 @@
|
||||
import type { ApiClient } from '../api/client.ts'
|
||||
import { isRecord } from '../api/errors.ts'
|
||||
|
||||
export const VAPID_PUBLIC_KEY_PATH = '/api/v1/push/vapid-public-key'
|
||||
export const SUBSCRIPTIONS_PATH = '/api/v1/push/subscriptions'
|
||||
|
||||
// MAX_USER_AGENT_BYTES is the server's limit on a subscription's user_agent
|
||||
// (internal/push MaxUserAgentBytes).
|
||||
export const MAX_USER_AGENT_BYTES = 512
|
||||
|
||||
// SubscribePushResult is how turning on Web Push on this device ended:
|
||||
// subscribed, the browser's permission denied (now or earlier), the
|
||||
// permission prompt dismissed without an answer, or a browser without Web
|
||||
// Push. Any other failure rejects with an Error (an ApiError from the API).
|
||||
export type SubscribePushResult = 'subscribed' | 'denied' | 'dismissed' | 'unsupported'
|
||||
|
||||
// UnsubscribePushResult is how turning off Web Push on this device ended:
|
||||
// its subscription removed from the browser and the server, no subscription
|
||||
// to remove, or a browser without Web Push. Any other failure rejects.
|
||||
export type UnsubscribePushResult = 'unsubscribed' | 'not-subscribed' | 'unsupported'
|
||||
|
||||
// The parts of the browser's Push API this module uses.
|
||||
export type BrowserPushSubscription = {
|
||||
readonly endpoint: string
|
||||
readonly options: { readonly applicationServerKey: ArrayBuffer | null }
|
||||
getKey(name: 'p256dh' | 'auth'): ArrayBuffer | null
|
||||
unsubscribe(): Promise<boolean>
|
||||
}
|
||||
|
||||
export type BrowserPushManager = {
|
||||
getSubscription(): Promise<BrowserPushSubscription | null>
|
||||
subscribe(options: { userVisibleOnly: boolean; applicationServerKey: Uint8Array<ArrayBuffer> }): Promise<BrowserPushSubscription>
|
||||
}
|
||||
|
||||
// PushEnvironment is what the browser offers for Web Push. A field is
|
||||
// undefined (or pushManager false) where the browser lacks it.
|
||||
export type PushEnvironment = {
|
||||
serviceWorker: { getRegistration(): Promise<{ readonly pushManager: BrowserPushManager } | undefined> } | undefined
|
||||
pushManager: boolean
|
||||
notification:
|
||||
| { readonly permission: NotificationPermission; requestPermission(): Promise<NotificationPermission> }
|
||||
| undefined
|
||||
// Whether the page is handling a user gesture right now.
|
||||
userActivation: { readonly isActive: boolean } | undefined
|
||||
userAgent: string
|
||||
}
|
||||
|
||||
// browserPushEnvironment reads the PushEnvironment from this window.
|
||||
export function browserPushEnvironment(): PushEnvironment {
|
||||
return {
|
||||
serviceWorker: 'serviceWorker' in navigator ? navigator.serviceWorker : undefined,
|
||||
pushManager: 'PushManager' in window,
|
||||
notification: 'Notification' in window ? Notification : undefined,
|
||||
userActivation: 'userActivation' in navigator ? navigator.userActivation : undefined,
|
||||
userAgent: navigator.userAgent,
|
||||
}
|
||||
}
|
||||
|
||||
type PushApi = Pick<ApiClient, 'apiFetch'>
|
||||
|
||||
// PermissionGestureError is why subscribePush refused to ask for notification
|
||||
// permission: no tap is active right now ('inactive'), or the browser cannot
|
||||
// say whether one is ('unobservable', no navigator.userActivation).
|
||||
export class PermissionGestureError extends Error {
|
||||
readonly reason: 'inactive' | 'unobservable'
|
||||
|
||||
constructor(reason: 'inactive' | 'unobservable') {
|
||||
super(
|
||||
reason === 'inactive'
|
||||
? 'subscribePush: notification permission can only be asked for from a tap on a button'
|
||||
: 'subscribePush: this browser cannot report whether a tap is active (no navigator.userActivation), so notification permission is not asked for',
|
||||
)
|
||||
this.name = 'PermissionGestureError'
|
||||
this.reason = reason
|
||||
}
|
||||
}
|
||||
|
||||
// encodeBase64url encodes bytes as unpadded base64url, as the server wants
|
||||
// subscription keys.
|
||||
export function encodeBase64url(bytes: ArrayBuffer): string {
|
||||
let binary = ''
|
||||
for (const byte of new Uint8Array(bytes)) binary += String.fromCharCode(byte)
|
||||
return btoa(binary).replaceAll('+', '-').replaceAll('/', '_').replace(/=+$/, '')
|
||||
}
|
||||
|
||||
// decodeVapidKey decodes the server's VAPID public key, unpadded base64url
|
||||
// of the 65-byte uncompressed P-256 point, into an applicationServerKey.
|
||||
export function decodeVapidKey(key: string): Uint8Array<ArrayBuffer> {
|
||||
const problem = `GET ${VAPID_PUBLIC_KEY_PATH}: public_key is not the unpadded base64url of a 65-byte uncompressed P-256 point`
|
||||
if (!/^[A-Za-z0-9_-]+$/.test(key) || key.length % 4 === 1) throw new Error(problem)
|
||||
const binary = atob(key.replaceAll('-', '+').replaceAll('_', '/'))
|
||||
const bytes = new Uint8Array(new ArrayBuffer(binary.length))
|
||||
for (let i = 0; i < binary.length; i++) bytes[i] = binary.charCodeAt(i)
|
||||
if (bytes.length !== 65 || bytes[0] !== 0x04) throw new Error(problem)
|
||||
return bytes
|
||||
}
|
||||
|
||||
function sameKey(current: ArrayBuffer | null, key: Uint8Array): boolean {
|
||||
if (current === null || current.byteLength !== key.length) return false
|
||||
const bytes = new Uint8Array(current)
|
||||
return bytes.every((byte, i) => byte === key[i])
|
||||
}
|
||||
|
||||
// SubscriptionBody is the POST /api/v1/push/subscriptions body. The server
|
||||
// refuses unknown fields and nulls, so it is built field by field rather
|
||||
// than from PushSubscription.toJSON(), whose expirationTime may be null.
|
||||
export type SubscriptionBody = { endpoint: string; keys: { p256dh: string; auth: string }; user_agent?: string }
|
||||
|
||||
// subscriptionBody is the body that stores subscription. The user agent only
|
||||
// labels the device, so one that is blank or over the server's limit is left
|
||||
// out rather than sent to be refused.
|
||||
export function subscriptionBody(subscription: BrowserPushSubscription, userAgent: string): SubscriptionBody {
|
||||
const p256dh = subscription.getKey('p256dh')
|
||||
const auth = subscription.getKey('auth')
|
||||
if (p256dh === null || auth === null) {
|
||||
throw new Error('Web Push: the browser gave a subscription without its p256dh and auth keys')
|
||||
}
|
||||
const body: SubscriptionBody = {
|
||||
endpoint: subscription.endpoint,
|
||||
keys: { p256dh: encodeBase64url(p256dh), auth: encodeBase64url(auth) },
|
||||
}
|
||||
if (userAgent.trim() !== '' && new TextEncoder().encode(userAgent).length <= MAX_USER_AGENT_BYTES) {
|
||||
body.user_agent = userAgent
|
||||
}
|
||||
return body
|
||||
}
|
||||
|
||||
async function fetchVapidKey(api: PushApi): Promise<Uint8Array<ArrayBuffer>> {
|
||||
const body = await api.apiFetch<unknown>(VAPID_PUBLIC_KEY_PATH)
|
||||
if (!isRecord(body) || typeof body.public_key !== 'string') {
|
||||
throw new Error(`GET ${VAPID_PUBLIC_KEY_PATH}: response has no public_key`)
|
||||
}
|
||||
return decodeVapidKey(body.public_key)
|
||||
}
|
||||
|
||||
// removeSubscription deletes subscription on the server, then in the
|
||||
// browser. Deleting on the server first means a failure leaves both still
|
||||
// subscribed, so trying again finds the subscription and finishes the job.
|
||||
async function removeSubscription(api: PushApi, subscription: BrowserPushSubscription): Promise<void> {
|
||||
await api.apiFetch<void>(SUBSCRIPTIONS_PATH, { method: 'DELETE', body: { endpoint: subscription.endpoint } })
|
||||
if (!(await subscription.unsubscribe())) {
|
||||
throw new Error('Web Push: the browser did not remove its push subscription')
|
||||
}
|
||||
}
|
||||
|
||||
function errorName(error: unknown): string | undefined {
|
||||
return isRecord(error) && typeof error.name === 'string' ? error.name : undefined
|
||||
}
|
||||
|
||||
// subscribePush turns on Web Push on this device. Call it only from the
|
||||
// household's tap on a button: when the browser has not yet been asked, it
|
||||
// asks for notification permission, which browsers allow only during a user
|
||||
// gesture, and it refuses to ask outside one — or where the browser cannot
|
||||
// say whether a gesture is active — with a PermissionGestureError. Permission
|
||||
// is asked before
|
||||
// anything is awaited so the gesture still counts. With permission, it
|
||||
// subscribes the browser with the server's VAPID public key — replacing a
|
||||
// subscription made with an older key — and stores the subscription on the
|
||||
// server. Subscribing again when already subscribed stores it again.
|
||||
export async function subscribePush(api: PushApi, env: PushEnvironment): Promise<SubscribePushResult> {
|
||||
const { serviceWorker, notification } = env
|
||||
if (serviceWorker === undefined || !env.pushManager || notification === undefined) return 'unsupported'
|
||||
|
||||
let permission = notification.permission
|
||||
if (permission === 'default') {
|
||||
if (env.userActivation === undefined) throw new PermissionGestureError('unobservable')
|
||||
if (!env.userActivation.isActive) throw new PermissionGestureError('inactive')
|
||||
permission = await notification.requestPermission()
|
||||
}
|
||||
if (permission === 'denied') return 'denied'
|
||||
if (permission === 'default') return 'dismissed'
|
||||
|
||||
const found = await serviceWorker.getRegistration()
|
||||
if (found === undefined) {
|
||||
throw new Error('Web Push: no service worker is registered for this page, so it cannot receive notifications')
|
||||
}
|
||||
const { pushManager } = found
|
||||
const key = await fetchVapidKey(api)
|
||||
let subscription = await pushManager.getSubscription()
|
||||
if (subscription !== null && !sameKey(subscription.options.applicationServerKey, key)) {
|
||||
await removeSubscription(api, subscription)
|
||||
subscription = null
|
||||
}
|
||||
if (subscription === null) {
|
||||
try {
|
||||
subscription = await pushManager.subscribe({ userVisibleOnly: true, applicationServerKey: key })
|
||||
} catch (error) {
|
||||
if (errorName(error) === 'NotAllowedError') return 'denied'
|
||||
throw new Error(`Web Push: the browser could not subscribe: ${(error as Error).message}`, { cause: error })
|
||||
}
|
||||
}
|
||||
await api.apiFetch(SUBSCRIPTIONS_PATH, { method: 'POST', body: subscriptionBody(subscription, env.userAgent) })
|
||||
return 'subscribed'
|
||||
}
|
||||
|
||||
// unsubscribePush turns off Web Push on this device: it removes the
|
||||
// browser's subscription on the server and in the browser. With no
|
||||
// subscription there is nothing to remove, and it answers 'not-subscribed'
|
||||
// without calling the server.
|
||||
export async function unsubscribePush(api: PushApi, env: PushEnvironment): Promise<UnsubscribePushResult> {
|
||||
const { serviceWorker } = env
|
||||
if (serviceWorker === undefined || !env.pushManager) return 'unsupported'
|
||||
const found = await serviceWorker.getRegistration()
|
||||
if (found === undefined) return 'not-subscribed'
|
||||
const subscription = await found.pushManager.getSubscription()
|
||||
if (subscription === null) return 'not-subscribed'
|
||||
await removeSubscription(api, subscription)
|
||||
return 'unsubscribed'
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
import { useMemo } from 'react'
|
||||
import { useApi } from '../api/ApiContext.tsx'
|
||||
import {
|
||||
browserPushEnvironment,
|
||||
subscribePush,
|
||||
unsubscribePush,
|
||||
type SubscribePushResult,
|
||||
type UnsubscribePushResult,
|
||||
} from './subscribe.ts'
|
||||
|
||||
export type Push = {
|
||||
// subscribePush turns on Web Push on this device; call it only from a tap.
|
||||
subscribePush(): Promise<SubscribePushResult>
|
||||
// unsubscribePush turns off Web Push on this device.
|
||||
unsubscribePush(): Promise<UnsubscribePushResult>
|
||||
}
|
||||
|
||||
// usePush binds subscribePush and unsubscribePush to the app's API client
|
||||
// and this browser.
|
||||
export function usePush(): Push {
|
||||
const api = useApi()
|
||||
return useMemo(
|
||||
() => ({
|
||||
subscribePush: () => subscribePush(api, browserPushEnvironment()),
|
||||
unsubscribePush: () => unsubscribePush(api, browserPushEnvironment()),
|
||||
}),
|
||||
[api],
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,144 @@
|
||||
/* 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);
|
||||
}
|
||||
@@ -0,0 +1,422 @@
|
||||
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([])
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,183 @@
|
||||
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. An archived Supply run shows the
|
||||
// same marks, read-only. An item toggled while the server cannot be reached
|
||||
// "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
|
||||
// says it will sync.
|
||||
function ItemRow({ item, toggles }: { item: SupplyRunItem; toggles: Toggles | null }) {
|
||||
const name = (
|
||||
@@ -121,12 +121,13 @@ 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 is
|
||||
// read-only, and a toggle it refused is not offered again.
|
||||
function SupplyRunList({ weekStart, timezone, layout }: { weekStart: string; timezone: string; layout: Breakpoint }) {
|
||||
// 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) {
|
||||
const toggles = useToggles(weekStart)
|
||||
const run = toggles.shown
|
||||
const archived = run.status === 'archived'
|
||||
const viewOnly = readOnly || 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)
|
||||
@@ -138,21 +139,23 @@ function SupplyRunList({ weekStart, timezone, layout }: { weekStart: string; tim
|
||||
<p className={styles.kicker}>Supply run · {weekLabel(weekStart).dates}</p>
|
||||
<div className={styles.titleRow}>
|
||||
<h1 className={styles.title}>Shopping list</h1>
|
||||
{archived && <Tag tone="grape">Archived</Tag>}
|
||||
{viewOnly && <Tag tone="grape">Archived</Tag>}
|
||||
<p className={styles.count}>
|
||||
{run.needed_count} / {run.total_count}
|
||||
</p>
|
||||
</div>
|
||||
<p className={styles.helper}>
|
||||
{archived
|
||||
{run.status === 'archived'
|
||||
? 'This list is in the Supply archive, as it was when the week ended.'
|
||||
: "Uncheck anything you already have — it won't be counted."}
|
||||
: viewOnly
|
||||
? "This week's list as it stands, read-only in the Supply archive."
|
||||
: "Uncheck anything you already have — it won't be counted."}
|
||||
</p>
|
||||
</div>
|
||||
{wide && (
|
||||
<div className={styles.actions}>
|
||||
<ArchiveLink layout={layout} />
|
||||
{!archived && (
|
||||
{!viewOnly && (
|
||||
<Unavailable hint={CHAT_HINT} below className={styles.action}>
|
||||
<Icon icon={MessageSquare} size="inline" />
|
||||
Send to Google Chat
|
||||
@@ -162,7 +165,7 @@ function SupplyRunList({ weekStart, timezone, layout }: { weekStart: string; tim
|
||||
)}
|
||||
</header>
|
||||
{failed !== null && (
|
||||
<ErrorBanner onRetry={archived ? undefined : () => toggles.toggle(failed.toggle)}>
|
||||
<ErrorBanner onRetry={viewOnly ? undefined : () => toggles.toggle(failed.toggle)}>
|
||||
Couldn't mark {failedItem ? failedItem.name : 'an item'} {failed.toggle.needed ? 'to buy' : '“have it”'}.{' '}
|
||||
{failed.error.message}
|
||||
</ErrorBanner>
|
||||
@@ -173,7 +176,7 @@ function SupplyRunList({ weekStart, timezone, layout }: { weekStart: string; tim
|
||||
) : (
|
||||
<div className={styles.groups} data-testid="supply-run-groups">
|
||||
{run.groups.map((group) => (
|
||||
<GroupCard key={`${group.date} ${group.dinner.id}`} group={group} toggles={archived ? null : toggles} />
|
||||
<GroupCard key={`${group.date} ${group.dinner.id}`} group={group} toggles={viewOnly ? null : toggles} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
@@ -201,9 +204,12 @@ 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.
|
||||
function WeekSupplyRun({ weekStart, timezone, layout }: { weekStart: string; timezone: string; layout: Breakpoint }) {
|
||||
// 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) {
|
||||
const run = useSupplyRun(weekStart)
|
||||
const generate = useGenerateSupplyRun(weekStart)
|
||||
|
||||
@@ -216,36 +222,42 @@ function WeekSupplyRun({ weekStart, timezone, layout }: { weekStart: string; tim
|
||||
if (run.data === null) {
|
||||
return (
|
||||
<div className={`${styles.page} ${styles[layout]}`}>
|
||||
<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>
|
||||
{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>
|
||||
)}
|
||||
{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} />
|
||||
return <SupplyRunList weekStart={weekStart} timezone={timezone} layout={layout} readOnly={readOnly} />
|
||||
}
|
||||
|
||||
// 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=, 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() {
|
||||
// ?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 }) {
|
||||
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 />
|
||||
<ChosenSupplyRun readOnly={readOnly} />
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
function ChosenSupplyRun() {
|
||||
function ChosenSupplyRun({ readOnly }: { readOnly: boolean }) {
|
||||
const layout = useBreakpoint()
|
||||
const settings = useHouseholdSettings()
|
||||
const [params] = useSearchParams()
|
||||
@@ -264,7 +276,7 @@ function ChosenSupplyRun() {
|
||||
}
|
||||
if (!schedule) return <SkeletonGroups layout={layout} />
|
||||
const week = chosen ?? defaultWeek(schedule, new Date())
|
||||
return <WeekSupplyRun key={week} weekStart={week} timezone={schedule.timezone} layout={layout} />
|
||||
return <WeekSupplyRun key={week} weekStart={week} timezone={schedule.timezone} layout={layout} readOnly={readOnly} />
|
||||
}
|
||||
|
||||
// isMonday says whether date is a real YYYY-MM-DD Monday.
|
||||
|
||||
+82
-2
@@ -12,6 +12,7 @@ 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 {
|
||||
@@ -187,16 +188,21 @@ 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.
|
||||
// the one shown. Once made, from whichever screen, a push of the week whose
|
||||
// Supply run was not made is settled (settleUnmadePushes).
|
||||
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) => queryClient.setQueryData<SupplyRun | null>(supplyRunKey(weekStart), run),
|
||||
onSuccess: (run, _, settle) => {
|
||||
queryClient.setQueryData<SupplyRun | null>(supplyRunKey(weekStart), run)
|
||||
settle()
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
@@ -497,3 +503,77 @@ 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 } from 'vitest'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi, type Mock, type MockInstance } 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'
|
||||
@@ -201,16 +201,32 @@ async function openPage() {
|
||||
}
|
||||
|
||||
// reload ends the page open and its timers, as a reload does: a toggle it
|
||||
// was waiting to try again is never tried, by it, again.
|
||||
// was waiting to try again is never tried, by it, again. The clock reads on
|
||||
// as it did: clearAllTimers also puts it back to the real time the fake
|
||||
// timers were installed at, which would open the new page on whatever week
|
||||
// the real date falls in.
|
||||
function reload(page: { unmount: () => void }) {
|
||||
page.unmount()
|
||||
const now = Date.now()
|
||||
vi.clearAllTimers()
|
||||
vi.setSystemTime(now)
|
||||
}
|
||||
|
||||
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}`) })
|
||||
}
|
||||
@@ -227,6 +243,7 @@ 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(() => {
|
||||
@@ -459,7 +476,7 @@ describe('offline toggle queue, replay', () => {
|
||||
expect(patches(server.fetchMock)).toHaveLength(1)
|
||||
expect(queue()).toEqual(['i3 true', 'i1 false'])
|
||||
|
||||
await advance(RETRY_FIRST)
|
||||
await retrySecondTry()
|
||||
await waitFor(() => expect(queue()).toEqual([]))
|
||||
expect(patches(server.fetchMock)).toEqual([
|
||||
`${ITEM('i3')} {"needed":true}`,
|
||||
@@ -492,7 +509,7 @@ describe('offline toggle queue, replay', () => {
|
||||
expect(patches(server.fetchMock)).toEqual([])
|
||||
|
||||
// The next try sends a fresh /me, which the server answers.
|
||||
await advance(RETRY_FIRST)
|
||||
await retrySecondTry()
|
||||
await waitFor(() => expect(queue()).toEqual([]))
|
||||
expect(mes(server.fetchMock)).toBe(3)
|
||||
expect(patches(server.fetchMock)).toEqual([`${ITEM('i3')} {"needed":true}`, `${ITEM('i1')} {"needed":false}`])
|
||||
@@ -532,7 +549,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 advance(RETRY_FIRST)
|
||||
await retrySecondTry()
|
||||
await waitFor(() => expect(queue()).toEqual([]))
|
||||
expect(patches(server.fetchMock)).toEqual([
|
||||
`${ITEM('i3')} {"needed":true}`,
|
||||
@@ -555,8 +572,10 @@ 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([])
|
||||
|
||||
await advance(RETRY_FIRST)
|
||||
// Nothing says the server is back: only the wait's timer tries it again.
|
||||
await retrySecondTry()
|
||||
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}`])
|
||||
|
||||
@@ -50,3 +50,57 @@ describe('service worker activation', () => {
|
||||
expect(cacheStorage.delete).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
})
|
||||
|
||||
// The push listeners only hand src/push/notification.ts the worker's globals;
|
||||
// jsdom has no ServiceWorkerGlobalScope, so registration and clients are
|
||||
// stubbed and the events are plain Events with the worker event's fields.
|
||||
describe('service worker push listeners', () => {
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals()
|
||||
})
|
||||
|
||||
async function dispatch(type: string, fields: object): Promise<void> {
|
||||
vi.stubGlobal('caches', { keys: vi.fn(async () => []), delete: vi.fn() })
|
||||
vi.stubGlobal('__WB_MANIFEST', [])
|
||||
await import('./sw')
|
||||
const pending: Promise<unknown>[] = []
|
||||
self.dispatchEvent(
|
||||
Object.assign(new Event(type), { waitUntil: (promise: Promise<unknown>) => pending.push(promise) }, fields),
|
||||
)
|
||||
expect(pending).toHaveLength(1)
|
||||
await Promise.all(pending)
|
||||
}
|
||||
|
||||
it('shows a push message as a notification', async () => {
|
||||
const registration = { showNotification: vi.fn(async () => {}) }
|
||||
vi.stubGlobal('registration', registration)
|
||||
const payload = JSON.stringify({ title: 'Plan ready', body: 'Adjust the Draft board.', tag: 'plan_ready', url: '/plan' })
|
||||
await dispatch('push', { data: { text: () => payload } })
|
||||
expect(registration.showNotification).toHaveBeenCalledWith('Plan ready', {
|
||||
body: 'Adjust the Draft board.',
|
||||
tag: 'plan_ready',
|
||||
icon: '/icons/icon-192.png',
|
||||
badge: '/icons/icon-192.png',
|
||||
data: { url: '/plan' },
|
||||
})
|
||||
})
|
||||
|
||||
it('shows the generic notification for a push without a payload', async () => {
|
||||
const registration = { showNotification: vi.fn(async () => {}) }
|
||||
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {})
|
||||
vi.stubGlobal('registration', registration)
|
||||
await dispatch('push', { data: null })
|
||||
expect(registration.showNotification).toHaveBeenCalledWith('MealQuest', expect.objectContaining({ data: { url: '/' } }))
|
||||
expect(warn).toHaveBeenCalledWith('MealQuest: push message has no payload; showing the generic notification')
|
||||
warn.mockRestore()
|
||||
})
|
||||
|
||||
it('closes a tapped notification and opens its url', async () => {
|
||||
const clients = { matchAll: vi.fn(async () => []), openWindow: vi.fn(async () => null) }
|
||||
vi.stubGlobal('clients', clients)
|
||||
const notification = { data: { url: '/discover' }, close: vi.fn() }
|
||||
await dispatch('notificationclick', { notification })
|
||||
expect(notification.close).toHaveBeenCalled()
|
||||
expect(clients.openWindow).toHaveBeenCalledWith(`${self.location.origin}/discover`)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -15,6 +15,7 @@ import { ExpirationPlugin } from 'workbox-expiration'
|
||||
import { PrecacheFallbackPlugin, matchPrecache, precacheAndRoute } from 'workbox-precaching'
|
||||
import { registerRoute } from 'workbox-routing'
|
||||
import { CacheFirst, NetworkOnly, type Strategy } from 'workbox-strategies'
|
||||
import { openNotification, showPush } from './push/notification'
|
||||
import { createApiStrategy } from './sw/apiStrategy'
|
||||
import {
|
||||
cacheNames,
|
||||
@@ -113,3 +114,18 @@ self.addEventListener('activate', (event) => {
|
||||
self.addEventListener('message', (event) => {
|
||||
if (event.data?.type === 'SKIP_WAITING') void self.skipWaiting()
|
||||
})
|
||||
|
||||
// Web Push: src/push/notification.ts decides what to show and where a tap
|
||||
// goes; these listeners only hand it the worker's globals. A payload that is
|
||||
// not a MealQuest notice still shows a generic notification, and the reason
|
||||
// is logged.
|
||||
self.addEventListener('push', (event) => {
|
||||
event.waitUntil(
|
||||
showPush(event.data?.text() ?? null, self.registration, self.location.origin, (message) => console.warn(message)),
|
||||
)
|
||||
})
|
||||
|
||||
self.addEventListener('notificationclick', (event) => {
|
||||
event.notification.close()
|
||||
event.waitUntil(openNotification(event.notification.data, self.clients, self.location.origin))
|
||||
})
|
||||
|
||||
@@ -44,3 +44,13 @@ describe('service worker source', () => {
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe('service worker push listeners source', () => {
|
||||
it('delegates push and notificationclick to src/push/notification.ts', () => {
|
||||
expect(source).toContain("import { openNotification, showPush } from './push/notification'")
|
||||
expect(source).toMatch(/self\.addEventListener\('push', \(event\) => \{\s*event\.waitUntil\(\s*showPush\(event\.data\?\.text\(\) \?\? null, self\.registration, self\.location\.origin,/)
|
||||
expect(source).toMatch(
|
||||
/self\.addEventListener\('notificationclick', \(event\) => \{\s*event\.notification\.close\(\)\s*event\.waitUntil\(openNotification\(event\.notification\.data, self\.clients, self\.location\.origin\)\)/,
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user