Compare commits

..
Author SHA1 Message Date
dev_agentandClaude Opus 5.5 f1e7503f94 Add VAPID config and the push subscription API
CI / ci (push) Successful in 6m3s
CI / ci (pull_request) Successful in 5m17s
Require MEALQUEST_VAPID_PUBLIC_KEY, MEALQUEST_VAPID_PRIVATE_KEY_FILE and
MEALQUEST_VAPID_SUBJECT at boot. Both keys are unpadded base64url P-256
keys, the encoding webpush-go reads; boot derives the public point from the
private key and refuses a pair that does not match. The subject must be
mailto:<address> or an https:// URL.

internal/push stores subscriptions by endpoint: GET
/api/v1/push/vapid-public-key serves only the public key, POST
/api/v1/push/subscriptions upserts {endpoint, keys.p256dh, keys.auth,
user_agent}, and DELETE removes by endpoint, answering 204 whether or not
it was stored. cmd/vapidgen prints a new key pair. deploy/compose.yaml
mounts the vapid_private_key secret.

Maestro task e8a7b1f4-327a-478b-88c7-5d265afda153.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 08:03:59 -04:00
49 changed files with 1535 additions and 1934 deletions
+12
View File
@@ -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
View File
@@ -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
}
+39
View File
@@ -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
}
+46
View File
@@ -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")
}
}
+17 -11
View File
@@ -6,7 +6,8 @@
#
# Secrets are never set here. They live in deploy/secrets/ (never committed),
# one value per file, owned by the container user 10001:10001 with mode
# 0600: secret_key, oidc_client_secret and google_client_secret. SearXNG's
# 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".
@@ -57,6 +58,19 @@ MEALQUEST_OIDC_ALLOWED_GROUPS=mealquest-admin
# 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
@@ -72,18 +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 CTXD project mealquest, RUNBOOKS.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). Key pair: CTXD project mealquest, RUNBOOKS.MD
# "VAPID keys (Web Push)".
# MEALQUEST_VAPID_PUBLIC_KEY=<base64url VAPID public key>
# MEALQUEST_VAPID_PRIVATE_KEY_FILE=/run/secrets/vapid_private_key
# MEALQUEST_VAPID_SUBJECT=mailto:<owner address>
+4
View File
@@ -31,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:
@@ -130,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:
+27 -24
View File
@@ -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. |
@@ -204,30 +206,6 @@ automatic** (D-021).
sets status `archived` and keeps the row; `POST
/api/v1/dinners/{id}/favorite` toggles `is_favorite`. Every write records
the session's subject as `updated_by`; an unknown id is 404.
`GET /api/v1/dinners/{id}` returns the dinner with its recipe, read in
one transaction: `{id, name, status, origin, method, minutes_total,
difficulty, cuisine, is_favorite, photo_urls, ingredients, steps,
source, grounding_status, grounding_error, why_picked}`. `photo_urls` is
`{"400": url, "1200": url}` (`/media/photos/{photo_id}-{size}.jpg`) or
null with no photo; `ingredients` is `[{name, qty, unit, note,
raw_text}]` and `steps` the step texts, both in recipe position order and
empty (`[]`) with no recipe, as for a dinner whose grounding is
`pending` or `failed`; a recipe entered by hand has only `raw_text`.
Two items at one position leave recipe order unknown, so such a recipe
is 500 rather than shown out of order. `source` is `{site_name}` of the
page the recipe was fetched from, null for a manual recipe or none; it
has no `url`, as the page's link may carry a personal token.
`site_name` is the name the page gives its site (publisher or
`og:site_name`) and null otherwise: a page naming no site is stored
under its host, which may be the pasted link's — a token in a
subdomain — or a redirect's, so any stored name that could be a host
(the link's host, an IP address or a dotted host name, even a real
name like "Food.com") is withheld. The detail screen shows "Recipe
from {site}" only when `site_name` is not null. `why_picked` is the reason of the
suggestion that created the dinner — its pending or accepted one, as
declining a suggestion archives its dinner — null for one no suggestion
created; a dinner with more than one such suggestion is 500, as nothing
records which created it. An unknown or archived dinner is 404.
- Dinner grounding. `POST /api/v1/dinners` also enqueues a `ground_dinner`
job and answers with its `grounding_job_id` without waiting for it
(`GET /api/v1/jobs/{id}` reports it); if the job cannot be enqueued it
@@ -681,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
+81
View File
@@ -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) {
+171 -1
View File
@@ -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()
+77
View File
@@ -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
}
+5 -7
View File
@@ -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
@@ -103,13 +104,6 @@ type Querier interface {
// Returns sql.ErrNoRows until the row exists.
GetChatSettings(ctx context.Context) (ChatSetting, error)
GetDinner(ctx context.Context, id string) (Dinner, error)
// The reason of each suggestion of dinner_id that can have created it,
// none for a dinner no suggestion created. A suggestion is only ever
// created with a new dinner, and declining one archives its dinner, so a
// live dinner's creating suggestion is pending (the dinner still
// suggested) or accepted; a declined one did not create it. More than one
// row means nothing records which did.
GetDinnerSuggestionReasons(ctx context.Context, dinnerID string) ([]string, error)
// Returns sql.ErrNoRows until the row exists.
GetHouseholdSettings(ctx context.Context) (HouseholdSetting, error)
// Returns sql.ErrNoRows until the row exists.
@@ -283,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
-35
View File
@@ -187,41 +187,6 @@ func (q *Queries) GetActiveSuggestionBatch(ctx context.Context) (SuggestionBatch
return i, err
}
const getDinnerSuggestionReasons = `-- name: GetDinnerSuggestionReasons :many
SELECT reason FROM suggestions
WHERE dinner_id = ? AND status IN ('pending', 'accepted')
ORDER BY id
`
// The reason of each suggestion of dinner_id that can have created it,
// none for a dinner no suggestion created. A suggestion is only ever
// created with a new dinner, and declining one archives its dinner, so a
// live dinner's creating suggestion is pending (the dinner still
// suggested) or accepted; a declined one did not create it. More than one
// row means nothing records which did.
func (q *Queries) GetDinnerSuggestionReasons(ctx context.Context, dinnerID string) ([]string, error) {
rows, err := q.db.QueryContext(ctx, getDinnerSuggestionReasons, dinnerID)
if err != nil {
return nil, err
}
defer rows.Close()
var items []string
for rows.Next() {
var reason string
if err := rows.Scan(&reason); err != nil {
return nil, err
}
items = append(items, reason)
}
if err := rows.Close(); err != nil {
return nil, err
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
}
const getLatestSuggestionBatch = `-- name: GetLatestSuggestionBatch :one
SELECT id, "trigger", status, ai_run_id, created_at, updated_at FROM suggestion_batches
ORDER BY id DESC
-218
View File
@@ -1,218 +0,0 @@
package dinners
import (
"context"
"database/sql"
"errors"
"fmt"
"net"
"net/url"
"strconv"
"strings"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/db"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/recipes"
)
// Detail is a dinner with its recipe, as the recipe detail screen and
// Kitchen reading mode show it.
type Detail struct {
ID string `json:"id"`
Name string `json:"name"`
Status string `json:"status"`
Origin string `json:"origin"`
Method *string `json:"method"`
MinutesTotal *int64 `json:"minutes_total"`
Difficulty *string `json:"difficulty"`
Cuisine *string `json:"cuisine"`
IsFavorite bool `json:"is_favorite"`
// PhotoURLs is the cached photo at each stored width, keyed by width
// ("400", "1200"); null when the dinner has no photo.
PhotoURLs map[string]string `json:"photo_urls"`
// Ingredients and Steps are the recipe's, in recipe order; empty when
// the dinner has no recipe.
Ingredients []Ingredient `json:"ingredients"`
Steps []string `json:"steps"`
// Source is the site of the recipe page the dinner is grounded in;
// null for a recipe entered by hand or a dinner with no recipe.
Source *Source `json:"source"`
GroundingStatus string `json:"grounding_status"`
// GroundingError is why grounding failed, one of the Reason codes;
// null unless GroundingStatus is failed.
GroundingError *string `json:"grounding_error"`
// WhyPicked is the reason of the suggestion that created the dinner
// (see GetDinnerSuggestionReasons); null for a dinner no suggestion
// created.
WhyPicked *string `json:"why_picked"`
}
// Ingredient is one recipe line. RawText is the line as the recipe gives
// it; Name, Qty, Unit and Note are normalize_recipe's reading of it, each
// null when it has none, and Name, Qty and Unit always null for a recipe
// entered by hand (see IsManualRecipe).
type Ingredient struct {
Name *string `json:"name"`
Qty *string `json:"qty"`
Unit *string `json:"unit"`
Note *string `json:"note"`
RawText string `json:"raw_text"`
}
// Source is the site a recipe was fetched from. It has no url: the page's
// link, pasted or offered as a candidate, may carry a personal token, and
// no stored link can be shown to be free of one, so like a dinner's
// recipe_url and a candidate's page it never leaves the server. SiteName
// is the name the page itself gives its site, and null when it gives none
// or the stored name could have come from a link (see pageSiteName), so a
// client shows "Recipe from {site}" only when SiteName is not null.
type Source struct {
SiteName *string `json:"site_name"`
}
// Get returns dinner id with its recipe and the reason it was suggested,
// read in one read transaction. An unknown or archived dinner is
// ErrNotFound: an archived dinner has no detail to show.
func (s *Service) Get(ctx context.Context, id string) (d Detail, err error) {
tx, err := s.st.Reader.BeginTx(ctx, nil)
if err != nil {
return Detail{}, fmt.Errorf("dinners: begin read: %w", err)
}
defer func() {
err = errors.Join(err, tx.Rollback())
}()
q := db.New(tx)
row, err := q.GetDinner(ctx, id)
if errors.Is(err, sql.ErrNoRows) {
return Detail{}, ErrNotFound
}
if err != nil {
return Detail{}, fmt.Errorf("dinners: read %s: %w", id, err)
}
if row.Status == StatusArchived {
return Detail{}, ErrNotFound
}
d = Detail{
ID: row.ID,
Name: row.Name,
Status: row.Status,
Origin: row.Origin,
Method: nullString(row.Method),
Difficulty: nullString(row.Difficulty),
Cuisine: nullString(row.Cuisine),
IsFavorite: row.IsFavorite == 1,
Ingredients: []Ingredient{},
Steps: []string{},
GroundingStatus: row.GroundingStatus,
GroundingError: nullString(row.GroundingError),
}
if row.MinutesTotal.Valid {
d.MinutesTotal = &row.MinutesTotal.Int64
}
if row.PhotoID.Valid {
d.PhotoURLs = make(map[string]string, len(recipes.PhotoSizes))
for _, size := range recipes.PhotoSizes {
d.PhotoURLs[strconv.Itoa(size)] = photoURL(row.PhotoID.String, size)
}
}
if (row.GroundingStatus == GroundingGrounded || row.GroundingStatus == GroundingManual) && !row.RecipeID.Valid {
return Detail{}, fmt.Errorf("dinners: %s is %s with no recipe", id, row.GroundingStatus)
}
if row.RecipeID.Valid {
if err := readRecipe(ctx, q, row.RecipeID.String, &d); err != nil {
return Detail{}, fmt.Errorf("dinners: read recipe of %s: %w", id, err)
}
}
reasons, err := q.GetDinnerSuggestionReasons(ctx, id)
if err != nil {
return Detail{}, fmt.Errorf("dinners: read suggestion reason of %s: %w", id, err)
}
switch len(reasons) {
case 0:
case 1:
d.WhyPicked = &reasons[0]
default:
// Nothing records which of them created the dinner, and any
// one picked would be a guess.
return Detail{}, fmt.Errorf("dinners: %s has %d suggestions that could have created it", id, len(reasons))
}
return d, nil
}
// readRecipe sets d's Source, Ingredients and Steps from recipe recipeID.
func readRecipe(ctx context.Context, q *db.Queries, recipeID string, d *Detail) error {
rec, err := q.GetRecipe(ctx, recipeID)
if err != nil {
return fmt.Errorf("recipe %s: %w", recipeID, err)
}
if !IsManualRecipe(rec.SourceUrl) {
site, err := pageSiteName(rec.SiteName, rec.SourceUrl)
if err != nil {
return err
}
d.Source = &Source{SiteName: site}
}
ings, err := q.ListRecipeIngredients(ctx, recipeID)
if err != nil {
return fmt.Errorf("ingredients: %w", err)
}
for i, ing := range ings {
if i > 0 && ing.Position == ings[i-1].Position {
return duplicatePosition("ingredient", ing.Position)
}
d.Ingredients = append(d.Ingredients, Ingredient{
Name: nullString(ing.Name),
Qty: nullString(ing.Qty),
Unit: nullString(ing.Unit),
Note: nullString(ing.Note),
RawText: ing.RawText,
})
}
steps, err := q.ListRecipeSteps(ctx, recipeID)
if err != nil {
return fmt.Errorf("steps: %w", err)
}
for i, step := range steps {
if i > 0 && step.Position == steps[i-1].Position {
return duplicatePosition("step", step.Position)
}
d.Steps = append(d.Steps, step.Text)
}
return nil
}
// pageSiteName is the stored site name of a recipe fetched from sourceURL
// when it is one the page gave, else nil. A page that names no site is
// stored under recipes.HostSiteName of the URL it was served from: the
// pasted link's host, which may hold a personal token (a private
// subdomain), or that of a page the link redirected to, which is not
// stored. Nothing records which a stored name is, so any name that could
// be a host — the link's own host, an IP address, or a dotted host name —
// is treated as one and withheld. A site that names itself as a domain,
// such as "Food.com", loses its attribution with them: a recipe with no
// attribution is better than one that leaks a link.
func pageSiteName(stored sql.NullString, sourceURL string) (*string, error) {
if !stored.Valid {
return nil, nil
}
link, err := url.Parse(sourceURL)
if err != nil {
// Not wrapped: url.Parse's error quotes the link.
return nil, errors.New("source url does not parse")
}
name := stored.String
if strings.EqualFold(name, recipes.HostSiteName(link)) || net.ParseIP(name) != nil {
return nil, nil
}
if u, err := url.Parse("https://" + name + "/"); err == nil && u.Host == name && strings.Contains(name, ".") {
return nil, nil
}
return &name, nil
}
// duplicatePosition is the error for two recipe items of kind at position.
// Position is the only record of recipe order, and the id that breaks the
// tie says nothing about it, so a recipe with a duplicate is refused rather
// than shown in an order nobody can trust.
func duplicatePosition(kind string, position int64) error {
return fmt.Errorf("two %ss at position %d: recipe order is unknown", kind, position)
}
+1 -6
View File
@@ -572,12 +572,7 @@ func checkUnique(ctx context.Context, q *db.Queries, self, name string, recipeUR
// PhotoURL is where the API serves a list-sized copy of photo id.
func PhotoURL(id string) string {
return photoURL(id, photoListWidth)
}
// photoURL is where the API serves photo id at stored width size.
func photoURL(id string, size int) string {
return fmt.Sprintf("/media/photos/%s-%d.jpg", id, size)
return fmt.Sprintf("/media/photos/%s-%d.jpg", id, photoListWidth)
}
func fromRow(r db.Dinner) Dinner {
-44
View File
@@ -1,7 +1,6 @@
package dinners_test
import (
"database/sql"
"encoding/json"
"errors"
"strings"
@@ -75,46 +74,3 @@ func TestCreateRegularNeverNamesFromLink(t *testing.T) {
}
check("duplicate error", dup.Error())
}
// TestPageSiteNameWithholdsHosts: a stored site name that could be a host —
// the link's own, in any case and without "www.", an IP address or a dotted
// host name — is withheld; a name the page gave, dotless or with spaces, is
// kept.
func TestPageSiteNameWithholdsHosts(t *testing.T) {
t.Parallel()
const link = "https://www.TokenHost/recipes/stew"
for _, tc := range []struct {
stored string
valid bool
want *string
}{
{"", false, nil},
{"tokenhost", true, nil},
{"TOKENHOST", true, nil},
{"private-token.example.com", true, nil},
{"Food.com", true, nil},
{"203.0.113.7", true, nil},
{"2001:db8::1", true, nil},
{"Delish", true, ptr("Delish")},
{"Bon Appétit", true, ptr("Bon Appétit")},
{"Mr. Food Test Kitchen", true, ptr("Mr. Food Test Kitchen")},
} {
got, err := dinners.PageSiteName(sql.NullString{String: tc.stored, Valid: tc.valid}, link)
if err != nil {
t.Fatalf("PageSiteName(%q): %v", tc.stored, err)
}
if (got == nil) != (tc.want == nil) || (got != nil && *got != *tc.want) {
t.Errorf("PageSiteName(%q) = %v, want %v", tc.stored, deref(got), deref(tc.want))
}
}
if _, err := dinners.PageSiteName(sql.NullString{String: "Delish", Valid: true}, "https://secret-token\x7f.example/%zz"); err == nil || strings.Contains(err.Error(), "secret-token") {
t.Errorf("PageSiteName of an unparseable link: err = %v, want one not quoting the link", err)
}
}
func deref(s *string) any {
if s == nil {
return nil
}
return *s
}
-3
View File
@@ -71,6 +71,3 @@ func (s *Service) RunGroundJob(ctx context.Context, p GroundJob) (string, error)
r, err := s.runGroundJob(ctx, p, nil)
return r.GroundingStatus, err
}
// PageSiteName is pageSiteName.
var PageSiteName = pageSiteName
+203
View File
@@ -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 ""
}
+224
View File
@@ -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))
}
}
+2 -8
View File
@@ -29,7 +29,7 @@ type Recipe struct {
// Title is the recipe's name.
Title string
// SiteName is the publisher's name, else the page's og:site_name, else
// HostSiteName of the page's URL.
// its host without a leading "www.".
SiteName string
// ImageURL is the largest image, absolute; empty when the recipe has
// none.
@@ -232,13 +232,7 @@ func siteName(publisher any, g graph, ogSiteName string, base *url.URL) (string,
if ogSiteName != "" {
return ogSiteName, nil
}
return HostSiteName(base), nil
}
// HostSiteName is the site name of a page that names none: its host
// without a leading "www.".
func HostSiteName(u *url.URL) string {
return strings.TrimPrefix(u.Hostname(), "www.")
return strings.TrimPrefix(base.Hostname(), "www."), nil
}
// imageURL picks the largest of the recipe's images by width × height;
+1 -1
View File
@@ -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)
}
-404
View File
@@ -1,404 +0,0 @@
package server
import (
"database/sql"
"encoding/json"
"net/http"
"net/http/httptest"
"reflect"
"strings"
"testing"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/db"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/dinners"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/recipes/recipestest"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/store"
)
// getDetail answers GET /dinners/{id} for id, failing unless it is 200, and
// returns the body both raw (to tell null from absent) and decoded.
func getDetail(t *testing.T, h http.Handler, id string) (map[string]any, dinners.Detail) {
t.Helper()
rec, raw := callDinners(t, h, http.MethodGet, dinnersPath+"/"+id, "")
if rec.Code != http.StatusOK {
t.Fatalf("GET dinner = %d: %s", rec.Code, rec.Body)
}
var d dinners.Detail
dec := json.NewDecoder(rec.Body)
dec.DisallowUnknownFields()
if err := dec.Decode(&d); err != nil {
t.Fatalf("decode detail: %v", err)
}
return raw, d
}
// wantNull fails unless each key is present in raw and null.
func wantNull(t *testing.T, raw map[string]any, keys ...string) {
t.Helper()
for _, k := range keys {
if v, ok := raw[k]; !ok || v != nil {
t.Errorf("%s = %v (present %v), want null", k, v, ok)
}
}
}
// TestGetDinnerGroundedSideQuest: a Side quest grounded in a fetched page
// shows its photo at both stored sizes, its recipe's ingredients and steps
// in recipe order (not row order), its source site and the reason it was
// suggested.
func TestGetDinnerGroundedSideQuest(t *testing.T) {
t.Parallel()
e := newDinnersEnv(t).withRecipes(t)
e.model.QueueGenerate(recipestest.NormalizedReply("stovetop", 40))
e.start(t)
rec, created := callDinners(t, e.h, http.MethodPost, dinnersPath, `{"name":"Chicken curry"}`)
if rec.Code != http.StatusCreated {
t.Fatalf("POST = %d: %s", rec.Code, rec.Body)
}
if job := waitJob(t, e.h, created["grounding_job_id"].(string)); job["status"] != "succeeded" {
t.Fatalf("grounding job = %v", job)
}
id := created["id"].(string)
// Make it an accepted Side quest with the suggestion that created it,
// and reverse its recipe's positions so row order disagrees with
// recipe order.
var recipeID, photoID string
if err := e.st.Reader.QueryRow(`SELECT recipe_id, photo_id FROM dinners WHERE id = ?`, id).Scan(&recipeID, &photoID); err != nil {
t.Fatal(err)
}
for _, stmt := range []string{
`UPDATE dinners SET status = 'quest_log', origin = 'side_quest', is_favorite = 1 WHERE id = ?`,
`UPDATE recipe_ingredients SET position = 1 - position WHERE recipe_id = (SELECT recipe_id FROM dinners WHERE id = ?)`,
`UPDATE recipe_steps SET position = 1 - position WHERE recipe_id = (SELECT recipe_id FROM dinners WHERE id = ?)`,
} {
if _, err := e.st.Writer.Exec(stmt, id); err != nil {
t.Fatal(err)
}
}
q := db.New(e.st.Writer)
now := store.Now().String()
batch, err := q.CreateSuggestionBatch(t.Context(), db.CreateSuggestionBatchParams{ID: store.NewID(), Trigger: "manual", CreatedAt: now, UpdatedAt: now})
if err != nil {
t.Fatal(err)
}
const reason = "Like your Tikka masala, but quicker."
if err := q.CreateSuggestion(t.Context(), db.CreateSuggestionParams{
ID: store.NewID(), DinnerID: id, BatchID: batch.ID, Name: "Chicken curry",
Reason: reason, CitedRegularIds: "[]", CreatedAt: now, UpdatedAt: now,
}); err != nil {
t.Fatal(err)
}
_, got := getDetail(t, e.h, id)
str := func(s string) *string { return &s }
minutes := int64(40)
want := dinners.Detail{
ID: id,
Name: "Chicken curry",
Status: "quest_log",
Origin: "side_quest",
Method: str("stovetop"),
MinutesTotal: &minutes,
Difficulty: str("easy"),
IsFavorite: true,
PhotoURLs: map[string]string{
"400": "/media/photos/" + photoID + "-400.jpg",
"1200": "/media/photos/" + photoID + "-1200.jpg",
},
Ingredients: []dinners.Ingredient{
{Name: str("garlic"), Qty: str("2"), Unit: str("cloves"), Note: str("minced"), RawText: "2 cloves garlic, minced"},
{Name: str("onion"), Qty: str("1"), RawText: "1 onion"},
},
Steps: []string{"Fry with the garlic.", "Chop the onion."},
Source: &dinners.Source{SiteName: str("Curry House")},
GroundingStatus: "grounded",
WhyPicked: str(reason),
}
if !reflect.DeepEqual(got, want) {
gj, _ := json.Marshal(got)
wj, _ := json.Marshal(want)
t.Errorf("detail =\n %s\nwant\n %s", gj, wj)
}
// Each photo URL is served.
for _, u := range got.PhotoURLs {
rec := httptest.NewRecorder()
e.h.ServeHTTP(rec, signedIn(http.MethodGet, u, nil))
if rec.Code != http.StatusOK || rec.Header().Get("Content-Type") != "image/jpeg" {
t.Errorf("GET %s = %d %s", u, rec.Code, rec.Header().Get("Content-Type"))
}
}
}
// TestGetDinnerManualRegular: a Regular added by hand and given a recipe
// typed in has no photo, no source and no reason, and its ingredients are
// the typed lines with nothing normalized.
func TestGetDinnerManualRegular(t *testing.T) {
t.Parallel()
e := newDinnersEnv(t)
id := createDinner(t, e.h, `{"name":"Family chili"}`)
if rec, _ := callDinners(t, e.h, http.MethodPost, dinnersPath+"/"+id+"/ground",
`{"manual":{"ingredients":["1 lb beef","1 can beans"],"steps":["Brown.","Simmer."],"minutes":240,"method":"crockpot"}}`); rec.Code != http.StatusOK {
t.Fatalf("POST ground = %d: %s", rec.Code, rec.Body)
}
raw, got := getDetail(t, e.h, id)
wantNull(t, raw, "photo_urls", "source", "why_picked", "difficulty", "grounding_error")
str := func(s string) *string { return &s }
minutes := int64(240)
want := dinners.Detail{
ID: id,
Name: "Family chili",
Status: "regular",
Origin: "regular",
Method: str("crockpot"),
MinutesTotal: &minutes,
Ingredients: []dinners.Ingredient{{RawText: "1 lb beef"}, {RawText: "1 can beans"}},
Steps: []string{"Brown.", "Simmer."},
GroundingStatus: "manual",
}
if !reflect.DeepEqual(got, want) {
gj, _ := json.Marshal(got)
wj, _ := json.Marshal(want)
t.Errorf("detail =\n %s\nwant\n %s", gj, wj)
}
}
// TestGetDinnerFailedGrounding: a dinner whose grounding failed shows its
// real state — failed with its reason, no photo, no source and empty
// ingredients and steps — never a pretend recipe.
func TestGetDinnerFailedGrounding(t *testing.T) {
t.Parallel()
e := newDinnersEnv(t).withRecipes(t)
e.start(t)
rec, created := callDinners(t, e.h, http.MethodPost, dinnersPath, `{"name":"Nothing matches this"}`)
if rec.Code != http.StatusCreated {
t.Fatalf("POST = %d: %s", rec.Code, rec.Body)
}
if job := waitJob(t, e.h, created["grounding_job_id"].(string)); job["status"] != "failed" {
t.Fatalf("grounding job = %v, want failed", job)
}
raw, got := getDetail(t, e.h, created["id"].(string))
wantNull(t, raw, "photo_urls", "source", "why_picked", "method", "minutes_total", "difficulty")
if ings, ok := raw["ingredients"].([]any); !ok || len(ings) != 0 {
t.Errorf("ingredients = %v, want []", raw["ingredients"])
}
if steps, ok := raw["steps"].([]any); !ok || len(steps) != 0 {
t.Errorf("steps = %v, want []", raw["steps"])
}
if got.GroundingStatus != "failed" || got.GroundingError == nil || *got.GroundingError != dinners.ReasonNoRecipe ||
got.Name != "Nothing matches this" || got.Status != "regular" || got.Origin != "regular" {
t.Errorf("detail = %+v, want the failed Regular with no_recipe_found", got)
}
}
// TestGetDinnerNotFound: an unknown dinner and an archived one are both
// 404, and the detail needs a session like every /api/v1 route.
func TestGetDinnerNotFound(t *testing.T) {
t.Parallel()
e := newDinnersEnv(t)
id := createDinner(t, e.h, `{"name":"Old favourite"}`)
if rec, _ := callDinners(t, e.h, http.MethodDelete, dinnersPath+"/"+id, ""); rec.Code != http.StatusOK {
t.Fatalf("DELETE = %d: %s", rec.Code, rec.Body)
}
for _, target := range []string{id, store.NewID()} {
rec, body := callDinners(t, e.h, http.MethodGet, dinnersPath+"/"+target, "")
if rec.Code != http.StatusNotFound || errorCode(body) != "not_found" {
t.Errorf("GET %s = %d %v, want 404 not_found", target, rec.Code, body)
}
}
live := createDinner(t, e.h, `{"name":"Taco night"}`)
req := httptest.NewRequest(http.MethodGet, dinnersPath+"/"+live, nil)
if rec, body := serve(t, e.h, req); rec.Code != http.StatusUnauthorized || errorCode(body) != "unauthenticated" {
t.Errorf("GET without a session = %d %v, want 401", rec.Code, body)
}
}
// TestGetDinnerSourceHidesTokenURL: a recipe grounded in a pasted link that
// carries a token in its path or query shows its site name, but never the
// link or any part of it.
func TestGetDinnerSourceHidesTokenURL(t *testing.T) {
t.Parallel()
e := newDinnersEnv(t)
const (
pathToken = "path-token-abc123"
queryToken = "query-token-def456"
pathPage = "https://secret-path.example/u/" + pathToken + "/chicken-curry"
queryPage = "https://secret-query.example/curry?access_token=" + queryToken
)
e.pages.Bodies[pathPage] = recipestest.RecipePage("Chicken Curry", "Curry House", "https://a.example/curry.jpg", 40)
e.pages.Bodies[queryPage] = recipestest.RecipePage("Weeknight Curry", "B Kitchen", "https://b.example/curry.jpg", 25)
e.images.JPEGs["https://a.example/curry.jpg"] = recipestest.JPEG(t, 400, 320, 1)
e.images.JPEGs["https://b.example/curry.jpg"] = recipestest.JPEG(t, 400, 320, 2)
for _, tc := range []struct{ dish, page, site string }{
{"Chicken curry", pathPage, "Curry House"},
{"Weeknight curry", queryPage, "B Kitchen"},
} {
id := createDinner(t, e.h, `{"name":"`+tc.dish+`"}`) // runner not started: stays pending
e.model.QueueGenerate(recipestest.NormalizedReply("stovetop", 25))
if rec, _ := callDinners(t, e.h, http.MethodPost, dinnersPath+"/"+id+"/ground", `{"recipe_url":"`+tc.page+`"}`); rec.Code != http.StatusOK {
t.Fatalf("POST ground %s = %d: %s", tc.page, rec.Code, rec.Body)
}
var stored string
if err := e.st.Reader.QueryRow(`SELECT source_url FROM recipes WHERE id = (SELECT recipe_id FROM dinners WHERE id = ?)`, id).Scan(&stored); err != nil {
t.Fatal(err)
}
if stored != tc.page {
t.Fatalf("stored source_url = %q, want %q", stored, tc.page)
}
rec, _ := callDinners(t, e.h, http.MethodGet, dinnersPath+"/"+id, "")
if rec.Code != http.StatusOK {
t.Fatalf("GET dinner = %d: %s", rec.Code, rec.Body)
}
for _, secret := range []string{tc.page, "secret-path.example", "secret-query.example", pathToken, queryToken, "access_token", `"url"`} {
if strings.Contains(rec.Body.String(), secret) {
t.Errorf("detail of %s contains %q: %s", tc.dish, secret, rec.Body)
}
}
raw, _ := getDetail(t, e.h, id)
if src, _ := raw["source"].(map[string]any); !reflect.DeepEqual(src, map[string]any{"site_name": tc.site}) {
t.Errorf("source of %s = %v, want only site_name %q", tc.dish, raw["source"], tc.site)
}
}
}
// TestGetDinnerSiteNameNeverFromLink: a page that names no site is stored
// under its host, so a recipe grounded in a pasted link whose host carries a
// token has site_name null and nothing from the link in its detail; a page
// that names its site keeps that name, even on a token-bearing host; and a
// stored name shaped like another host, as a redirect leaves, is withheld.
func TestGetDinnerSiteNameNeverFromLink(t *testing.T) {
t.Parallel()
e := newDinnersEnv(t)
const (
bareToken = "private-token-abc123"
namedToken = "named-token-def456"
bare = "https://" + bareToken + ".example.com/recipe"
named = "https://www." + namedToken + ".example.com/recipe"
)
e.pages.Bodies[bare] = recipestest.RecipePage("Chicken Curry", "", "https://a.example/curry.jpg", 40)
e.pages.Bodies[named] = recipestest.RecipePage("Weeknight Curry", "Curry House", "https://b.example/curry.jpg", 25)
e.images.JPEGs["https://a.example/curry.jpg"] = recipestest.JPEG(t, 400, 320, 1)
e.images.JPEGs["https://b.example/curry.jpg"] = recipestest.JPEG(t, 400, 320, 2)
ground := func(dish, page string) (id, recipeID string) {
t.Helper()
id = createDinner(t, e.h, `{"name":"`+dish+`"}`) // runner not started: stays pending
e.model.QueueGenerate(recipestest.NormalizedReply("stovetop", 25))
if rec, _ := callDinners(t, e.h, http.MethodPost, dinnersPath+"/"+id+"/ground", `{"recipe_url":"`+page+`"}`); rec.Code != http.StatusOK {
t.Fatalf("POST ground %s = %d: %s", page, rec.Code, rec.Body)
}
if err := e.st.Reader.QueryRow(`SELECT recipe_id FROM dinners WHERE id = ?`, id).Scan(&recipeID); err != nil {
t.Fatal(err)
}
return id, recipeID
}
storedSite := func(recipeID string) string {
t.Helper()
var site string
if err := e.st.Reader.QueryRow(`SELECT site_name FROM recipes WHERE id = ?`, recipeID).Scan(&site); err != nil {
t.Fatal(err)
}
return site
}
check := func(what, id string, leaks []string, want any) {
t.Helper()
rec, _ := callDinners(t, e.h, http.MethodGet, dinnersPath+"/"+id, "")
if rec.Code != http.StatusOK {
t.Fatalf("GET %s = %d: %s", what, rec.Code, rec.Body)
}
for _, leak := range leaks {
if strings.Contains(strings.ToLower(rec.Body.String()), leak) {
t.Errorf("detail of %s contains %q: %s", what, leak, rec.Body)
}
}
raw, _ := getDetail(t, e.h, id)
if src, _ := raw["source"].(map[string]any); !reflect.DeepEqual(src, map[string]any{"site_name": want}) {
t.Errorf("source of %s = %v, want only site_name %v", what, raw["source"], want)
}
}
bareID, bareRecipe := ground("Chicken curry", bare)
if got := storedSite(bareRecipe); got != bareToken+".example.com" {
t.Fatalf("stored site_name = %q, want the link's host", got)
}
check("a page naming no site", bareID, []string{bare, bareToken, "example.com", `"url"`}, nil)
namedID, namedRecipe := ground("Weeknight curry", named)
if got := storedSite(namedRecipe); got != "Curry House" {
t.Fatalf("stored site_name = %q, want the page's", got)
}
check("a page naming its site", namedID, []string{named, namedToken, "example.com", `"url"`}, "Curry House")
const redirectHost = "redirect-token-ghi789.example.net"
if _, err := e.st.Writer.Exec(`UPDATE recipes SET site_name = ? WHERE id = ?`, redirectHost, namedRecipe); err != nil {
t.Fatal(err)
}
check("a page named by a redirect's host", namedID, []string{redirectHost, "redirect-token", namedToken}, nil)
}
// TestGetDinnerWhyPickedIsCreatingSuggestion: why_picked is the reason of
// the suggestion that created the dinner, never that of an earlier
// suggestion row for it that was declined.
func TestGetDinnerWhyPickedIsCreatingSuggestion(t *testing.T) {
t.Parallel()
e := newDinnersEnv(t)
id := createDinner(t, e.h, `{"name":"Chicken curry"}`)
if _, err := e.st.Writer.Exec(`UPDATE dinners SET status = 'quest_log', origin = 'side_quest' WHERE id = ?`, id); err != nil {
t.Fatal(err)
}
q := db.New(e.st.Writer)
now := store.Now().String()
batch, err := q.CreateSuggestionBatch(t.Context(), db.CreateSuggestionBatchParams{ID: store.NewID(), Trigger: "manual", CreatedAt: now, UpdatedAt: now})
if err != nil {
t.Fatal(err)
}
const reason = "Like your Tikka masala, but quicker."
// The declined row is created first, so it is the earliest by both
// created_at and id.
for _, s := range []struct{ reason, status string }{
{"Passed on last week.", "declined"},
{reason, "accepted"},
} {
sid := store.NewID()
if err := q.CreateSuggestion(t.Context(), db.CreateSuggestionParams{
ID: sid, DinnerID: id, BatchID: batch.ID, Name: "Chicken curry",
Reason: s.reason, CitedRegularIds: "[]", CreatedAt: now, UpdatedAt: now,
}); err != nil {
t.Fatal(err)
}
if n, err := q.DecideSuggestion(t.Context(), db.DecideSuggestionParams{
Status: s.status, Now: sql.NullString{String: now, Valid: true}, ID: sid,
}); err != nil || n != 1 {
t.Fatalf("decide %s = %d, %v", s.status, n, err)
}
}
if _, got := getDetail(t, e.h, id); got.WhyPicked == nil || *got.WhyPicked != reason {
t.Errorf("why_picked = %v, want %q", got.WhyPicked, reason)
}
}
// TestGetDinnerDuplicatePositionFails: a recipe with two ingredients or two
// steps at one position has no order anyone can trust, so its detail is a
// 500 rather than the recipe in some order.
func TestGetDinnerDuplicatePositionFails(t *testing.T) {
t.Parallel()
e := newDinnersEnv(t)
for _, table := range []string{"recipe_ingredients", "recipe_steps"} {
id := createDinner(t, e.h, `{"name":"Family chili `+table+`"}`)
if rec, _ := callDinners(t, e.h, http.MethodPost, dinnersPath+"/"+id+"/ground",
`{"manual":{"ingredients":["1 lb beef","1 can beans"],"steps":["Brown.","Simmer."],"minutes":240,"method":"crockpot"}}`); rec.Code != http.StatusOK {
t.Fatalf("POST ground = %d: %s", rec.Code, rec.Body)
}
if _, err := e.st.Writer.Exec(`UPDATE `+table+` SET position = 0 WHERE recipe_id = (SELECT recipe_id FROM dinners WHERE id = ?)`, id); err != nil {
t.Fatal(err)
}
rec, _ := callDinners(t, e.h, http.MethodGet, dinnersPath+"/"+id, "")
if rec.Code != http.StatusInternalServerError || strings.Contains(rec.Body.String(), "Simmer.") || strings.Contains(rec.Body.String(), "1 lb beef") {
t.Errorf("GET with duplicate %s positions = %d: %s, want 500 and no recipe", table, rec.Code, rec.Body)
}
}
}
-18
View File
@@ -70,24 +70,6 @@ func getDinners(logger *slog.Logger, svc *dinners.Service) http.HandlerFunc {
}
}
// getDinner answers GET /dinners/{id} with the dinner, its recipe and the
// reason it was suggested. An archived dinner is 404, like an unknown one,
// not a 200 with its status: it has no detail to show. source never has the
// recipe page's link, and source.site_name is null unless the page itself
// named its site — a name that could be a host, the link's or a redirect's,
// is withheld (dinners.Source) — so the detail screen shows "Recipe from
// {site}" only when site_name is not null.
func getDinner(logger *slog.Logger, svc *dinners.Service) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
d, err := svc.Get(r.Context(), chi.URLParam(r, "id"))
if err != nil {
writeDinnerError(w, r, logger, "get dinner", err)
return
}
writeJSON(w, r, logger, http.StatusOK, d)
}
}
// postDinner adds a Regular, recording the session's actor as updated_by,
// and answers 201 with it and its grounding_job_id without waiting for the
// job. When the job cannot be enqueued it still answers 201, with the dinner
+2 -8
View File
@@ -86,19 +86,13 @@ func newDinnersEnv(t *testing.T) *dinnersEnv {
t.Fatal(err)
}
e.runner = runner
// The router serves the photos the grounder caches.
dataDir := t.TempDir()
g := recipestest.NewGrounder(t, st, dataDir, e.search, e.pages, e.images, e.model)
photos, err := recipes.NewPhotoCache(e.images, st, dataDir)
if err != nil {
t.Fatal(err)
}
g := recipestest.NewGrounder(t, st, t.TempDir(), e.search, e.pages, e.images, e.model)
svc, err := dinners.NewService(st, g, runner, logger)
if err != nil {
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, photos, 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)
}
+1 -1
View File
@@ -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)
+1 -1
View File
@@ -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)
}
+1 -1
View File
@@ -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)
+1 -1
View File
@@ -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)
}
+1 -1
View File
@@ -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)
}
+98
View File
@@ -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")
}
+284
View File
@@ -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())
}
+1 -1
View File
@@ -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)
+12 -4
View File
@@ -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"
@@ -39,7 +40,7 @@ type RouteMounter interface {
// /media; settings serves /api/v1/settings/household; search serves
// /api/v1/settings/recipe-search and its /test; ai serves
// /api/v1/settings/ai, its /api-key, /verify, /models and /reset-prompt;
// dinnerSvc serves GET and POST /api/v1/dinners, GET, PATCH and DELETE
// dinnerSvc serves GET and POST /api/v1/dinners, PATCH and DELETE
// /api/v1/dinners/{id}, POST /api/v1/dinners/{id}/favorite, GET
// /api/v1/dinners/{id}/recipe-candidates and POST /api/v1/dinners/{id}/ground;
// suggestSvc serves POST /api/v1/suggestions/batches, GET
@@ -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")
}
@@ -130,7 +136,6 @@ func NewRouter(logger *slog.Logger, version string, trusted forwarded.Trusted, a
apiV1.Post("/settings/ai/reset-prompt", postAIResetPrompt(logger, ai))
apiV1.Get("/dinners", getDinners(logger, dinnerSvc))
apiV1.Post("/dinners", postDinner(logger, dinnerSvc))
apiV1.Get("/dinners/{id}", getDinner(logger, dinnerSvc))
apiV1.Patch("/dinners/{id}", patchDinner(logger, dinnerSvc))
apiV1.Delete("/dinners/{id}", deleteDinner(logger, dinnerSvc))
apiV1.Post("/dinners/{id}/favorite", postDinnerFavorite(logger, dinnerSvc))
@@ -159,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))
+11 -11
View File
@@ -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")
}
}
+1 -1
View File
@@ -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)
}
+1 -1
View File
@@ -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)
}
+87
View File
@@ -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
}
+92
View File
@@ -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)
}
}
}
+20
View File
@@ -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 = ?;
-11
View File
@@ -116,14 +116,3 @@ WHERE id = sqlc.arg(id) AND status = 'pending';
UPDATE dinners
SET status = sqlc.arg(status), updated_by = sqlc.arg(updated_by), updated_at = sqlc.arg(updated_at)
WHERE id = sqlc.arg(id) AND status = 'suggested';
-- name: GetDinnerSuggestionReasons :many
-- The reason of each suggestion of dinner_id that can have created it,
-- none for a dinner no suggestion created. A suggestion is only ever
-- created with a new dinner, and declining one archives its dinner, so a
-- live dinner's creating suggestion is pending (the dinner still
-- suggested) or accepted; a declined one did not create it. More than one
-- row means nothing records which did.
SELECT reason FROM suggestions
WHERE dinner_id = ? AND status IN ('pending', 'accepted')
ORDER BY id;
+1 -2
View File
@@ -35,7 +35,6 @@ const SCREENS = {
'/plan/shopping': true,
'/plan/shopping/archive': true,
'/discover': true,
'/dinner/:id': true,
'/settings': true,
'/settings/ai': true,
'/settings/household': true,
@@ -300,7 +299,7 @@ describe('App offline bootstrap', () => {
it.each([
['/quick', 'Quick dinners'],
['/cook-from-pantry', 'Cook from what I have'],
['/dinner/01J9ZK', 'Dinner'],
])('an offline reload of %s after an online visit opens the saved page', async (path, title) => {
await visitOnline()
goOffline()
-2
View File
@@ -3,7 +3,6 @@ import { QueryClientProvider } from '@tanstack/react-query'
import { Route, Routes } from 'react-router'
import { ApiProvider, createQueryClient, type ApiClient } from './api/index.ts'
import { AuthBootstrap } from './AuthBootstrap.tsx'
import { DinnerDetailPage } from './dinners/DinnerDetailPage.tsx'
import { DiscoverPage } from './discover/DiscoverPage.tsx'
import { ONBOARDING_PATH, OnboardingGate } from './onboarding/OnboardingGate.tsx'
import { OnboardingPage } from './onboarding/OnboardingPage.tsx'
@@ -46,7 +45,6 @@ const screens: Partial<Record<(typeof routes)[number]['path'], ReactNode>> = {
'/plan/shopping': <SupplyRunPage />,
'/plan/shopping/archive': <SupplyArchivePage />,
'/discover': <DiscoverPage />,
'/dinner/:id': <DinnerDetailPage />,
'/settings': <SettingsPage />,
'/settings/ai': <AISettingsPage />,
'/settings/household': <HouseholdSettingsPage />,
-240
View File
@@ -1,240 +0,0 @@
/* Mock 1k. On mobile the photo and the sheet run full-bleed past the
shell's 20 px side padding; from tablet up they sit in one column. The
page is at least as tall as the shell's scroll area (a stretched flex
item, so its height resolves the percentage) and the sheet grows into
any room left, so the action bar rests at the bottom of a short recipe. */
.page {
display: flex;
flex-direction: column;
min-height: 100%;
font-family: var(--font-body);
color: var(--color-text);
}
.tablet,
.desktop {
max-width: 720px;
margin: 0 auto;
}
.hero {
position: relative;
height: 250px;
}
.mobile .hero,
.mobile .skeletonHero {
margin: 0 -20px;
}
.tablet .hero,
.desktop .hero {
border-radius: var(--radius-card) var(--radius-card) 0 0;
overflow: hidden;
}
/* Without a dinner to show, the back button sits alone above the message. */
.backRow {
padding-top: calc(16px + env(safe-area-inset-top));
}
.backRow .back {
position: static;
}
.photo {
width: 100%;
height: 100%;
}
.back {
position: absolute;
top: calc(16px + env(safe-area-inset-top));
left: 20px;
}
/* The sheet overlaps the photo by 28 px. */
.sheet {
position: relative;
flex-grow: 1;
margin-top: -28px;
display: flex;
flex-direction: column;
gap: 10px;
padding: 22px 20px 0;
border-radius: 32px 32px 0 0;
background: var(--color-bg);
}
.mobile .sheet {
margin-left: -20px;
margin-right: -20px;
}
.tags {
display: flex;
flex-wrap: wrap;
gap: 6px;
}
.title {
margin: 0;
font-family: var(--font-heading);
font-size: 28px;
font-weight: 400;
line-height: 1.05;
}
.why {
margin: 0;
padding: 12px 14px;
border-radius: 18px;
background: var(--color-accent-2-100);
color: var(--color-accent-2-900);
font-size: 13px;
line-height: 1.45;
}
.section {
display: flex;
flex-direction: column;
margin-top: 6px;
}
.sectionTitle {
margin: 0 0 4px;
font-family: var(--font-heading);
font-size: 18px;
font-weight: 400;
line-height: 1.15;
}
.ingredients,
.steps {
margin: 0;
padding: 0;
list-style: none;
font-size: 14px;
line-height: 1.45;
}
.ingredient {
display: flex;
justify-content: space-between;
gap: 12px;
padding: 7px 0;
border-bottom: 1px solid var(--color-divider);
}
.ingredient:last-child {
border-bottom: 0;
}
.note,
.qty {
color: var(--color-neutral-700);
}
.qty {
flex: none;
text-align: right;
}
.step {
display: flex;
gap: 12px;
padding: 8px 0;
}
.number {
flex: none;
display: inline-flex;
align-items: center;
justify-content: center;
width: 24px;
height: 24px;
border-radius: var(--radius-pill);
background: var(--color-accent-100);
color: var(--color-accent-800);
font-size: 12px;
font-weight: 700;
}
.source {
margin: 4px 0 0;
font-size: 12px;
font-weight: 700;
color: var(--color-neutral-700);
}
.source a {
color: inherit;
}
.missing {
display: flex;
flex-direction: column;
align-items: flex-start;
gap: 8px;
margin: 6px 0 0;
padding: 12px 14px;
border-radius: 18px;
background: var(--color-neutral-100);
color: var(--color-neutral-800);
font-size: 13px;
line-height: 1.45;
}
.missing p {
margin: 0;
}
/* A 44 px tall tap target for the link. */
.fix {
display: inline-flex;
align-items: center;
min-height: 44px;
color: var(--color-accent-700);
font-weight: 700;
}
/* At the bottom of the page, which fills the scroll area, and held at the
bottom of the screen over a long recipe. Sticky offsets count from inside
the scroll area's padding, which on mobile already keeps clear of the nav
bar. */
.bar {
position: sticky;
bottom: 0;
z-index: 1;
display: flex;
align-items: center;
gap: 10px;
margin-top: 24px;
padding: 10px 0;
background: var(--color-bg);
}
.bar > :first-child {
flex: 1;
}
.add {
width: 100%;
min-height: 52px;
font-size: 16px;
}
.filled {
color: var(--color-accent);
fill: currentColor;
}
.skeletonHero {
height: 250px;
border-radius: 0;
}
.skeletonTitle {
height: 32px;
width: 70%;
}
-443
View File
@@ -1,443 +0,0 @@
import { act, fireEvent, render, screen, waitFor, within } from '@testing-library/react'
import { createMemoryRouter, RouterProvider } from 'react-router'
import { afterEach, beforeEach, describe, expect, it, vi, type Mock } from 'vitest'
import { AppShell } from '../shell/AppShell.tsx'
import { cacheFallbackHeader } from '../sw/routes.ts'
import { ApiProviders } from '../test/ApiProviders.tsx'
import { declaredStyle } from '../test/cssRules.ts'
import { errorResponse, jsonResponse, stubFetchByPath } from '../test/fetchStub.ts'
import { mockViewport } from '../test/matchMedia.ts'
import { GROUNDING_POLL_MS } from './api.ts'
import { DinnerDetailPage, REGULAR_DINNERS } from './DinnerDetailPage.tsx'
const DINNER = '/api/v1/dinners/d1'
// detail is a dinner as GET /api/v1/dinners/{id} sends it: mock 1k's
// Korean beef bowls, a Side quest grounded in a page that named its site.
function detail(overrides: Record<string, unknown> = {}) {
return {
id: 'd1',
name: 'Korean beef bowls',
status: 'quest_log',
origin: 'suggested',
method: 'stovetop',
minutes_total: 30,
difficulty: 'easy',
cuisine: 'korean',
is_favorite: false,
photo_urls: { '400': '/media/photos/p1-400.jpg', '1200': '/media/photos/p1-1200.jpg' },
ingredients: [
{ name: 'Ground beef', qty: '1', unit: 'lb', note: null, raw_text: '1 lb ground beef' },
{ name: 'Jasmine rice', qty: '2', unit: 'cups', note: null, raw_text: '2 cups jasmine rice' },
{ name: 'Soy sauce', qty: '¼', unit: 'cup', note: null, raw_text: '¼ cup soy sauce' },
{ name: 'Green onions', qty: '1', unit: 'bunch', note: 'sliced', raw_text: '1 bunch green onions, sliced' },
],
steps: ['Cook the rice.', 'Brown the beef with the sauce.', 'Serve over rice with the onions.'],
source: { site_name: 'Serious Eats' },
grounding_status: 'grounded',
grounding_error: null,
why_picked: 'close cousin of your Beef tacos and Chicken stir-fry.',
...overrides,
}
}
// A recipe the household typed in: only raw_text, no source.
const MANUAL = {
status: 'regular',
origin: 'regular',
photo_urls: null,
ingredients: [
{ name: null, qty: null, unit: null, note: null, raw_text: '2 lb chuck roast' },
{ name: null, qty: null, unit: null, note: null, raw_text: 'A splash of red wine' },
],
steps: ['Sear the roast.', 'Slow cook 8 hours.'],
source: null,
grounding_status: 'manual',
why_picked: null,
}
// A Regular whose recipe could not be found: no recipe at all.
const FAILED = {
status: 'regular',
origin: 'regular',
photo_urls: null,
minutes_total: null,
difficulty: null,
ingredients: [],
steps: [],
source: null,
grounding_status: 'failed',
grounding_error: 'no_recipe_found',
why_picked: null,
}
// renderAt renders the detail screen at the last of entries, inside the
// app shell's scroll area as App routes it, with probes for where its links
// and back button go.
function renderAt(entries: string[]) {
const router = createMemoryRouter(
[
{ element: <AppShell />, children: [{ path: '/dinner/:id', element: <DinnerDetailPage /> }] },
{ path: '/', element: <h1>Home probe</h1> },
{ path: '/plan', element: <h1>Plan probe</h1> },
{ path: REGULAR_DINNERS, element: <h1>Regular dinners probe</h1> },
],
{ initialEntries: entries, initialIndex: entries.length - 1 },
)
render(
<ApiProviders>
<RouterProvider router={router} />
</ApiProviders>,
)
return router
}
function serve(body: unknown) {
return stubFetchByPath({ [DINNER]: () => jsonResponse(200, body) })
}
beforeEach(() => {
mockViewport(390)
})
afterEach(() => {
Reflect.deleteProperty(window, 'matchMedia')
vi.unstubAllGlobals()
})
describe('DinnerDetailPage', () => {
describe('a grounded Side quest', () => {
it('shows its photo washed and full-bleed, tags, title and why it was picked', async () => {
serve(detail())
renderAt(['/dinner/d1'])
expect(await screen.findByRole('heading', { level: 1, name: 'Korean beef bowls' })).toBeInTheDocument()
const photo = screen.getByRole('img', { name: 'Korean beef bowls' })
expect(photo).toHaveAttribute('src', '/media/photos/p1-400.jpg')
expect(photo).toHaveAttribute('srcset', '/media/photos/p1-400.jpg 400w, /media/photos/p1-1200.jpg 1200w')
expect(declaredStyle(photo.parentElement!).height).toBe('250px')
expect(declaredStyle(photo.parentElement!).margin).toBe('0px -20px')
expect(screen.getByText('Side quest')).toBeInTheDocument()
expect(screen.getByText('Easy')).toBeInTheDocument()
expect(screen.getByText('30 min')).toBeInTheDocument()
expect(screen.queryByText('Regular')).not.toBeInTheDocument()
expect(screen.getByText(/close cousin of your Beef tacos/)).toHaveTextContent(
'Why it was picked: close cousin of your Beef tacos and Chicken stir-fry.',
)
})
it('lists ingredients in recipe order with quantities on the right, then numbered steps', async () => {
serve(detail())
renderAt(['/dinner/d1'])
expect(await screen.findByRole('heading', { level: 2, name: 'Ingredients' })).toBeInTheDocument()
const rows = within(screen.getAllByRole('list')[0]!).getAllByRole('listitem')
expect(rows.map((row) => Array.from(row.children, (cell) => cell.textContent))).toEqual([
['Ground beef', '1 lb'],
['Jasmine rice', '2 cups'],
['Soy sauce', '¼ cup'],
['Green onions, sliced', '1 bunch'],
])
const qty = rows[0]!.lastElementChild!
expect(declaredStyle(qty).textAlign).toBe('right')
expect(declaredStyle(rows[0]!).justifyContent).toBe('space-between')
expect(declaredStyle(rows[0]!).borderBottom).toBe('1px solid var(--color-divider)')
expect(screen.getByRole('heading', { level: 2, name: 'Steps' })).toBeInTheDocument()
const steps = screen.getAllByRole('list')[1]!
expect(steps.tagName).toBe('OL')
expect(within(steps).getAllByRole('listitem').map((step) => step.textContent)).toEqual([
'1Cook the rice.',
'2Brown the beef with the sauce.',
'3Serve over rice with the onions.',
])
})
it('credits the site the recipe came from, with no link to it', async () => {
serve(detail())
renderAt(['/dinner/d1'])
const credit = await screen.findByText('Recipe from Serious Eats')
expect(credit.closest('a')).toBeNull()
expect(screen.queryByRole('link', { name: /Recipe from/ })).not.toBeInTheDocument()
})
it('links the credit to the recipe in a new tab once the server sends a safe url', async () => {
serve(detail({ source: { site_name: 'Serious Eats', url: 'https://www.seriouseats.com/korean-beef-bowls' } }))
renderAt(['/dinner/d1'])
const credit = await screen.findByRole('link', { name: 'Recipe from Serious Eats' })
expect(credit).toHaveAttribute('href', 'https://www.seriouseats.com/korean-beef-bowls')
expect(credit).toHaveAttribute('target', '_blank')
expect(credit).toHaveAttribute('rel', 'noopener noreferrer')
})
it.each([
['http', 'http://www.seriouseats.com/korean-beef-bowls'],
['a password', 'https://user:secret@www.seriouseats.com/korean-beef-bowls'],
['a query', 'https://www.seriouseats.com/korean-beef-bowls?token=abc'],
['a fragment', 'https://www.seriouseats.com/korean-beef-bowls#token=abc'],
])('refuses a source url with %s', async (_, url) => {
serve(detail({ source: { site_name: 'Serious Eats', url } }))
renderAt(['/dinner/d1'])
expect(await screen.findByRole('alert')).toHaveTextContent('source url is not an https link')
expect(screen.queryByRole('link', { name: /Recipe from/ })).not.toBeInTheDocument()
})
it('leaves the credit out when the server names no site', async () => {
serve(detail({ source: { site_name: null } }))
renderAt(['/dinner/d1'])
await screen.findByRole('heading', { level: 1, name: 'Korean beef bowls' })
expect(screen.queryByText(/Recipe from/)).not.toBeInTheDocument()
})
it('holds Add to this week and the heart at the bottom, at rest until the next update', async () => {
serve(detail({ is_favorite: true }))
renderAt(['/dinner/d1'])
const add = await screen.findByRole('button', { name: 'Add to this week' })
expect(add).toHaveAttribute('aria-disabled', 'true')
expect(add).toHaveAccessibleDescription('Adding to this week and the favorite heart arrive in the next update.')
const heart = screen.getByRole('button', { name: 'Favorite' })
expect(heart).toBeDisabled()
expect(heart).toHaveAttribute('aria-pressed', 'true')
const bar = heart.parentElement!
expect(bar).toContainElement(add)
expect(declaredStyle(bar).position).toBe('sticky')
expect(declaredStyle(bar).bottom).toBe('0px')
})
})
// jsdom does no layout, so no test here can measure where the bar lands.
// This pins the CSS chain that puts it at the bottom of the screen on a
// short recipe: the shell's scroll area is a full-height flex item, the
// page fills it as a column, the sheet takes the room left and the bar is
// the page's last child. Drop any link and the bar sits right after the
// content again, and this fails.
it('fills the scroll area so the bar rests at the bottom of a short recipe', async () => {
serve(detail(FAILED))
renderAt(['/dinner/d1'])
const bar = (await screen.findByRole('button', { name: 'Favorite' })).parentElement!
const page = bar.parentElement!
const main = page.parentElement!
const shell = main.parentElement!
expect(main.tagName).toBe('MAIN')
expect(declaredStyle(shell).height).toBe('100dvh')
expect(declaredStyle(shell).display).toBe('flex')
expect(declaredStyle(shell).alignItems).toBe('')
expect(declaredStyle(main).overflowY).toBe('auto')
expect(declaredStyle(page).minHeight).toBe('100%')
expect(declaredStyle(page).display).toBe('flex')
expect(declaredStyle(page).flexDirection).toBe('column')
expect(page.lastElementChild).toBe(bar)
const sheet = screen.getByRole('article')
expect(sheet.parentElement).toBe(page)
expect(sheet.nextElementSibling).toBe(bar)
expect(declaredStyle(sheet).flexGrow).toBe('1')
expect(declaredStyle(bar).position).toBe('sticky')
})
it('shows the striped placeholder when the dinner has no photo', async () => {
serve(detail({ photo_urls: null }))
renderAt(['/dinner/d1'])
const placeholder = await screen.findByRole('img', { name: 'Korean beef bowls' })
expect(placeholder.tagName).toBe('DIV')
expect(placeholder).toHaveTextContent('dish photo')
})
it('shows a manual Regular line by line, as typed, with no credit', async () => {
serve(detail(MANUAL))
renderAt(['/dinner/d1'])
expect(await screen.findByText('Regular')).toBeInTheDocument()
expect(screen.queryByText('Side quest')).not.toBeInTheDocument()
const [ingredients, steps] = screen.getAllByRole('list')
expect(within(ingredients!).getAllByRole('listitem').map((row) => row.firstElementChild!.textContent)).toEqual([
'2 lb chuck roast',
'A splash of red wine',
])
expect(within(steps!).getAllByRole('listitem')).toHaveLength(2)
expect(screen.queryByText(/Why it was picked/)).not.toBeInTheDocument()
expect(screen.queryByText(/Recipe from/)).not.toBeInTheDocument()
expect(screen.getByRole('img', { name: 'Korean beef bowls' })).toHaveTextContent('dish photo')
})
it('links a dinner whose recipe was not found to Settings → Regular dinners, with no recipe', async () => {
serve(detail(FAILED))
const router = renderAt(['/dinner/d1'])
const fix = await screen.findByRole('link', { name: 'Fix recipe' })
expect(screen.getByText(/couldn't find a recipe/)).toBeInTheDocument()
expect(screen.queryByRole('heading', { name: 'Ingredients' })).not.toBeInTheDocument()
expect(screen.queryByRole('heading', { name: 'Steps' })).not.toBeInTheDocument()
expect(screen.queryByRole('list')).not.toBeInTheDocument()
fireEvent.click(fix)
expect(await screen.findByRole('heading', { name: 'Regular dinners probe' })).toBeInTheDocument()
expect(router.state.location.pathname).toBe(REGULAR_DINNERS)
})
it('says it is still finding a recipe while grounding is pending', async () => {
serve(detail({ ...FAILED, grounding_status: 'pending', grounding_error: null }))
renderAt(['/dinner/d1'])
expect(await screen.findByText(/Still finding the recipe/)).toBeInTheDocument()
expect(screen.queryByRole('link', { name: 'Fix recipe' })).not.toBeInTheDocument()
})
describe('while the recipe is being found', () => {
const PENDING = detail({ ...FAILED, grounding_status: 'pending', grounding_error: null })
const GROUNDED = detail({ grounding_status: 'grounded' })
let onLine: boolean
let body: unknown
let fromCache: boolean
let fetchMock: Mock<typeof fetch>
const reads = () => fetchMock.mock.calls.length
const tick = (intervals = 1) => act(() => vi.advanceTimersByTimeAsync(GROUNDING_POLL_MS * intervals))
function setOnline(value: boolean) {
onLine = value
act(() => {
window.dispatchEvent(new Event(value ? 'online' : 'offline'))
})
}
beforeEach(() => {
// Only intervals are faked: TanStack Query polls with setInterval, and
// Testing Library's waits keep their real timers.
vi.useFakeTimers({ toFake: ['setInterval', 'clearInterval'] })
onLine = true
vi.spyOn(navigator, 'onLine', 'get').mockImplementation(() => onLine)
body = PENDING
fromCache = false
fetchMock = stubFetchByPath({
[DINNER]: () =>
new Response(JSON.stringify(body), {
headers: { 'Content-Type': 'application/json', ...(fromCache ? { [cacheFallbackHeader]: '1' } : {}) },
}),
})
})
// An offline event leaves TanStack Query's online manager, shared by
// every test, offline until an online event.
afterEach(() => {
window.dispatchEvent(new Event('online'))
vi.useRealTimers()
vi.restoreAllMocks()
})
it('reads the dinner again until the recipe lands, then stops', async () => {
renderAt(['/dinner/d1'])
expect(await screen.findByText(/Still finding the recipe/)).toBeInTheDocument()
expect(reads()).toBe(1)
await tick()
await waitFor(() => expect(reads()).toBe(2))
body = GROUNDED
await tick()
expect(await screen.findByRole('heading', { level: 2, name: 'Ingredients' })).toBeInTheDocument()
expect(reads()).toBe(3)
await tick(3)
expect(reads()).toBe(3)
})
it('pauses while the browser is offline and resumes when it is back', async () => {
renderAt(['/dinner/d1'])
expect(await screen.findByText(/Still finding the recipe/)).toBeInTheDocument()
expect(reads()).toBe(1)
setOnline(false)
await tick(3)
expect(reads()).toBe(1)
setOnline(true)
await tick()
await waitFor(() => expect(reads()).toBe(2))
})
it('reads a saved pending copy offline once, then waits for the connection', async () => {
onLine = false
fromCache = true
renderAt(['/dinner/d1'])
expect(await screen.findByText(/Still finding the recipe/)).toBeInTheDocument()
expect(reads()).toBe(1)
await tick(3)
expect(reads()).toBe(1)
// The connection comes back: the saved copy is read again at once,
// and polling goes on once the server answers.
fromCache = false
setOnline(true)
await waitFor(() => expect(reads()).toBe(2))
await tick()
await waitFor(() => expect(reads()).toBe(3))
})
it('stops polling on a saved copy even while the browser says it is online', async () => {
fromCache = true
renderAt(['/dinner/d1'])
expect(await screen.findByText(/Still finding the recipe/)).toBeInTheDocument()
await tick(3)
expect(reads()).toBe(1)
})
it('stops polling when the page is left', async () => {
const router = renderAt(['/dinner/d1'])
expect(await screen.findByText(/Still finding the recipe/)).toBeInTheDocument()
expect(reads()).toBe(1)
await act(() => router.navigate('/'))
expect(screen.getByRole('heading', { name: 'Home probe' })).toBeInTheDocument()
await tick(3)
expect(reads()).toBe(1)
})
})
it('says an unknown or removed dinner is not found', async () => {
stubFetchByPath({ [DINNER]: () => errorResponse(404, 'not_found') })
renderAt(['/dinner/d1'])
expect(await screen.findByRole('heading', { name: 'Dinner not found' })).toBeInTheDocument()
expect(screen.queryByRole('alert')).not.toBeInTheDocument()
expect(screen.getByRole('link', { name: 'Go Home' })).toHaveAttribute('href', '/')
})
it('reports any other failure with a retry', async () => {
stubFetchByPath({ [DINNER]: () => errorResponse(500, 'internal') })
renderAt(['/dinner/d1'])
expect(await screen.findByRole('alert')).toHaveTextContent("Couldn't load this dinner.")
expect(screen.getByRole('button', { name: 'Retry' })).toBeInTheDocument()
})
describe('the back button', () => {
it('goes back in the app', async () => {
serve(detail())
const router = renderAt(['/plan', '/dinner/d1'])
fireEvent.click(await screen.findByRole('button', { name: 'Back' }))
expect(await screen.findByRole('heading', { name: 'Plan probe' })).toBeInTheDocument()
expect(router.state.location.pathname).toBe('/plan')
})
it('goes Home when the dinner was opened from a link', async () => {
serve(detail())
const router = renderAt(['/dinner/d1'])
fireEvent.click(await screen.findByRole('button', { name: 'Back' }))
expect(await screen.findByRole('heading', { name: 'Home probe' })).toBeInTheDocument()
expect(router.state.location.pathname).toBe('/')
})
})
})
-225
View File
@@ -1,225 +0,0 @@
import { ChevronLeft, Heart } from 'lucide-react'
import { Link, useLocation, useNavigate, useParams } from 'react-router'
import { ApiError } from '../api/errors.ts'
import { duration, Unavailable } from '../plan/PlanPage.tsx'
import { useBreakpoint } from '../shell/useBreakpoint.ts'
import buttonStyles from '../ui/Button.module.css'
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 { Photo } from '../ui/Photo.tsx'
import { Skeleton } from '../ui/Skeleton.tsx'
import { Tag } from '../ui/Tag.tsx'
import type { Difficulty } from './api.ts'
import { useDinnerDetail, type DinnerDetail, type Ingredient, type RecipeSource } from './detail.ts'
import styles from './DinnerDetailPage.module.css'
// REGULAR_DINNERS is Settings → Regular dinners, where a recipe is fixed.
export const REGULAR_DINNERS = '/settings/regulars'
// ADD_HINT is why Add to this week and the heart are unavailable.
// TODO(07423ba7): the add-to-week sheet and the favorite heart.
const ADD_HINT = 'Adding to this week and the favorite heart arrive in the next update.'
const difficultyLabels: Record<Difficulty, string> = { easy: 'Easy', medium: 'Medium', hard: 'Hard' }
// BackButton goes back in the app's history, or, opened from a link with
// no history in the app, to Home.
function BackButton() {
const navigate = useNavigate()
const location = useLocation()
// The first entry of a session has the key "default".
const back = () => (location.key === 'default' ? void navigate('/', { replace: true }) : void navigate(-1))
return (
<IconButton label="Back" tone="neutral" className={styles.back} onClick={back}>
<Icon icon={ChevronLeft} size="inline" />
</IconButton>
)
}
// quantity is an ingredient's amount as the right-hand column shows it,
// "1 lb", or empty when the recipe gives none.
function quantity(ingredient: Ingredient): string {
return [ingredient.qty, ingredient.unit].filter(Boolean).join(' ')
}
// IngredientRow is one ingredient: its name with any note, or the line as
// the recipe gives it when there is no name, and its amount on the right.
function IngredientRow({ ingredient }: { ingredient: Ingredient }) {
return (
<li className={styles.ingredient}>
<span>
{ingredient.name ?? ingredient.raw_text}
{ingredient.name !== null && ingredient.note !== null && <span className={styles.note}>, {ingredient.note}</span>}
</span>
<span className={styles.qty}>{quantity(ingredient)}</span>
</li>
)
}
// SourceCredit is "Recipe from {site}": a link opening the recipe's page in
// a new tab when the server sends a url safe to open, else plain text.
function SourceCredit({ siteName, url }: RecipeSource & { siteName: string }) {
const credit = `Recipe from ${siteName}`
return (
<p className={styles.source}>
{url === null ? (
credit
) : (
<a href={url} target="_blank" rel="noopener noreferrer">
{credit}
</a>
)}
</p>
)
}
// Recipe is the dinner's ingredients and steps, or what stands in for them:
// still searching, or the link to fix a recipe that could not be found.
function Recipe({ dinner }: { dinner: DinnerDetail }) {
if (dinner.grounding_status === 'failed') {
return (
<section className={styles.missing} aria-label="No recipe">
<p>We couldn't find a recipe for this dinner, so there are no ingredients or steps yet.</p>
<Link to={REGULAR_DINNERS} className={styles.fix}>
Fix recipe
</Link>
</section>
)
}
if (dinner.grounding_status === 'pending') {
return <p className={styles.missing}>Still finding the recipe — its ingredients and steps will show here.</p>
}
return (
<>
{dinner.ingredients.length > 0 && (
<section className={styles.section}>
<h2 className={styles.sectionTitle}>Ingredients</h2>
<ul className={styles.ingredients}>
{dinner.ingredients.map((ingredient, i) => (
<IngredientRow key={i} ingredient={ingredient} />
))}
</ul>
</section>
)}
{dinner.steps.length > 0 && (
<section className={styles.section}>
<h2 className={styles.sectionTitle}>Steps</h2>
<ol className={styles.steps}>
{dinner.steps.map((step, i) => (
<li key={i} className={styles.step}>
<span className={styles.number} aria-hidden="true">
{i + 1}
</span>
<span>{step}</span>
</li>
))}
</ol>
</section>
)}
{dinner.source !== null && dinner.source.siteName !== null && (
<SourceCredit siteName={dinner.source.siteName} url={dinner.source.url} />
)}
</>
)
}
// ActionBar is the bar at the bottom of the screen, the page's last child:
// the page fills the scroll area so a short recipe still leaves it at the
// bottom, and it sticks there over a long one. It holds Add to this week
// and the favorite heart, showing whether the dinner is a favorite. Both
// rest unavailable until the next update wires them.
function ActionBar({ dinner }: { dinner: DinnerDetail }) {
return (
<div className={styles.bar}>
<Unavailable hint={ADD_HINT} className={styles.add}>
Add to this week
</Unavailable>
<IconButton label="Favorite" size={52} aria-pressed={dinner.is_favorite} disabled>
<Icon icon={Heart} size="nav" className={dinner.is_favorite ? styles.filled : undefined} />
</IconButton>
</div>
)
}
// Detail is a dinner as mock 1k shows it: the photo full-bleed under a sheet
// with its tags, title, why it was picked and its recipe, over the action bar.
function Detail({ dinner }: { dinner: DinnerDetail }) {
const tags = [
dinner.difficulty !== null && difficultyLabels[dinner.difficulty],
dinner.minutes_total !== null && duration(dinner.minutes_total),
].filter((tag) => tag !== false)
return (
<>
<div className={styles.hero}>
<Photo id={dinner.photo_id} name={dinner.name} radius="0" sizes="100vw" className={styles.photo} />
<BackButton />
</div>
<article className={styles.sheet}>
<div className={styles.tags}>
<Tag tone="grape">{dinner.status === 'regular' ? 'Regular' : 'Side quest'}</Tag>
{tags.map((tag) => (
<Tag key={tag} tone="neutral">
{tag}
</Tag>
))}
</div>
<h1 className={styles.title}>{dinner.name}</h1>
{dinner.why_picked !== null && (
<p className={styles.why}>
<b>Why it was picked:</b> {dinner.why_picked}
</p>
)}
<Recipe dinner={dinner} />
</article>
<ActionBar dinner={dinner} />
</>
)
}
// DinnerDetailPage is a dinner with its recipe (mock 1k): its photo, or the
// striped placeholder, under a sheet with Regular or Side quest, difficulty
// and time, why it was picked, the ingredients and the steps. A dinner whose
// recipe could not be found links to Settings → Regular dinners to fix it;
// an unknown or removed dinner says it is not found.
export function DinnerDetailPage() {
const { id = '' } = useParams()
const layout = useBreakpoint()
const dinner = useDinnerDetail(id)
let body
if (dinner.isError) {
body = (
<>
<div className={styles.backRow}>
<BackButton />
</div>
{dinner.error instanceof ApiError && dinner.error.status === 404 ? (
<>
<EmptyState title="Dinner not found">This dinner isn't in our kitchen — it may have been removed.</EmptyState>
<Link to="/" className={[buttonStyles.button, buttonStyles.outline].join(' ')}>
Go Home
</Link>
</>
) : (
<ErrorBanner onRetry={() => void dinner.refetch()}>Couldn't load this dinner. {dinner.error.message}</ErrorBanner>
)}
</>
)
} else if (dinner.isPending) {
body = (
<section aria-busy="true" aria-label="Dinner loading">
<Skeleton className={styles.skeletonHero} />
<div className={styles.sheet}>
<Skeleton className={styles.skeletonTitle} />
<Skeleton />
<Skeleton />
</div>
</section>
)
} else {
body = <Detail dinner={dinner.data} />
}
return <div className={`${styles.page} ${styles[layout]}`}>{body}</div>
}
+1 -1
View File
@@ -12,7 +12,7 @@ export const GROUNDING_POLL_MS = 3000
// Methods and Difficulties are the values the dinners API allows.
export const METHODS = ['stovetop', 'oven', 'crockpot', 'grill', 'other'] as const
export const DIFFICULTIES = ['easy', 'medium', 'hard'] as const
export const GROUNDING_STATUSES = ['pending', 'grounded', 'failed', 'manual'] as const
const GROUNDING_STATUSES = ['pending', 'grounded', 'failed', 'manual'] as const
export type Method = (typeof METHODS)[number]
export type Difficulty = (typeof DIFFICULTIES)[number]
-178
View File
@@ -1,178 +0,0 @@
import { useQuery } from '@tanstack/react-query'
import { useEffect, useSyncExternalStore } from 'react'
import { useApi } from '../api/index.ts'
import { isRecord } from '../api/errors.ts'
import {
DIFFICULTIES,
dinnerPath,
DINNERS_PATH,
GROUNDING_POLL_MS,
GROUNDING_STATUSES,
METHODS,
oneOf,
photoId,
type Difficulty,
type GroundingStatus,
type Method,
} from './api.ts'
// The statuses a dinner with detail can have; an archived dinner is 404.
const STATUSES = ['suggested', 'quest_log', 'tried', 'regular'] as const
export type DinnerStatus = (typeof STATUSES)[number]
// Ingredient is one recipe line. raw_text is the line as the recipe gives
// it; name, qty, unit and note are the server's reading of it, each null
// when it has none — always name, qty and unit for a recipe typed in.
export type Ingredient = {
name: string | null
qty: string | null
unit: string | null
note: string | null
raw_text: string
}
// RecipeSource is where a fetched recipe came from. siteName is the site,
// or null when there is none to name: a page that named no site, or a name
// the server withheld because it could have come from the link. url is the
// recipe's page, or null: today's API never sends the stored link, which can
// carry a token, so the credit is text until the server sends a url it has
// checked is free of secrets.
export type RecipeSource = { siteName: string | null; url: string | null }
// DinnerDetail is a dinner with its recipe (GET /api/v1/dinners/{id}).
// source is null for a recipe typed in. cached says the service worker
// answered from its saved copy because the server could not be reached.
export type DinnerDetail = {
id: string
name: string
status: DinnerStatus
method: Method | null
minutes_total: number | null
difficulty: Difficulty | null
is_favorite: boolean
photo_id: string | undefined
ingredients: Ingredient[]
steps: string[]
source: RecipeSource | null
grounding_status: GroundingStatus
why_picked: string | null
cached: boolean
}
function text(where: string, field: string, value: unknown): string {
if (typeof value !== 'string') throw new Error(`${where}: ${field} is not a string`)
return value
}
function nullableText(where: string, field: string, value: unknown): string | null {
return value === null ? null : text(where, field, value)
}
function parseIngredient(where: string, body: unknown): Ingredient {
if (!isRecord(body)) throw new Error(`${where}: ingredient is not an object`)
return {
name: nullableText(where, 'ingredient name', body.name),
qty: nullableText(where, 'ingredient qty', body.qty),
unit: nullableText(where, 'ingredient unit', body.unit),
note: nullableText(where, 'ingredient note', body.note),
raw_text: text(where, 'ingredient raw_text', body.raw_text),
}
}
// detailPhotoId is the photo id of photo_urls, which the server sends as
// both cached sizes of one photo, or null.
function detailPhotoId(where: string, value: unknown): string | undefined {
if (value === null) return undefined
if (!isRecord(value)) throw new Error(`${where}: photo_urls is not an object`)
const id = photoId(where, value['400'])
if (id === undefined || value['1200'] !== `/media/photos/${encodeURIComponent(id)}-1200.jpg`) {
throw new Error(`${where}: photo_urls is not both sizes of one stored photo`)
}
return id
}
// sourceUrl is source.url, which today's API does not send, or a link safe
// to open: https, with no user, password, query or fragment.
function sourceUrl(where: string, value: unknown): string | null {
if (value === undefined || value === null) return null
const href = text(where, 'source url', value)
let link: URL
try {
link = new URL(href)
} catch (error) {
throw new Error(`${where}: source url is not a URL`, { cause: error })
}
if (link.protocol !== 'https:' || link.username !== '' || link.password !== '' || link.search !== '' || link.hash !== '') {
throw new Error(`${where}: source url is not an https link without credentials, query or fragment`)
}
return link.href
}
function parseSource(where: string, value: unknown): RecipeSource | null {
if (value === null) return null
if (!isRecord(value)) throw new Error(`${where}: source is not an object`)
return { siteName: nullableText(where, 'source site_name', value.site_name), url: sourceUrl(where, value.url) }
}
// parseDinnerDetail checks a dinner with its recipe as the API sends it;
// cached says whether the service worker answered from its saved copy.
export function parseDinnerDetail(where: string, body: unknown, cached: boolean): DinnerDetail {
if (!isRecord(body) || typeof body.id !== 'string' || typeof body.name !== 'string') {
throw new Error(`${where}: response is not a dinner`)
}
if (typeof body.is_favorite !== 'boolean') throw new Error(`${where}: is_favorite is not a boolean`)
if (!Array.isArray(body.ingredients) || !Array.isArray(body.steps)) throw new Error(`${where}: response has no recipe lists`)
const minutes = body.minutes_total
if (minutes !== null && (typeof minutes !== 'number' || !Number.isInteger(minutes))) {
throw new Error(`${where}: minutes_total is not a whole number`)
}
return {
id: body.id,
name: body.name,
status: oneOf(where, 'status', STATUSES, body.status),
method: body.method === null ? null : oneOf(where, 'method', METHODS, body.method),
minutes_total: minutes,
difficulty: body.difficulty === null ? null : oneOf(where, 'difficulty', DIFFICULTIES, body.difficulty),
is_favorite: body.is_favorite,
photo_id: detailPhotoId(where, body.photo_urls),
ingredients: body.ingredients.map((ingredient) => parseIngredient(where, ingredient)),
steps: body.steps.map((step) => text(where, 'step', step)),
source: parseSource(where, body.source),
grounding_status: oneOf(where, 'grounding_status', GROUNDING_STATUSES, body.grounding_status),
why_picked: nullableText(where, 'why_picked', body.why_picked),
cached,
}
}
// useDinnerDetail is dinner id with its recipe (GET /api/v1/dinners/{id}).
// While the dinner is still finding its recipe it is read again every
// GROUNDING_POLL_MS, but only while the server can be reached: polling pauses
// while the browser is offline or the last read was the service worker's
// saved copy, which would only answer the same again, and a saved pending
// copy is read once more when the connection comes back. Polling stops on
// unmount. An unknown or archived dinner fails with the API's 404. It runs
// even while the browser reports offline, so the copy the service worker
// saved is shown, or the read fails, instead of waiting for the network.
export function useDinnerDetail(id: string) {
const { apiRead, network } = useApi()
const offline = useSyncExternalStore(network.subscribe, network.offline)
const query = useQuery({
queryKey: ['dinners', 'detail', id],
queryFn: async () => {
const read = await apiRead<unknown>(dinnerPath(id))
return parseDinnerDetail(`GET ${DINNERS_PATH}/{id}`, read.body, read.cached)
},
networkMode: 'always',
refetchInterval: (query) => {
const dinner = query.state.data
return dinner?.grounding_status === 'pending' && !dinner.cached && !offline ? GROUNDING_POLL_MS : false
},
})
const { data, refetch } = query
const stalled = data?.grounding_status === 'pending' && data.cached
useEffect(() => {
if (stalled && !offline) void refetch()
}, [stalled, offline, refetch])
return query
}
-10
View File
@@ -24,11 +24,6 @@
margin: -2px;
}
.size52 {
width: 52px;
height: 52px;
}
.size62 {
width: 62px;
height: 62px;
@@ -61,11 +56,6 @@
height: 40px;
}
.size52 .face {
width: 52px;
height: 52px;
}
.size62 .face {
width: 62px;
height: 62px;
-7
View File
@@ -26,13 +26,6 @@ describe('IconButton', () => {
expect(hitArea(face)).toEqual({ width: 40, height: 40 })
})
it('is 52 px at the medium size', () => {
render(<IconButton label="Favorite" size={52} />)
const button = screen.getByRole('button', { name: 'Favorite' })
expectMinHitArea(button)
expect(hitArea(button)).toEqual({ width: 52, height: 52 })
})
it('is 62 px at the large size', () => {
render(<IconButton label="Try it" size={62} tone="teal" />)
const button = screen.getByRole('button', { name: 'Try it' })
+2 -3
View File
@@ -1,7 +1,7 @@
import type { ComponentPropsWithRef } from 'react'
import styles from './IconButton.module.css'
export type IconButtonSize = 40 | 52 | 62
export type IconButtonSize = 40 | 62
export type IconButtonTone = 'surface' | 'neutral' | 'accent' | 'teal'
export type IconButtonProps = Omit<ComponentPropsWithRef<'button'>, 'aria-label'> & {
@@ -15,8 +15,7 @@ export type IconButtonProps = Omit<ComponentPropsWithRef<'button'>, 'aria-label'
}
// Round icon button, named by its label and showing it only with showLabel.
// The 40 px size keeps a 44 px hit box (see the CSS); the 52 px size sits
// beside a 52 px pill button, as the recipe's heart; the 62 px size is the
// The 40 px size keeps a 44 px hit box (see the CSS); the 62 px size is the
// round action button with --shadow-md.
export function IconButton({
label,