Compare commits
5
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8fe68659bd | ||
|
|
104396da55 | ||
|
|
c8e5829a77 | ||
|
|
7a2f80cc09 | ||
|
|
3ba2be05e9 |
@@ -19,6 +19,7 @@ import (
|
|||||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/household"
|
"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/secretbox"
|
||||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/store"
|
"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"
|
"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.EnvOIDCAllowedGroups, "mealquest-admin")
|
||||||
t.Setenv(config.EnvGoogleClientID, "mealquest.apps.googleusercontent.com")
|
t.Setenv(config.EnvGoogleClientID, "mealquest.apps.googleusercontent.com")
|
||||||
t.Setenv(config.EnvGoogleClientSecretFile, googleSecretPath)
|
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.
|
// MEALQUEST_DEFAULT_TZ is left unset; t.Setenv restores any outer value.
|
||||||
t.Setenv(config.EnvDefaultTZ, "")
|
t.Setenv(config.EnvDefaultTZ, "")
|
||||||
if err := os.Unsetenv(config.EnvDefaultTZ); err != nil {
|
if err := os.Unsetenv(config.EnvDefaultTZ); err != nil {
|
||||||
|
|||||||
+29
-6
@@ -24,7 +24,9 @@ import (
|
|||||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/gcal"
|
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/gcal"
|
||||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/household"
|
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/household"
|
||||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/jobs"
|
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/jobs"
|
||||||
|
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/notify"
|
||||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/plan"
|
"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/recipes"
|
||||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/secretbox"
|
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/secretbox"
|
||||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/server"
|
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/server"
|
||||||
@@ -138,6 +140,23 @@ func run(cfg config.Config, logger *slog.Logger) error {
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pushSvc, err := push.NewService(db, cfg.VAPID.PublicKey)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
notifier, err := notify.NewService(db, logger)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
pushSender, err := push.NewSender(db, logger, cfg.VAPID.PublicKey, cfg.VAPID.PrivateKey.Reveal(), cfg.VAPID.Subject)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := notifier.Register(notify.ChannelPush, pushSender); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
sessions, err := auth.NewSessionManager(db, cfg.SessionIdle, cfg.SessionMax)
|
sessions, err := auth.NewSessionManager(db, cfg.SessionIdle, cfg.SessionMax)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
@@ -154,20 +173,24 @@ func run(cfg config.Config, logger *slog.Logger) error {
|
|||||||
return err
|
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 {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
// Start fails the jobs a previous process left running before any
|
// Start fails the jobs a previous process left running before any
|
||||||
// worker runs. The workers stop, and are waited for, before the
|
// worker runs. The job workers and the notification worker stop, and
|
||||||
// database closes.
|
// are waited for, before the database closes.
|
||||||
jobsCtx, stopJobs := context.WithCancel(ctx)
|
workersCtx, stopWorkers := context.WithCancel(ctx)
|
||||||
defer func() {
|
defer func() {
|
||||||
stopJobs()
|
stopWorkers()
|
||||||
runner.Wait()
|
runner.Wait()
|
||||||
|
notifier.Wait()
|
||||||
}()
|
}()
|
||||||
if err := runner.Start(jobsCtx); err != nil {
|
if err := runner.Start(workersCtx); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := notifier.Start(workersCtx); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
// With those jobs failed, a dinner still pending with no grounding job
|
// With those jobs failed, a dinner still pending with no grounding job
|
||||||
|
|||||||
@@ -0,0 +1,39 @@
|
|||||||
|
// Command vapidgen prints a new VAPID key pair for Web Push. The public key
|
||||||
|
// line goes into deploy/.env as is; the private key line goes, alone, into
|
||||||
|
// the secret file MEALQUEST_VAPID_PRIVATE_KEY_FILE names. Both keys are
|
||||||
|
// unpadded base64url P-256 keys, the encoding config.Load validates.
|
||||||
|
//
|
||||||
|
// go run ./cmd/vapidgen
|
||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"os"
|
||||||
|
|
||||||
|
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/config"
|
||||||
|
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/vapid"
|
||||||
|
)
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
if len(os.Args) > 1 {
|
||||||
|
fmt.Fprintln(os.Stderr, "usage: vapidgen (takes no arguments)")
|
||||||
|
os.Exit(2)
|
||||||
|
}
|
||||||
|
if err := run(os.Stdout); err != nil {
|
||||||
|
fmt.Fprintln(os.Stderr, err)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// run writes a new key pair to w in four lines: a comment and the public
|
||||||
|
// key's environment line, then a comment and the private key alone.
|
||||||
|
func run(w io.Writer) error {
|
||||||
|
pair, err := vapid.Generate()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
_, err = fmt.Fprintf(w, "# Environment (deploy/.env):\n%s=%s\n# Private key: this next line alone is the file %s names.\n%s\n",
|
||||||
|
config.EnvVAPIDPublicKey, pair.PublicKey, config.EnvVAPIDPrivateKeyFile, pair.PrivateKey)
|
||||||
|
return err
|
||||||
|
}
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/config"
|
||||||
|
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/vapid"
|
||||||
|
)
|
||||||
|
|
||||||
|
// TestRunPrintsAMatchingPair checks the output shape and that the printed
|
||||||
|
// private key line is a key config.Load accepts for the printed public key.
|
||||||
|
func TestRunPrintsAMatchingPair(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
var out strings.Builder
|
||||||
|
if err := run(&out); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
lines := strings.Split(strings.TrimSuffix(out.String(), "\n"), "\n")
|
||||||
|
if len(lines) != 4 || !strings.HasPrefix(lines[0], "# ") || !strings.HasPrefix(lines[2], "# ") {
|
||||||
|
t.Fatalf("output = %q, want comment, public key, comment, private key", out.String())
|
||||||
|
}
|
||||||
|
public, ok := strings.CutPrefix(lines[1], config.EnvVAPIDPublicKey+"=")
|
||||||
|
if !ok {
|
||||||
|
t.Fatalf("public key line = %q, want %s=<key>", lines[1], config.EnvVAPIDPublicKey)
|
||||||
|
}
|
||||||
|
pub, err := vapid.ParsePublicKey(public)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("printed public key: %v", err)
|
||||||
|
}
|
||||||
|
priv, err := vapid.ParsePrivateKey(lines[3])
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("printed private key: %v", err)
|
||||||
|
}
|
||||||
|
if err := vapid.CheckPair(pub, priv); err != nil {
|
||||||
|
t.Error(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
var second strings.Builder
|
||||||
|
if err := run(&second); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if second.String() == out.String() {
|
||||||
|
t.Error("two runs printed the same key pair")
|
||||||
|
}
|
||||||
|
}
|
||||||
+17
-11
@@ -6,7 +6,8 @@
|
|||||||
#
|
#
|
||||||
# Secrets are never set here. They live in deploy/secrets/ (never committed),
|
# 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
|
# 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
|
# 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
|
# the host account that runs compose, mode 0600. Creating them: CTXD project
|
||||||
# mealquest, RUNBOOKS.MD "Stack and first deploy" > "4. Secrets".
|
# 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.
|
# URI is <MEALQUEST_PUBLIC_URL origin>/api/v1/integrations/google/callback.
|
||||||
MEALQUEST_GOOGLE_CLIENT_ID=<client id>.apps.googleusercontent.com
|
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
|
# 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.
|
# after sign-in; MAX must be at least IDLE. Defaults: 720h and 4320h.
|
||||||
MEALQUEST_SESSION_IDLE=720h
|
MEALQUEST_SESSION_IDLE=720h
|
||||||
@@ -72,18 +86,10 @@ MEALQUEST_SESSION_MAX=4320h
|
|||||||
# The Authentik provider's client secret.
|
# The Authentik provider's client secret.
|
||||||
# MEALQUEST_GOOGLE_CLIENT_SECRET_FILE=/run/secrets/google_client_secret
|
# MEALQUEST_GOOGLE_CLIENT_SECRET_FILE=/run/secrets/google_client_secret
|
||||||
# The Google OAuth web client's 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
|
# MEALQUEST_SEARXNG_URL=http://searxng:8080
|
||||||
# The SearXNG recipe-search sidecar on the compose network. The Settings
|
# 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
|
# 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.
|
# provider and base URL come from PATCH /api/v1/settings/recipe-search.
|
||||||
# See CTXD project mealquest, RUNBOOKS.MD "Recipe search (SearXNG)".
|
# 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>
|
|
||||||
|
|||||||
@@ -31,11 +31,13 @@ services:
|
|||||||
MEALQUEST_SECRET_KEY_FILE: /run/secrets/secret_key
|
MEALQUEST_SECRET_KEY_FILE: /run/secrets/secret_key
|
||||||
MEALQUEST_OIDC_CLIENT_SECRET_FILE: /run/secrets/oidc_client_secret
|
MEALQUEST_OIDC_CLIENT_SECRET_FILE: /run/secrets/oidc_client_secret
|
||||||
MEALQUEST_GOOGLE_CLIENT_SECRET_FILE: /run/secrets/google_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
|
MEALQUEST_SEARXNG_URL: http://searxng:8080
|
||||||
secrets:
|
secrets:
|
||||||
- secret_key
|
- secret_key
|
||||||
- oidc_client_secret
|
- oidc_client_secret
|
||||||
- google_client_secret
|
- google_client_secret
|
||||||
|
- vapid_private_key
|
||||||
volumes:
|
volumes:
|
||||||
- mealquest-data:/data
|
- mealquest-data:/data
|
||||||
ports:
|
ports:
|
||||||
@@ -130,6 +132,8 @@ secrets:
|
|||||||
file: ./secrets/oidc_client_secret
|
file: ./secrets/oidc_client_secret
|
||||||
google_client_secret:
|
google_client_secret:
|
||||||
file: ./secrets/google_client_secret
|
file: ./secrets/google_client_secret
|
||||||
|
vapid_private_key:
|
||||||
|
file: ./secrets/vapid_private_key
|
||||||
|
|
||||||
volumes:
|
volumes:
|
||||||
mealquest-data:
|
mealquest-data:
|
||||||
|
|||||||
@@ -43,6 +43,7 @@ headers, so sign-in works whichever peer the request arrives from.
|
|||||||
| Path | Purpose |
|
| Path | Purpose |
|
||||||
| --- | --- |
|
| --- | --- |
|
||||||
| `cmd/server` | Entry point; wiring only. |
|
| `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/config` | Env-only `MEALQUEST_*` config; secrets from `*_FILE`. |
|
||||||
| `internal/server` | HTTP server, router, middleware. |
|
| `internal/server` | HTTP server, router, middleware. |
|
||||||
| `internal/frontend` | Embedded PWA, SPA fallback. |
|
| `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/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/suggest`, `internal/plan`, `internal/shopping`, `internal/ideas` | Feature services. |
|
||||||
| `internal/gcal`, `internal/notify`, `internal/push`, `internal/chat` | Integrations. |
|
| `internal/gcal`, `internal/notify`, `internal/push`, `internal/chat` | Integrations. |
|
||||||
|
| `internal/vapid` | VAPID key generation and validation. |
|
||||||
| `internal/jobs`, `internal/scheduler` | Background jobs and schedules. |
|
| `internal/jobs`, `internal/scheduler` | Background jobs and schedules. |
|
||||||
| `migrations/` | SQL migrations (no goose or golang-migrate). |
|
| `migrations/` | SQL migrations (no goose or golang-migrate). |
|
||||||
| `web/` | React PWA. |
|
| `web/` | React PWA. |
|
||||||
@@ -657,6 +659,31 @@ Runs are recorded in `schedule_runs` and executed through `jobs`.
|
|||||||
- Delivery goes through `notification_outbox`.
|
- Delivery goes through `notification_outbox`.
|
||||||
- Web Push uses VAPID (`webpush-go`). Google Chat uses the stored incoming
|
- Web Push uses VAPID (`webpush-go`). Google Chat uses the stored incoming
|
||||||
webhook.
|
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
|
## Google Calendar
|
||||||
|
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ module code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest
|
|||||||
go 1.25.0
|
go 1.25.0
|
||||||
|
|
||||||
require (
|
require (
|
||||||
|
github.com/SherClockHolmes/webpush-go v1.4.0
|
||||||
github.com/coreos/go-oidc/v3 v3.21.0
|
github.com/coreos/go-oidc/v3 v3.21.0
|
||||||
github.com/go-chi/chi/v5 v5.3.1
|
github.com/go-chi/chi/v5 v5.3.1
|
||||||
github.com/oklog/ulid/v2 v2.1.2
|
github.com/oklog/ulid/v2 v2.1.2
|
||||||
@@ -14,10 +15,12 @@ require (
|
|||||||
require (
|
require (
|
||||||
github.com/dustin/go-humanize v1.0.1 // indirect
|
github.com/dustin/go-humanize v1.0.1 // indirect
|
||||||
github.com/go-jose/go-jose/v4 v4.1.4 // indirect
|
github.com/go-jose/go-jose/v4 v4.1.4 // indirect
|
||||||
|
github.com/golang-jwt/jwt/v5 v5.2.1 // indirect
|
||||||
github.com/google/uuid v1.6.0 // indirect
|
github.com/google/uuid v1.6.0 // indirect
|
||||||
github.com/mattn/go-isatty v0.0.24 // indirect
|
github.com/mattn/go-isatty v0.0.24 // indirect
|
||||||
github.com/ncruces/go-strftime v1.0.0 // indirect
|
github.com/ncruces/go-strftime v1.0.0 // indirect
|
||||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
|
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
|
||||||
|
golang.org/x/crypto v0.31.0 // indirect
|
||||||
golang.org/x/sys v0.47.0 // indirect
|
golang.org/x/sys v0.47.0 // indirect
|
||||||
modernc.org/libc v1.75.7 // indirect
|
modernc.org/libc v1.75.7 // indirect
|
||||||
modernc.org/mathutil v1.7.1 // indirect
|
modernc.org/mathutil v1.7.1 // indirect
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
github.com/SherClockHolmes/webpush-go v1.4.0 h1:ocnzNKWN23T9nvHi6IfyrQjkIc0oJWv1B1pULsf9i3s=
|
||||||
|
github.com/SherClockHolmes/webpush-go v1.4.0/go.mod h1:XSq8pKX11vNV8MJEMwjrlTkxhAj1zKfxmyhdV7Pd6UA=
|
||||||
github.com/coreos/go-oidc/v3 v3.21.0 h1:wZo4Q9Pum8dYEj0eMUPrqR+kvuGkeUplbLpNCkBqoWM=
|
github.com/coreos/go-oidc/v3 v3.21.0 h1:wZo4Q9Pum8dYEj0eMUPrqR+kvuGkeUplbLpNCkBqoWM=
|
||||||
github.com/coreos/go-oidc/v3 v3.21.0/go.mod h1:DYCf24+ncYi+XkIH97GY1+dqoRlbaSI26KVTCI9SrY4=
|
github.com/coreos/go-oidc/v3 v3.21.0/go.mod h1:DYCf24+ncYi+XkIH97GY1+dqoRlbaSI26KVTCI9SrY4=
|
||||||
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
|
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
|
||||||
@@ -6,6 +8,9 @@ github.com/go-chi/chi/v5 v5.3.1 h1:3j4HZLGZQ3JpMCrPJF/Jl3mYJfWLKBfNJ6quurUGCf8=
|
|||||||
github.com/go-chi/chi/v5 v5.3.1/go.mod h1:R+tYY2hNuVUUjxoPtqUdgBqevM9s9njzkTLutVsOCto=
|
github.com/go-chi/chi/v5 v5.3.1/go.mod h1:R+tYY2hNuVUUjxoPtqUdgBqevM9s9njzkTLutVsOCto=
|
||||||
github.com/go-jose/go-jose/v4 v4.1.4 h1:moDMcTHmvE6Groj34emNPLs/qtYXRVcd6S7NHbHz3kA=
|
github.com/go-jose/go-jose/v4 v4.1.4 h1:moDMcTHmvE6Groj34emNPLs/qtYXRVcd6S7NHbHz3kA=
|
||||||
github.com/go-jose/go-jose/v4 v4.1.4/go.mod h1:x4oUasVrzR7071A4TnHLGSPpNOm2a21K9Kf04k1rs08=
|
github.com/go-jose/go-jose/v4 v4.1.4/go.mod h1:x4oUasVrzR7071A4TnHLGSPpNOm2a21K9Kf04k1rs08=
|
||||||
|
github.com/golang-jwt/jwt/v5 v5.2.1 h1:OuVbFODueb089Lh128TAcimifWaLhJwVflnrgM17wHk=
|
||||||
|
github.com/golang-jwt/jwt/v5 v5.2.1/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk=
|
||||||
|
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
|
||||||
github.com/google/pprof v0.0.0-20260802141513-ef3492d7dac3 h1:LMLX+LgTNWpfvCBdFebv6EsYotImrt/Ppc5cXIriCSo=
|
github.com/google/pprof v0.0.0-20260802141513-ef3492d7dac3 h1:LMLX+LgTNWpfvCBdFebv6EsYotImrt/Ppc5cXIriCSo=
|
||||||
github.com/google/pprof v0.0.0-20260802141513-ef3492d7dac3/go.mod h1:jl5iWTm0/hd5PjEYEOuwAJ57L/CibdZfrqZ5XA5GrCk=
|
github.com/google/pprof v0.0.0-20260802141513-ef3492d7dac3/go.mod h1:jl5iWTm0/hd5PjEYEOuwAJ57L/CibdZfrqZ5XA5GrCk=
|
||||||
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
||||||
@@ -21,18 +26,82 @@ github.com/oklog/ulid/v2 v2.1.2/go.mod h1:rcEKHmBBKfef9DhnvX7y1HZBYxjXb0cP5ExxNs
|
|||||||
github.com/pborman/getopt v0.0.0-20170112200414-7148bc3a4c30/go.mod h1:85jBQOZwpVEaDAr341tbn15RS4fCAsIst0qp7i8ex1o=
|
github.com/pborman/getopt v0.0.0-20170112200414-7148bc3a4c30/go.mod h1:85jBQOZwpVEaDAr341tbn15RS4fCAsIst0qp7i8ex1o=
|
||||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
|
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
|
||||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
|
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
|
||||||
|
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
|
||||||
|
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
|
||||||
|
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
|
||||||
|
golang.org/x/crypto v0.13.0/go.mod h1:y6Z2r+Rw4iayiXXAIxJIDAJ1zMW4yaTpebo8fPOliYc=
|
||||||
|
golang.org/x/crypto v0.19.0/go.mod h1:Iy9bg/ha4yyC70EfRS8jz+B6ybOBKMaSxLj6P6oBDfU=
|
||||||
|
golang.org/x/crypto v0.23.0/go.mod h1:CKFgDieR+mRhux2Lsu27y0fO304Db0wZe70UKqHu0v8=
|
||||||
|
golang.org/x/crypto v0.31.0 h1:ihbySMvVjLAeSH1IbfcRTkD/iNscyz8rGzjF/E5hV6U=
|
||||||
|
golang.org/x/crypto v0.31.0/go.mod h1:kDsLvtWBEx7MV9tJOj9bnXsPbxwJQ6csT/x4KIN4Ssk=
|
||||||
golang.org/x/image v0.45.0 h1:FMb1nTbH5H9vF55SriQHgFw5GnNL9Jg6L25BwXKzhB0=
|
golang.org/x/image v0.45.0 h1:FMb1nTbH5H9vF55SriQHgFw5GnNL9Jg6L25BwXKzhB0=
|
||||||
golang.org/x/image v0.45.0/go.mod h1:n62x/7RqlwXDvGsSU4u6IUTUf6KghUZ9Bt7cG/T9Fx4=
|
golang.org/x/image v0.45.0/go.mod h1:n62x/7RqlwXDvGsSU4u6IUTUf6KghUZ9Bt7cG/T9Fx4=
|
||||||
|
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
|
||||||
|
golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
|
||||||
|
golang.org/x/mod v0.12.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
|
||||||
|
golang.org/x/mod v0.15.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c=
|
||||||
|
golang.org/x/mod v0.17.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c=
|
||||||
golang.org/x/mod v0.38.0 h1:MECBjubtXD7yj4HrhIUcywNaGeNVUdfVnxmPajOk4yk=
|
golang.org/x/mod v0.38.0 h1:MECBjubtXD7yj4HrhIUcywNaGeNVUdfVnxmPajOk4yk=
|
||||||
golang.org/x/mod v0.38.0/go.mod h1:V6Xz0pq8TQ3dGqVQ1FVHuelZpAL0uNhSkk9ogYP3c40=
|
golang.org/x/mod v0.38.0/go.mod h1:V6Xz0pq8TQ3dGqVQ1FVHuelZpAL0uNhSkk9ogYP3c40=
|
||||||
|
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
|
||||||
|
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
|
||||||
|
golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
|
||||||
|
golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
|
||||||
|
golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg=
|
||||||
|
golang.org/x/net v0.15.0/go.mod h1:idbUs1IY1+zTqbi8yxTbhexhEEk5ur9LInksu6HrEpk=
|
||||||
|
golang.org/x/net v0.21.0/go.mod h1:bIjVDfnllIU7BJ2DNgfnXvpSvtn8VRwhlsaeUTyUS44=
|
||||||
|
golang.org/x/net v0.25.0/go.mod h1:JkAGAh7GEvH74S6FOH42FLoXpXbE/aqXSrIQjXgsiwM=
|
||||||
golang.org/x/oauth2 v0.36.0 h1:peZ/1z27fi9hUOFCAZaHyrpWG5lwe0RJEEEeH0ThlIs=
|
golang.org/x/oauth2 v0.36.0 h1:peZ/1z27fi9hUOFCAZaHyrpWG5lwe0RJEEEeH0ThlIs=
|
||||||
golang.org/x/oauth2 v0.36.0/go.mod h1:YDBUJMTkDnJS+A4BP4eZBjCqtokkg1hODuPjwiGPO7Q=
|
golang.org/x/oauth2 v0.36.0/go.mod h1:YDBUJMTkDnJS+A4BP4eZBjCqtokkg1hODuPjwiGPO7Q=
|
||||||
|
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||||
|
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||||
|
golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||||
|
golang.org/x/sync v0.3.0/go.mod h1:FU7BRWz2tNW+3quACPkgCx/L+uEAv1htQ0V83Z9Rj+Y=
|
||||||
|
golang.org/x/sync v0.6.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
|
||||||
|
golang.org/x/sync v0.7.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
|
||||||
|
golang.org/x/sync v0.10.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
|
||||||
golang.org/x/sync v0.22.0 h1:SZjpbeLmrCk4xhRSZFNZW5gFUeCeFgjekvI/+gfScek=
|
golang.org/x/sync v0.22.0 h1:SZjpbeLmrCk4xhRSZFNZW5gFUeCeFgjekvI/+gfScek=
|
||||||
golang.org/x/sync v0.22.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
|
golang.org/x/sync v0.22.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
|
||||||
|
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||||
|
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||||
|
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
|
golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
|
golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
|
golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
|
golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
|
golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
|
golang.org/x/sys v0.17.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||||
|
golang.org/x/sys v0.20.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||||
|
golang.org/x/sys v0.28.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||||
golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs=
|
golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs=
|
||||||
golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
|
golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
|
||||||
|
golang.org/x/telemetry v0.0.0-20240228155512-f48c80bd79b2/go.mod h1:TeRTkGYfJXctD9OcfyVLyj2J3IxLnKwHJR8f4D8a3YE=
|
||||||
|
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
|
||||||
|
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
|
||||||
|
golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k=
|
||||||
|
golang.org/x/term v0.8.0/go.mod h1:xPskH00ivmX89bAKVGSKKtLOWNx2+17Eiy94tnKShWo=
|
||||||
|
golang.org/x/term v0.12.0/go.mod h1:owVbMEjm3cBLCHdkQu9b1opXd4ETQWc3BhuQGKgXgvU=
|
||||||
|
golang.org/x/term v0.17.0/go.mod h1:lLRBjIVuehSbZlaOtGMbcMncT+aqLLLmKrsjNrUguwk=
|
||||||
|
golang.org/x/term v0.20.0/go.mod h1:8UkIAJTvZgivsXaD6/pH6U9ecQzZ45awqEOzuCvwpFY=
|
||||||
|
golang.org/x/term v0.27.0/go.mod h1:iMsnZpn0cago0GOrHO2+Y7u7JPn5AylBrcoWkElMTSM=
|
||||||
|
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
|
||||||
|
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
||||||
|
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
|
||||||
|
golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
|
||||||
|
golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8=
|
||||||
|
golang.org/x/text v0.13.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE=
|
||||||
|
golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
|
||||||
|
golang.org/x/text v0.15.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
|
||||||
|
golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ=
|
||||||
|
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
||||||
|
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
|
||||||
|
golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
|
||||||
|
golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU=
|
||||||
|
golang.org/x/tools v0.13.0/go.mod h1:HvlwmtVNQAhOuCjW7xxvovg8wbNq7LwfXh/k7wXUl58=
|
||||||
|
golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d/go.mod h1:aiJjzUbINMkxbQROHiO6hDPo2LHcIPhhQsa9DLh0yGk=
|
||||||
golang.org/x/tools v0.48.0 h1:3+hClM1aLL5mjMKm5ovokw9epgRXPuu2tILgismM6RE=
|
golang.org/x/tools v0.48.0 h1:3+hClM1aLL5mjMKm5ovokw9epgRXPuu2tILgismM6RE=
|
||||||
golang.org/x/tools v0.48.0/go.mod h1:08xX0orndb/F7jJxGDicx061tyd5pcMto75YMAXr6lk=
|
golang.org/x/tools v0.48.0/go.mod h1:08xX0orndb/F7jJxGDicx061tyd5pcMto75YMAXr6lk=
|
||||||
|
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||||
modernc.org/cc/v4 v4.29.2 h1:h6+9ciCnPKutf4I03CvheAvDLX7+IHlqR6Iy6J+cgd8=
|
modernc.org/cc/v4 v4.29.2 h1:h6+9ciCnPKutf4I03CvheAvDLX7+IHlqR6Iy6J+cgd8=
|
||||||
modernc.org/cc/v4 v4.29.2/go.mod h1:OnovgIhbbMXMu1aISnJ0wvVD1KnW+cAUJkIrAWh+kVI=
|
modernc.org/cc/v4 v4.29.2/go.mod h1:OnovgIhbbMXMu1aISnJ0wvVD1KnW+cAUJkIrAWh+kVI=
|
||||||
modernc.org/ccgo/v4 v4.35.0 h1:F+TUsmw09QxLzmi3aeYYGxjAXarmZaKgj3mKQHNaA8w=
|
modernc.org/ccgo/v4 v4.35.0 h1:F+TUsmw09QxLzmi3aeYYGxjAXarmZaKgj3mKQHNaA8w=
|
||||||
|
|||||||
@@ -3,9 +3,11 @@
|
|||||||
package config
|
package config
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"crypto/ecdh"
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"net"
|
"net"
|
||||||
|
"net/mail"
|
||||||
"net/url"
|
"net/url"
|
||||||
"os"
|
"os"
|
||||||
"strconv"
|
"strconv"
|
||||||
@@ -14,6 +16,7 @@ import (
|
|||||||
|
|
||||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/forwarded"
|
"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/secureurl"
|
||||||
|
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/vapid"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Environment variable names.
|
// Environment variable names.
|
||||||
@@ -32,6 +35,10 @@ const (
|
|||||||
EnvGoogleClientID = "MEALQUEST_GOOGLE_CLIENT_ID"
|
EnvGoogleClientID = "MEALQUEST_GOOGLE_CLIENT_ID"
|
||||||
EnvGoogleClientSecretFile = "MEALQUEST_GOOGLE_CLIENT_SECRET_FILE"
|
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"
|
EnvSessionIdle = "MEALQUEST_SESSION_IDLE"
|
||||||
EnvSessionMax = "MEALQUEST_SESSION_MAX"
|
EnvSessionMax = "MEALQUEST_SESSION_MAX"
|
||||||
|
|
||||||
@@ -70,6 +77,9 @@ type Config struct {
|
|||||||
GoogleClientID string
|
GoogleClientID string
|
||||||
GoogleClientSecret Secret
|
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 ends a session left unused this long.
|
||||||
SessionIdle time.Duration
|
SessionIdle time.Duration
|
||||||
// SessionMax ends every session this long after sign-in, used or not.
|
// SessionMax ends every session this long after sign-in, used or not.
|
||||||
@@ -93,6 +103,20 @@ type Config struct {
|
|||||||
SearXNGURL string
|
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
|
// PublicOrigin returns the scheme and host of PublicURL, the only parts of it
|
||||||
// that are safe to log.
|
// that are safe to log.
|
||||||
func (c Config) PublicOrigin() string {
|
func (c Config) PublicOrigin() string {
|
||||||
@@ -122,6 +146,8 @@ func load(lookup func(string) (string, bool)) (Config, error) {
|
|||||||
GoogleClientID: r.str(EnvGoogleClientID),
|
GoogleClientID: r.str(EnvGoogleClientID),
|
||||||
GoogleClientSecret: r.secretFile(EnvGoogleClientSecretFile),
|
GoogleClientSecret: r.secretFile(EnvGoogleClientSecretFile),
|
||||||
|
|
||||||
|
VAPID: r.vapid(),
|
||||||
|
|
||||||
SessionIdle: r.sessionDuration(EnvSessionIdle, DefaultSessionIdle),
|
SessionIdle: r.sessionDuration(EnvSessionIdle, DefaultSessionIdle),
|
||||||
SessionMax: r.sessionDuration(EnvSessionMax, DefaultSessionMax),
|
SessionMax: r.sessionDuration(EnvSessionMax, DefaultSessionMax),
|
||||||
|
|
||||||
@@ -387,6 +413,61 @@ func (r *reader) secretFile(name string) Secret {
|
|||||||
return Secret{value: value}
|
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 {
|
func unwrapPathError(err error) error {
|
||||||
var pe *os.PathError
|
var pe *os.PathError
|
||||||
if errors.As(err, &pe) {
|
if errors.As(err, &pe) {
|
||||||
|
|||||||
@@ -14,8 +14,24 @@ import (
|
|||||||
"time"
|
"time"
|
||||||
|
|
||||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/forwarded"
|
"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) {
|
func TestLoad(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
@@ -37,6 +53,16 @@ func TestLoad(t *testing.T) {
|
|||||||
if err := os.WriteFile(emptyPath, []byte("\n"), 0o600); err != nil {
|
if err := os.WriteFile(emptyPath, []byte("\n"), 0o600); err != nil {
|
||||||
t.Fatal(err)
|
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 {
|
valid := func() map[string]string {
|
||||||
return map[string]string{
|
return map[string]string{
|
||||||
@@ -53,6 +79,10 @@ func TestLoad(t *testing.T) {
|
|||||||
|
|
||||||
EnvGoogleClientID: "mealquest.apps.googleusercontent.com",
|
EnvGoogleClientID: "mealquest.apps.googleusercontent.com",
|
||||||
EnvGoogleClientSecretFile: googleSecretPath,
|
EnvGoogleClientSecretFile: googleSecretPath,
|
||||||
|
|
||||||
|
EnvVAPIDPublicKey: vapidPair.PublicKey,
|
||||||
|
EnvVAPIDPrivateKeyFile: vapidKeyPath,
|
||||||
|
EnvVAPIDSubject: "mailto:owner@example.com",
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
with := func(changes map[string]string, unset ...string) map[string]string {
|
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,
|
EnvHTTPAddr, EnvPublicURL, EnvDataDir, EnvShutdownTimeout, EnvSecretKeyFile,
|
||||||
EnvOIDCIssuer, EnvOIDCClientID, EnvOIDCClientSecretFile, EnvOIDCAllowedGroups,
|
EnvOIDCIssuer, EnvOIDCClientID, EnvOIDCClientSecretFile, EnvOIDCAllowedGroups,
|
||||||
EnvGoogleClientID, EnvGoogleClientSecretFile,
|
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",
|
name: "invalid duration",
|
||||||
env: with(map[string]string{EnvShutdownTimeout: "ten seconds"}),
|
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 {
|
if err := os.WriteFile(googleSecretPath, []byte("google-s3cret\n"), 0o600); err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
|
vapidPair, vapidKeyPath := writeVAPIDKey(t, dir, "vapid_private_key")
|
||||||
env := map[string]string{
|
env := map[string]string{
|
||||||
EnvHTTPAddr: "127.0.0.1:9000",
|
EnvHTTPAddr: "127.0.0.1:9000",
|
||||||
EnvPublicURL: "http://localhost:9000",
|
EnvPublicURL: "http://localhost:9000",
|
||||||
@@ -461,6 +592,10 @@ func TestLoadValues(t *testing.T) {
|
|||||||
EnvGoogleClientID: "mealquest.apps.googleusercontent.com",
|
EnvGoogleClientID: "mealquest.apps.googleusercontent.com",
|
||||||
EnvGoogleClientSecretFile: googleSecretPath,
|
EnvGoogleClientSecretFile: googleSecretPath,
|
||||||
|
|
||||||
|
EnvVAPIDPublicKey: vapidPair.PublicKey,
|
||||||
|
EnvVAPIDPrivateKeyFile: vapidKeyPath,
|
||||||
|
EnvVAPIDSubject: "mailto:owner@example.com",
|
||||||
|
|
||||||
EnvDefaultTZ: "Europe/London",
|
EnvDefaultTZ: "Europe/London",
|
||||||
|
|
||||||
EnvSearXNGURL: "http://searxng:8080",
|
EnvSearXNGURL: "http://searxng:8080",
|
||||||
@@ -500,6 +635,9 @@ func TestLoadValues(t *testing.T) {
|
|||||||
if cfg.GoogleClientSecret.Reveal() != "google-s3cret" {
|
if cfg.GoogleClientSecret.Reveal() != "google-s3cret" {
|
||||||
t.Errorf("GoogleClientSecret.Reveal() = %q", cfg.GoogleClientSecret.Reveal())
|
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) {
|
if want := []string{"mealquest-admin", "family"}; !slices.Equal(cfg.OIDCAllowedGroups, want) {
|
||||||
t.Errorf("OIDCAllowedGroups = %q, want %q", 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"}
|
s := Secret{value: "s3cret-value"}
|
||||||
for _, format := range []string{"%v", "%s", "%+v", "%#v"} {
|
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)} {
|
for _, out := range []string{fmt.Sprintf(format, s), fmt.Sprintf(format, cfg)} {
|
||||||
if strings.Contains(out, "s3cret-value") {
|
if strings.Contains(out, "s3cret-value") {
|
||||||
t.Errorf("format %s leaked secret: %s", format, out)
|
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) {
|
func TestPublicURLWithCredentialsRejected(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,202 @@
|
|||||||
|
// Code generated by sqlc. DO NOT EDIT.
|
||||||
|
// versions:
|
||||||
|
// sqlc v1.31.1
|
||||||
|
// source: notifications.sql
|
||||||
|
|
||||||
|
package db
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"database/sql"
|
||||||
|
)
|
||||||
|
|
||||||
|
const claimDueNotification = `-- name: ClaimDueNotification :one
|
||||||
|
UPDATE notification_outbox
|
||||||
|
SET next_attempt_at = ?1, updated_at = ?2
|
||||||
|
WHERE id = (
|
||||||
|
SELECT id FROM notification_outbox
|
||||||
|
WHERE status = 'pending' AND next_attempt_at <= ?2
|
||||||
|
ORDER BY next_attempt_at, id
|
||||||
|
LIMIT 1
|
||||||
|
)
|
||||||
|
RETURNING id, event_key, channel, payload, status, attempts, next_attempt_at, last_error, created_at, updated_at
|
||||||
|
`
|
||||||
|
|
||||||
|
type ClaimDueNotificationParams struct {
|
||||||
|
LeaseUntil sql.NullString
|
||||||
|
Now string
|
||||||
|
}
|
||||||
|
|
||||||
|
// Leases the pending notification due longest ago by moving its
|
||||||
|
// next_attempt_at to lease_until, and returns it. A claimed row is not due
|
||||||
|
// again until the lease ends, so no other claim takes it while it is being
|
||||||
|
// delivered. Returns sql.ErrNoRows when none is due.
|
||||||
|
func (q *Queries) ClaimDueNotification(ctx context.Context, arg ClaimDueNotificationParams) (NotificationOutbox, error) {
|
||||||
|
row := q.db.QueryRowContext(ctx, claimDueNotification, arg.LeaseUntil, arg.Now)
|
||||||
|
var i NotificationOutbox
|
||||||
|
err := row.Scan(
|
||||||
|
&i.ID,
|
||||||
|
&i.EventKey,
|
||||||
|
&i.Channel,
|
||||||
|
&i.Payload,
|
||||||
|
&i.Status,
|
||||||
|
&i.Attempts,
|
||||||
|
&i.NextAttemptAt,
|
||||||
|
&i.LastError,
|
||||||
|
&i.CreatedAt,
|
||||||
|
&i.UpdatedAt,
|
||||||
|
)
|
||||||
|
return i, err
|
||||||
|
}
|
||||||
|
|
||||||
|
const failNotification = `-- name: FailNotification :execrows
|
||||||
|
UPDATE notification_outbox
|
||||||
|
SET status = 'failed', attempts = attempts + ?1, next_attempt_at = NULL, last_error = ?2, updated_at = ?3
|
||||||
|
WHERE id = ?4 AND status = 'pending' AND next_attempt_at = ?5
|
||||||
|
`
|
||||||
|
|
||||||
|
type FailNotificationParams struct {
|
||||||
|
Attempted int64
|
||||||
|
LastError sql.NullString
|
||||||
|
Now string
|
||||||
|
ID string
|
||||||
|
LeaseUntil sql.NullString
|
||||||
|
}
|
||||||
|
|
||||||
|
// Gives up on a notification. attempts counts the delivery attempt that
|
||||||
|
// failed, if one was made. The lease must still be the claim's.
|
||||||
|
func (q *Queries) FailNotification(ctx context.Context, arg FailNotificationParams) (int64, error) {
|
||||||
|
result, err := q.db.ExecContext(ctx, failNotification,
|
||||||
|
arg.Attempted,
|
||||||
|
arg.LastError,
|
||||||
|
arg.Now,
|
||||||
|
arg.ID,
|
||||||
|
arg.LeaseUntil,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
return result.RowsAffected()
|
||||||
|
}
|
||||||
|
|
||||||
|
const getNotificationPref = `-- name: GetNotificationPref :one
|
||||||
|
SELECT event, push, chat, created_at, updated_at FROM notification_prefs
|
||||||
|
WHERE event = ?
|
||||||
|
`
|
||||||
|
|
||||||
|
func (q *Queries) GetNotificationPref(ctx context.Context, event string) (NotificationPref, error) {
|
||||||
|
row := q.db.QueryRowContext(ctx, getNotificationPref, event)
|
||||||
|
var i NotificationPref
|
||||||
|
err := row.Scan(
|
||||||
|
&i.Event,
|
||||||
|
&i.Push,
|
||||||
|
&i.Chat,
|
||||||
|
&i.CreatedAt,
|
||||||
|
&i.UpdatedAt,
|
||||||
|
)
|
||||||
|
return i, err
|
||||||
|
}
|
||||||
|
|
||||||
|
const insertNotification = `-- name: InsertNotification :execrows
|
||||||
|
INSERT INTO notification_outbox (
|
||||||
|
id, event_key, channel, payload, status, attempts, next_attempt_at, created_at, updated_at
|
||||||
|
) VALUES (
|
||||||
|
?1, ?2, ?3, ?4, 'pending', 0, ?5, ?6, ?6
|
||||||
|
)
|
||||||
|
ON CONFLICT (event_key, channel) DO NOTHING
|
||||||
|
`
|
||||||
|
|
||||||
|
type InsertNotificationParams struct {
|
||||||
|
ID string
|
||||||
|
EventKey string
|
||||||
|
Channel string
|
||||||
|
Payload string
|
||||||
|
NextAttemptAt sql.NullString
|
||||||
|
Now string
|
||||||
|
}
|
||||||
|
|
||||||
|
// Stores a pending notification for one channel, due at once. A row already
|
||||||
|
// stored for the event key and channel, in any status, is left unchanged and
|
||||||
|
// no row is inserted.
|
||||||
|
func (q *Queries) InsertNotification(ctx context.Context, arg InsertNotificationParams) (int64, error) {
|
||||||
|
result, err := q.db.ExecContext(ctx, insertNotification,
|
||||||
|
arg.ID,
|
||||||
|
arg.EventKey,
|
||||||
|
arg.Channel,
|
||||||
|
arg.Payload,
|
||||||
|
arg.NextAttemptAt,
|
||||||
|
arg.Now,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
return result.RowsAffected()
|
||||||
|
}
|
||||||
|
|
||||||
|
const markNotificationSent = `-- name: MarkNotificationSent :execrows
|
||||||
|
UPDATE notification_outbox
|
||||||
|
SET status = 'sent', attempts = attempts + 1, next_attempt_at = NULL, updated_at = ?1
|
||||||
|
WHERE id = ?2 AND status = 'pending' AND next_attempt_at = ?3
|
||||||
|
`
|
||||||
|
|
||||||
|
type MarkNotificationSentParams struct {
|
||||||
|
Now string
|
||||||
|
ID string
|
||||||
|
LeaseUntil sql.NullString
|
||||||
|
}
|
||||||
|
|
||||||
|
// Records a delivered notification; updated_at is when it was sent. The
|
||||||
|
// lease must still be the claim's.
|
||||||
|
func (q *Queries) MarkNotificationSent(ctx context.Context, arg MarkNotificationSentParams) (int64, error) {
|
||||||
|
result, err := q.db.ExecContext(ctx, markNotificationSent, arg.Now, arg.ID, arg.LeaseUntil)
|
||||||
|
if err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
return result.RowsAffected()
|
||||||
|
}
|
||||||
|
|
||||||
|
const nextNotificationDue = `-- name: NextNotificationDue :one
|
||||||
|
SELECT next_attempt_at FROM notification_outbox
|
||||||
|
WHERE status = 'pending'
|
||||||
|
ORDER BY next_attempt_at
|
||||||
|
LIMIT 1
|
||||||
|
`
|
||||||
|
|
||||||
|
// Returns when the next pending notification is due. Returns sql.ErrNoRows
|
||||||
|
// when none is pending.
|
||||||
|
func (q *Queries) NextNotificationDue(ctx context.Context) (sql.NullString, error) {
|
||||||
|
row := q.db.QueryRowContext(ctx, nextNotificationDue)
|
||||||
|
var next_attempt_at sql.NullString
|
||||||
|
err := row.Scan(&next_attempt_at)
|
||||||
|
return next_attempt_at, err
|
||||||
|
}
|
||||||
|
|
||||||
|
const retryNotification = `-- name: RetryNotification :execrows
|
||||||
|
UPDATE notification_outbox
|
||||||
|
SET attempts = attempts + 1, next_attempt_at = ?1, last_error = ?2, updated_at = ?3
|
||||||
|
WHERE id = ?4 AND status = 'pending' AND next_attempt_at = ?5
|
||||||
|
`
|
||||||
|
|
||||||
|
type RetryNotificationParams struct {
|
||||||
|
NextAttemptAt sql.NullString
|
||||||
|
LastError sql.NullString
|
||||||
|
Now string
|
||||||
|
ID string
|
||||||
|
LeaseUntil sql.NullString
|
||||||
|
}
|
||||||
|
|
||||||
|
// Records a failed delivery and when to try again. The lease must still be
|
||||||
|
// the claim's.
|
||||||
|
func (q *Queries) RetryNotification(ctx context.Context, arg RetryNotificationParams) (int64, error) {
|
||||||
|
result, err := q.db.ExecContext(ctx, retryNotification,
|
||||||
|
arg.NextAttemptAt,
|
||||||
|
arg.LastError,
|
||||||
|
arg.Now,
|
||||||
|
arg.ID,
|
||||||
|
arg.LeaseUntil,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
return result.RowsAffected()
|
||||||
|
}
|
||||||
@@ -0,0 +1,160 @@
|
|||||||
|
// 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 deletePushSubscriptionByID = `-- name: DeletePushSubscriptionByID :exec
|
||||||
|
DELETE FROM push_subscriptions
|
||||||
|
WHERE id = ?
|
||||||
|
`
|
||||||
|
|
||||||
|
// Removes a subscription the push service reports gone (404 or 410).
|
||||||
|
func (q *Queries) DeletePushSubscriptionByID(ctx context.Context, id string) error {
|
||||||
|
_, err := q.db.ExecContext(ctx, deletePushSubscriptionByID, id)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
const listPushSubscriptions = `-- name: ListPushSubscriptions :many
|
||||||
|
SELECT id, endpoint, p256dh, auth
|
||||||
|
FROM push_subscriptions
|
||||||
|
ORDER BY created_at, id
|
||||||
|
`
|
||||||
|
|
||||||
|
type ListPushSubscriptionsRow struct {
|
||||||
|
ID string
|
||||||
|
Endpoint string
|
||||||
|
P256dh string
|
||||||
|
Auth string
|
||||||
|
}
|
||||||
|
|
||||||
|
// Every stored subscription with its keys, for the push sender, oldest
|
||||||
|
// first.
|
||||||
|
func (q *Queries) ListPushSubscriptions(ctx context.Context) ([]ListPushSubscriptionsRow, error) {
|
||||||
|
rows, err := q.db.QueryContext(ctx, listPushSubscriptions)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
var items []ListPushSubscriptionsRow
|
||||||
|
for rows.Next() {
|
||||||
|
var i ListPushSubscriptionsRow
|
||||||
|
if err := rows.Scan(
|
||||||
|
&i.ID,
|
||||||
|
&i.Endpoint,
|
||||||
|
&i.P256dh,
|
||||||
|
&i.Auth,
|
||||||
|
); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
items = append(items, i)
|
||||||
|
}
|
||||||
|
if err := rows.Close(); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if err := rows.Err(); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return items, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
const recordPushFailure = `-- name: RecordPushFailure :exec
|
||||||
|
UPDATE push_subscriptions
|
||||||
|
SET failure_count = failure_count + 1
|
||||||
|
WHERE id = ?
|
||||||
|
`
|
||||||
|
|
||||||
|
// Counts a delivery to a subscription that the push service did not accept.
|
||||||
|
func (q *Queries) RecordPushFailure(ctx context.Context, id string) error {
|
||||||
|
_, err := q.db.ExecContext(ctx, recordPushFailure, id)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
const recordPushSuccess = `-- name: RecordPushSuccess :exec
|
||||||
|
UPDATE push_subscriptions
|
||||||
|
SET last_success_at = ?
|
||||||
|
WHERE id = ?
|
||||||
|
`
|
||||||
|
|
||||||
|
type RecordPushSuccessParams struct {
|
||||||
|
LastSuccessAt sql.NullString
|
||||||
|
ID string
|
||||||
|
}
|
||||||
|
|
||||||
|
// Records that the push service accepted a notification for a subscription.
|
||||||
|
func (q *Queries) RecordPushSuccess(ctx context.Context, arg RecordPushSuccessParams) error {
|
||||||
|
_, err := q.db.ExecContext(ctx, recordPushSuccess, arg.LastSuccessAt, arg.ID)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
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
|
||||||
|
}
|
||||||
@@ -12,6 +12,11 @@ import (
|
|||||||
type Querier interface {
|
type Querier interface {
|
||||||
// An unknown id returns sql.ErrNoRows.
|
// An unknown id returns sql.ErrNoRows.
|
||||||
ArchiveDinner(ctx context.Context, arg ArchiveDinnerParams) (Dinner, error)
|
ArchiveDinner(ctx context.Context, arg ArchiveDinnerParams) (Dinner, error)
|
||||||
|
// Leases the pending notification due longest ago by moving its
|
||||||
|
// next_attempt_at to lease_until, and returns it. A claimed row is not due
|
||||||
|
// again until the lease ends, so no other claim takes it while it is being
|
||||||
|
// delivered. Returns sql.ErrNoRows when none is due.
|
||||||
|
ClaimDueNotification(ctx context.Context, arg ClaimDueNotificationParams) (NotificationOutbox, error)
|
||||||
// Marks the oldest queued job running and returns it. Returns sql.ErrNoRows
|
// Marks the oldest queued job running and returns it. Returns sql.ErrNoRows
|
||||||
// when no job is queued.
|
// when no job is queued.
|
||||||
ClaimNextJob(ctx context.Context, now sql.NullString) (Job, error)
|
ClaimNextJob(ctx context.Context, now sql.NullString) (Job, error)
|
||||||
@@ -62,11 +67,17 @@ type Querier interface {
|
|||||||
DecideSuggestion(ctx context.Context, arg DecideSuggestionParams) (int64, error)
|
DecideSuggestion(ctx context.Context, arg DecideSuggestionParams) (int64, error)
|
||||||
DeleteAIRunsBefore(ctx context.Context, cutoff string) (int64, error)
|
DeleteAIRunsBefore(ctx context.Context, cutoff string) (int64, error)
|
||||||
DeleteIntegrationsGoogle(ctx context.Context) error
|
DeleteIntegrationsGoogle(ctx context.Context) error
|
||||||
|
DeletePushSubscription(ctx context.Context, endpoint string) (int64, error)
|
||||||
|
// Removes a subscription the push service reports gone (404 or 410).
|
||||||
|
DeletePushSubscriptionByID(ctx context.Context, id string) error
|
||||||
DeleteRecipeIngredients(ctx context.Context, recipeID string) error
|
DeleteRecipeIngredients(ctx context.Context, recipeID string) error
|
||||||
DeleteRecipeSteps(ctx context.Context, recipeID string) error
|
DeleteRecipeSteps(ctx context.Context, recipeID string) error
|
||||||
DeleteShoppingGroup(ctx context.Context, id string) error
|
DeleteShoppingGroup(ctx context.Context, id string) error
|
||||||
DeleteShoppingItem(ctx context.Context, id string) error
|
DeleteShoppingItem(ctx context.Context, id string) error
|
||||||
FailJob(ctx context.Context, arg FailJobParams) (int64, error)
|
FailJob(ctx context.Context, arg FailJobParams) (int64, error)
|
||||||
|
// Gives up on a notification. attempts counts the delivery attempt that
|
||||||
|
// failed, if one was made. The lease must still be the claim's.
|
||||||
|
FailNotification(ctx context.Context, arg FailNotificationParams) (int64, error)
|
||||||
// Fails every unarchived dinner still pending whose grounding job for its
|
// Fails every unarchived dinner still pending whose grounding job for its
|
||||||
// current grounding_revision is no longer queued or running: the process
|
// current grounding_revision is no longer queued or running: the process
|
||||||
// stopped before the job, or before its handler recorded a result. An
|
// stopped before the job, or before its handler recorded a result. An
|
||||||
@@ -111,6 +122,7 @@ type Querier interface {
|
|||||||
// The newest batch, whatever its status. Returns sql.ErrNoRows when there
|
// The newest batch, whatever its status. Returns sql.ErrNoRows when there
|
||||||
// is none.
|
// is none.
|
||||||
GetLatestSuggestionBatch(ctx context.Context) (SuggestionBatch, error)
|
GetLatestSuggestionBatch(ctx context.Context) (SuggestionBatch, error)
|
||||||
|
GetNotificationPref(ctx context.Context, event string) (NotificationPref, error)
|
||||||
// The pending Rotation check of dinner_id. MarkTried makes one only for a
|
// The pending Rotation check of dinner_id. MarkTried makes one only for a
|
||||||
// dinner in the Quest log, which it moves to tried, so a dinner has at
|
// dinner in the Quest log, which it moves to tried, so a dinner has at
|
||||||
// most one. Returns sql.ErrNoRows when there is none.
|
// most one. Returns sql.ErrNoRows when there is none.
|
||||||
@@ -152,6 +164,10 @@ type Querier interface {
|
|||||||
// them only when a member edited them while it was grounding. Like
|
// them only when a member edited them while it was grounding. Like
|
||||||
// GroundDinner it clears the pasted recipe_url: the recipe replaces the link.
|
// GroundDinner it clears the pasted recipe_url: the recipe replaces the link.
|
||||||
GroundPendingDinner(ctx context.Context, arg GroundPendingDinnerParams) (int64, error)
|
GroundPendingDinner(ctx context.Context, arg GroundPendingDinnerParams) (int64, error)
|
||||||
|
// Stores a pending notification for one channel, due at once. A row already
|
||||||
|
// stored for the event key and channel, in any status, is left unchanged and
|
||||||
|
// no row is inserted.
|
||||||
|
InsertNotification(ctx context.Context, arg InsertNotificationParams) (int64, error)
|
||||||
// The pending suggestions whose dinner is still suggested, oldest first,
|
// The pending suggestions whose dinner is still suggested, oldest first,
|
||||||
// with their dinners.
|
// with their dinners.
|
||||||
ListDeck(ctx context.Context) ([]ListDeckRow, error)
|
ListDeck(ctx context.Context) ([]ListDeckRow, error)
|
||||||
@@ -174,6 +190,9 @@ type Querier interface {
|
|||||||
// The planned dinners dated from from_date up to but not including
|
// The planned dinners dated from from_date up to but not including
|
||||||
// to_date, oldest first. Days with no dinner are left out.
|
// to_date, oldest first. Days with no dinner are left out.
|
||||||
ListPlannedDinners(ctx context.Context, arg ListPlannedDinnersParams) ([]ListPlannedDinnersRow, error)
|
ListPlannedDinners(ctx context.Context, arg ListPlannedDinnersParams) ([]ListPlannedDinnersRow, error)
|
||||||
|
// Every stored subscription with its keys, for the push sender, oldest
|
||||||
|
// first.
|
||||||
|
ListPushSubscriptions(ctx context.Context) ([]ListPushSubscriptionsRow, error)
|
||||||
// Reads for the AI household context block (internal/ai/harness).
|
// Reads for the AI household context block (internal/ai/harness).
|
||||||
// The most recent names the household turned down, newest first, as
|
// The most recent names the household turned down, newest first, as
|
||||||
// ListPassedNames has them.
|
// ListPassedNames has them.
|
||||||
@@ -210,6 +229,9 @@ type Querier interface {
|
|||||||
// Moves a dinner still in the Quest log to tried; a dinner with any other
|
// Moves a dinner still in the Quest log to tried; a dinner with any other
|
||||||
// status is left as it is (0 rows).
|
// status is left as it is (0 rows).
|
||||||
MarkDinnerTried(ctx context.Context, arg MarkDinnerTriedParams) (int64, error)
|
MarkDinnerTried(ctx context.Context, arg MarkDinnerTriedParams) (int64, error)
|
||||||
|
// Records a delivered notification; updated_at is when it was sent. The
|
||||||
|
// lease must still be the claim's.
|
||||||
|
MarkNotificationSent(ctx context.Context, arg MarkNotificationSentParams) (int64, error)
|
||||||
// Records an edit of plan id, raising its revision: a synced plan becomes
|
// Records an edit of plan id, raising its revision: a synced plan becomes
|
||||||
// changed, any other keeps its status.
|
// changed, any other keeps its status.
|
||||||
MarkWeekPlanEdited(ctx context.Context, arg MarkWeekPlanEditedParams) error
|
MarkWeekPlanEdited(ctx context.Context, arg MarkWeekPlanEditedParams) error
|
||||||
@@ -229,17 +251,27 @@ type Querier interface {
|
|||||||
// by a Rotation check decision, recording updated_by; its origin is kept.
|
// by a Rotation check decision, recording updated_by; its origin is kept.
|
||||||
// A dinner no longer tried is left as it is (0 rows).
|
// A dinner no longer tried is left as it is (0 rows).
|
||||||
MoveTriedDinner(ctx context.Context, arg MoveTriedDinnerParams) (int64, error)
|
MoveTriedDinner(ctx context.Context, arg MoveTriedDinnerParams) (int64, error)
|
||||||
|
// Returns when the next pending notification is due. Returns sql.ErrNoRows
|
||||||
|
// when none is pending.
|
||||||
|
NextNotificationDue(ctx context.Context) (sql.NullString, error)
|
||||||
// Stores a partial result of a running job.
|
// Stores a partial result of a running job.
|
||||||
PublishJobResult(ctx context.Context, arg PublishJobResultParams) (int64, error)
|
PublishJobResult(ctx context.Context, arg PublishJobResultParams) (int64, error)
|
||||||
// Plans dinner_id on date of plan_id. An existing day keeps its calendar
|
// Plans dinner_id on date of plan_id. An existing day keeps its calendar
|
||||||
// event id while its dinner stays the same; a new dinner clears it, since
|
// event id while its dinner stays the same; a new dinner clears it, since
|
||||||
// Calendar no longer shows the day as planned.
|
// Calendar no longer shows the day as planned.
|
||||||
PutPlanDay(ctx context.Context, arg PutPlanDayParams) error
|
PutPlanDay(ctx context.Context, arg PutPlanDayParams) error
|
||||||
|
// Counts a delivery to a subscription that the push service did not accept.
|
||||||
|
RecordPushFailure(ctx context.Context, id string) error
|
||||||
|
// Records that the push service accepted a notification for a subscription.
|
||||||
|
RecordPushSuccess(ctx context.Context, arg RecordPushSuccessParams) error
|
||||||
// Records a new draft over plan id: its status and note, raising its
|
// Records a new draft over plan id: its status and note, raising its
|
||||||
// revision.
|
// revision.
|
||||||
RedraftWeekPlan(ctx context.Context, arg RedraftWeekPlanParams) error
|
RedraftWeekPlan(ctx context.Context, arg RedraftWeekPlanParams) error
|
||||||
// Records a generation of list id from plan plan_id.
|
// Records a generation of list id from plan plan_id.
|
||||||
RegenerateShoppingList(ctx context.Context, arg RegenerateShoppingListParams) error
|
RegenerateShoppingList(ctx context.Context, arg RegenerateShoppingListParams) error
|
||||||
|
// Records a failed delivery and when to try again. The lease must still be
|
||||||
|
// the claim's.
|
||||||
|
RetryNotification(ctx context.Context, arg RetryNotificationParams) (int64, error)
|
||||||
// An unknown id returns sql.ErrNoRows.
|
// An unknown id returns sql.ErrNoRows.
|
||||||
SetDinnerFavorite(ctx context.Context, arg SetDinnerFavoriteParams) (Dinner, error)
|
SetDinnerFavorite(ctx context.Context, arg SetDinnerFavoriteParams) (Dinner, error)
|
||||||
// Sets the chosen calendar; affects no row when nothing is connected.
|
// Sets the chosen calendar; affects no row when nothing is connected.
|
||||||
@@ -276,6 +308,10 @@ type Querier interface {
|
|||||||
// Creates the single row (id 1) or replaces every column except id and
|
// Creates the single row (id 1) or replaces every column except id and
|
||||||
// created_at.
|
// created_at.
|
||||||
UpsertIntegrationsGoogle(ctx context.Context, arg UpsertIntegrationsGoogleParams) (IntegrationsGoogle, error)
|
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
|
// 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
|
// existing row and keeps its id and created_at, which the returned row
|
||||||
// carries. Replace its ingredients and steps in the same transaction with
|
// carries. Replace its ingredients and steps in the same transaction with
|
||||||
|
|||||||
@@ -0,0 +1,248 @@
|
|||||||
|
// Package notify decides who is told what and delivers it reliably. It is
|
||||||
|
// the only package that reads notification_prefs to choose channels.
|
||||||
|
//
|
||||||
|
// Emit is the one way to notify the household: it reads the preference row
|
||||||
|
// of the event's type and, for each channel switched on, stores one pending
|
||||||
|
// row in notification_outbox keyed by (event key, channel). The event key is
|
||||||
|
// the caller's idempotency key, so emitting the same event twice stores
|
||||||
|
// nothing the second time and never touches a row already stored, whatever
|
||||||
|
// its status.
|
||||||
|
//
|
||||||
|
// The worker started by Start delivers due rows through the Sender
|
||||||
|
// registered for their channel. A delivered row is marked sent. A failed
|
||||||
|
// delivery is retried after a backoff of one minute doubling up to one hour,
|
||||||
|
// until 24 hours after the row was created; the delivery that fails at or
|
||||||
|
// after that point marks it failed, with the error in last_error.
|
||||||
|
package notify
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"database/sql"
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"log/slog"
|
||||||
|
"net/url"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/db"
|
||||||
|
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/store"
|
||||||
|
)
|
||||||
|
|
||||||
|
// EventType is what a notification is about. Each has one row in
|
||||||
|
// notification_prefs saying which channels it goes to.
|
||||||
|
type EventType string
|
||||||
|
|
||||||
|
// The event types, as notification_prefs.event names them.
|
||||||
|
const (
|
||||||
|
PlanReady EventType = "plan_ready"
|
||||||
|
ListReady EventType = "list_ready"
|
||||||
|
DayOfReminder EventType = "day_of_reminder"
|
||||||
|
NewSuggestions EventType = "new_suggestions"
|
||||||
|
RotationReview EventType = "rotation_review"
|
||||||
|
)
|
||||||
|
|
||||||
|
// eventTypes is every EventType.
|
||||||
|
var eventTypes = map[EventType]bool{
|
||||||
|
PlanReady: true, ListReady: true, DayOfReminder: true, NewSuggestions: true, RotationReview: true,
|
||||||
|
}
|
||||||
|
|
||||||
|
// Channel is a way of telling the household, as notification_outbox.channel
|
||||||
|
// names it.
|
||||||
|
type Channel string
|
||||||
|
|
||||||
|
// The channels.
|
||||||
|
const (
|
||||||
|
ChannelPush Channel = "push"
|
||||||
|
ChannelChat Channel = "chat"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Event is something to tell the household.
|
||||||
|
type Event struct {
|
||||||
|
// Type is what the event is about.
|
||||||
|
Type EventType
|
||||||
|
// Key identifies this event: it starts with Type and a colon, as in
|
||||||
|
// "plan_ready:2026-10-12". An event emitted again with the same key is
|
||||||
|
// ignored.
|
||||||
|
Key string
|
||||||
|
// Title is the notification's headline; it must not be blank.
|
||||||
|
Title string
|
||||||
|
// Body is the notification's text; it may be empty.
|
||||||
|
Body string
|
||||||
|
// URL is the path-absolute, same-origin path a tap on the notification
|
||||||
|
// opens, such as "/plan".
|
||||||
|
URL string
|
||||||
|
// Week is the Monday of the week the event concerns, for the senders'
|
||||||
|
// message builders; zero when the event concerns no week.
|
||||||
|
Week store.LocalDate
|
||||||
|
}
|
||||||
|
|
||||||
|
// Payload is the JSON stored in notification_outbox.payload and handed to
|
||||||
|
// a Sender: the one shape every channel delivers from. Its title, body, tag
|
||||||
|
// and url are the notice the service worker shows (web/src/push/notification.ts),
|
||||||
|
// which ignores the other fields.
|
||||||
|
type Payload struct {
|
||||||
|
Event EventType `json:"event"`
|
||||||
|
Title string `json:"title"`
|
||||||
|
Body string `json:"body"`
|
||||||
|
// URL is a path-absolute, same-origin path. A sender that needs an
|
||||||
|
// absolute URL resolves it against the public URL.
|
||||||
|
URL string `json:"url"`
|
||||||
|
// Tag is the event key, so a notification shown again for the same
|
||||||
|
// event replaces the first.
|
||||||
|
Tag string `json:"tag"`
|
||||||
|
// Week is the YYYY-MM-DD Monday of the event's week, or absent.
|
||||||
|
Week string `json:"week,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// ErrInvalidEvent is returned by Emit for an event it refuses; nothing is
|
||||||
|
// stored.
|
||||||
|
var ErrInvalidEvent = errors.New("notify: invalid event")
|
||||||
|
|
||||||
|
// Service emits notifications and runs the worker that delivers them.
|
||||||
|
type Service struct {
|
||||||
|
st *store.Store
|
||||||
|
logger *slog.Logger
|
||||||
|
// now is the clock rows are created, claimed and retried on; tests
|
||||||
|
// replace it.
|
||||||
|
now func() time.Time
|
||||||
|
// wake holds up to one token; Emit adds one so the worker looks for
|
||||||
|
// the rows it stored.
|
||||||
|
wake chan struct{}
|
||||||
|
|
||||||
|
mu sync.Mutex
|
||||||
|
senders map[Channel]Sender
|
||||||
|
started bool
|
||||||
|
|
||||||
|
wg sync.WaitGroup
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewService returns a service over st with no senders registered.
|
||||||
|
func NewService(st *store.Store, logger *slog.Logger) (*Service, error) {
|
||||||
|
if st == nil {
|
||||||
|
return nil, errors.New("notify: nil store")
|
||||||
|
}
|
||||||
|
if logger == nil {
|
||||||
|
return nil, errors.New("notify: nil logger")
|
||||||
|
}
|
||||||
|
return &Service{
|
||||||
|
st: st,
|
||||||
|
logger: logger,
|
||||||
|
now: time.Now,
|
||||||
|
wake: make(chan struct{}, 1),
|
||||||
|
senders: make(map[Channel]Sender),
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Emit stores one pending notification of ev for each channel its type's
|
||||||
|
// preference switches on, and wakes the worker. A channel already holding a
|
||||||
|
// row with ev.Key is left as it is. An invalid event is ErrInvalidEvent and
|
||||||
|
// stores nothing; a type with no preference row is an error.
|
||||||
|
func (s *Service) Emit(ctx context.Context, ev Event) error {
|
||||||
|
if err := validate(ev); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
payload := Payload{Event: ev.Type, Title: ev.Title, Body: ev.Body, URL: ev.URL, Tag: ev.Key}
|
||||||
|
if !ev.Week.IsZero() {
|
||||||
|
payload.Week = ev.Week.String()
|
||||||
|
}
|
||||||
|
data, err := json.Marshal(payload)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("notify: encode payload of %s: %w", ev.Key, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
var queued []Channel
|
||||||
|
err = s.st.WithTx(ctx, func(tx *sql.Tx) error {
|
||||||
|
q := db.New(tx)
|
||||||
|
pref, err := q.GetNotificationPref(ctx, string(ev.Type))
|
||||||
|
if errors.Is(err, sql.ErrNoRows) {
|
||||||
|
return fmt.Errorf("notify: no notification preference for %s", ev.Type)
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("notify: read notification preference for %s: %w", ev.Type, err)
|
||||||
|
}
|
||||||
|
now := timestamp(s.now())
|
||||||
|
for _, ch := range enabledChannels(pref) {
|
||||||
|
n, err := q.InsertNotification(ctx, db.InsertNotificationParams{
|
||||||
|
ID: store.NewID(),
|
||||||
|
EventKey: ev.Key,
|
||||||
|
Channel: string(ch),
|
||||||
|
Payload: string(data),
|
||||||
|
NextAttemptAt: sql.NullString{String: now, Valid: true},
|
||||||
|
Now: now,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("notify: store %s notification %s: %w", ch, ev.Key, err)
|
||||||
|
}
|
||||||
|
if n == 1 {
|
||||||
|
queued = append(queued, ch)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
s.logger.InfoContext(ctx, "notification emitted", "event_key", ev.Key, "queued", queued)
|
||||||
|
if len(queued) > 0 {
|
||||||
|
s.wakeWorker()
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// enabledChannels returns the channels pref switches on.
|
||||||
|
func enabledChannels(pref db.NotificationPref) []Channel {
|
||||||
|
var chs []Channel
|
||||||
|
if pref.Push == 1 {
|
||||||
|
chs = append(chs, ChannelPush)
|
||||||
|
}
|
||||||
|
if pref.Chat == 1 {
|
||||||
|
chs = append(chs, ChannelChat)
|
||||||
|
}
|
||||||
|
return chs
|
||||||
|
}
|
||||||
|
|
||||||
|
// validate reports why ev cannot be emitted, wrapping ErrInvalidEvent.
|
||||||
|
func validate(ev Event) error {
|
||||||
|
if !eventTypes[ev.Type] {
|
||||||
|
return fmt.Errorf("%w: unknown type %q", ErrInvalidEvent, ev.Type)
|
||||||
|
}
|
||||||
|
prefix := string(ev.Type) + ":"
|
||||||
|
if !strings.HasPrefix(ev.Key, prefix) || len(ev.Key) == len(prefix) {
|
||||||
|
return fmt.Errorf("%w: key %q does not start with %q and an identifier", ErrInvalidEvent, ev.Key, prefix)
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(ev.Title) == "" {
|
||||||
|
return fmt.Errorf("%w: %s has a blank title", ErrInvalidEvent, ev.Key)
|
||||||
|
}
|
||||||
|
if !isAppPath(ev.URL) {
|
||||||
|
return fmt.Errorf("%w: %s url %q is not a path-absolute same-origin path", ErrInvalidEvent, ev.Key, ev.URL)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// isAppPath reports whether raw is a path on the app's own origin, as the
|
||||||
|
// service worker requires: it starts with one slash and names no scheme or
|
||||||
|
// host, however it is parsed.
|
||||||
|
func isAppPath(raw string) bool {
|
||||||
|
if !strings.HasPrefix(raw, "/") || strings.HasPrefix(raw, "//") || strings.ContainsAny(raw, "\\\t\r\n") {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
u, err := url.Parse(raw)
|
||||||
|
return err == nil && u.Scheme == "" && u.Host == "" && !strings.HasPrefix(u.Path, "//")
|
||||||
|
}
|
||||||
|
|
||||||
|
// timestamp returns t in the stored UTC RFC 3339 form.
|
||||||
|
func timestamp(t time.Time) string {
|
||||||
|
return store.Timestamp{Time: t.UTC().Truncate(time.Millisecond)}.String()
|
||||||
|
}
|
||||||
|
|
||||||
|
// parseTimestamp parses a stored timestamp.
|
||||||
|
func parseTimestamp(s string) (time.Time, error) {
|
||||||
|
var ts store.Timestamp
|
||||||
|
if err := ts.Scan(s); err != nil {
|
||||||
|
return time.Time{}, err
|
||||||
|
}
|
||||||
|
return ts.Time, nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,730 @@
|
|||||||
|
package notify
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"database/sql"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"log/slog"
|
||||||
|
"sync"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/db"
|
||||||
|
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/household"
|
||||||
|
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/store"
|
||||||
|
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/migrations"
|
||||||
|
)
|
||||||
|
|
||||||
|
// start is the fake clock's first instant.
|
||||||
|
var start = time.Date(2026, 10, 9, 15, 0, 0, 0, time.UTC)
|
||||||
|
|
||||||
|
// clock is a controllable clock.
|
||||||
|
type clock struct {
|
||||||
|
mu sync.Mutex
|
||||||
|
t time.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *clock) now() time.Time {
|
||||||
|
c.mu.Lock()
|
||||||
|
defer c.mu.Unlock()
|
||||||
|
return c.t
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *clock) set(t time.Time) {
|
||||||
|
c.mu.Lock()
|
||||||
|
defer c.mu.Unlock()
|
||||||
|
c.t = t
|
||||||
|
}
|
||||||
|
|
||||||
|
// fakeSender records the messages it is given and fails while fail is set.
|
||||||
|
type fakeSender struct {
|
||||||
|
mu sync.Mutex
|
||||||
|
fail error
|
||||||
|
got []Message
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *fakeSender) Send(_ context.Context, m Message) error {
|
||||||
|
f.mu.Lock()
|
||||||
|
defer f.mu.Unlock()
|
||||||
|
f.got = append(f.got, m)
|
||||||
|
return f.fail
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *fakeSender) messages() []Message {
|
||||||
|
f.mu.Lock()
|
||||||
|
defer f.mu.Unlock()
|
||||||
|
return append([]Message(nil), f.got...)
|
||||||
|
}
|
||||||
|
|
||||||
|
func newTestService(t *testing.T) (*Service, *store.Store, *clock) {
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
if err := household.SeedNotificationPrefs(ctx, st); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
svc, err := NewService(st, slog.New(slog.NewTextHandler(io.Discard, nil)))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
c := &clock{t: start}
|
||||||
|
svc.now = c.now
|
||||||
|
return svc, st, c
|
||||||
|
}
|
||||||
|
|
||||||
|
func setPref(t *testing.T, st *store.Store, ev EventType, push, chat bool) {
|
||||||
|
t.Helper()
|
||||||
|
res, err := st.Writer.Exec(`UPDATE notification_prefs SET push = ?, chat = ? WHERE event = ?`, push, chat, string(ev))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if n, _ := res.RowsAffected(); n != 1 {
|
||||||
|
t.Fatalf("no notification preference for %s", ev)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// outbox returns the stored rows of key by channel.
|
||||||
|
func outbox(t *testing.T, st *store.Store, key string) map[Channel]db.NotificationOutbox {
|
||||||
|
t.Helper()
|
||||||
|
rs, err := st.Reader.Query(`SELECT id, event_key, channel, payload, status, attempts, next_attempt_at, last_error, created_at, updated_at
|
||||||
|
FROM notification_outbox WHERE event_key = ?`, key)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
defer rs.Close()
|
||||||
|
got := map[Channel]db.NotificationOutbox{}
|
||||||
|
for rs.Next() {
|
||||||
|
var r db.NotificationOutbox
|
||||||
|
if err := rs.Scan(&r.ID, &r.EventKey, &r.Channel, &r.Payload, &r.Status, &r.Attempts, &r.NextAttemptAt, &r.LastError, &r.CreatedAt, &r.UpdatedAt); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if _, dup := got[Channel(r.Channel)]; dup {
|
||||||
|
t.Fatalf("two %s rows for %s", r.Channel, key)
|
||||||
|
}
|
||||||
|
got[Channel(r.Channel)] = r
|
||||||
|
}
|
||||||
|
if err := rs.Err(); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
return got
|
||||||
|
}
|
||||||
|
|
||||||
|
func onlyRow(t *testing.T, st *store.Store, key string, ch Channel) db.NotificationOutbox {
|
||||||
|
t.Helper()
|
||||||
|
rows := outbox(t, st, key)
|
||||||
|
r, ok := rows[ch]
|
||||||
|
if !ok || len(rows) != 1 {
|
||||||
|
t.Fatalf("rows of %s = %v, want one %s row", key, rows, ch)
|
||||||
|
}
|
||||||
|
return r
|
||||||
|
}
|
||||||
|
|
||||||
|
func planReady(key string) Event {
|
||||||
|
return Event{
|
||||||
|
Type: PlanReady,
|
||||||
|
Key: key,
|
||||||
|
Title: "This week's plan is ready",
|
||||||
|
Body: "Five dinners on the Draft board.",
|
||||||
|
URL: "/plan",
|
||||||
|
Week: mustDate("2026-10-12"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func mustDate(s string) store.LocalDate {
|
||||||
|
d, err := store.ParseLocalDate(s)
|
||||||
|
if err != nil {
|
||||||
|
panic(err)
|
||||||
|
}
|
||||||
|
return d
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestEmitRespectsPrefsMatrix(t *testing.T) {
|
||||||
|
ctx := context.Background()
|
||||||
|
for _, tc := range []struct {
|
||||||
|
push, chat bool
|
||||||
|
want []Channel
|
||||||
|
}{
|
||||||
|
{push: false, chat: false, want: nil},
|
||||||
|
{push: true, chat: false, want: []Channel{ChannelPush}},
|
||||||
|
{push: false, chat: true, want: []Channel{ChannelChat}},
|
||||||
|
{push: true, chat: true, want: []Channel{ChannelPush, ChannelChat}},
|
||||||
|
} {
|
||||||
|
t.Run(fmt.Sprintf("push=%v,chat=%v", tc.push, tc.chat), func(t *testing.T) {
|
||||||
|
svc, st, _ := newTestService(t)
|
||||||
|
for ev := range eventTypes {
|
||||||
|
setPref(t, st, ev, tc.push, tc.chat)
|
||||||
|
key := string(ev) + ":2026-10-12"
|
||||||
|
if err := svc.Emit(ctx, Event{Type: ev, Key: key, Title: "Title", Body: "Body", URL: "/"}); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
rows := outbox(t, st, key)
|
||||||
|
if len(rows) != len(tc.want) {
|
||||||
|
t.Fatalf("%s: %d rows, want channels %v", ev, len(rows), tc.want)
|
||||||
|
}
|
||||||
|
for _, ch := range tc.want {
|
||||||
|
r, ok := rows[ch]
|
||||||
|
if !ok {
|
||||||
|
t.Fatalf("%s: no %s row", ev, ch)
|
||||||
|
}
|
||||||
|
now := timestamp(start)
|
||||||
|
if r.Status != "pending" || r.Attempts != 0 || r.NextAttemptAt.String != now || r.CreatedAt != now || r.LastError.Valid {
|
||||||
|
t.Errorf("%s %s row = %+v, want pending, due now", ev, ch, r)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestEmitStoresPayload(t *testing.T) {
|
||||||
|
svc, st, _ := newTestService(t)
|
||||||
|
setPref(t, st, PlanReady, true, true)
|
||||||
|
if err := svc.Emit(context.Background(), planReady("plan_ready:2026-10-12")); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
want := `{"event":"plan_ready","title":"This week's plan is ready","body":"Five dinners on the Draft board.","url":"/plan","tag":"plan_ready:2026-10-12","week":"2026-10-12"}`
|
||||||
|
for ch, r := range outbox(t, st, "plan_ready:2026-10-12") {
|
||||||
|
if r.Payload != want {
|
||||||
|
t.Errorf("%s payload = %s, want %s", ch, r.Payload, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// An event about no week omits it.
|
||||||
|
if err := svc.Emit(context.Background(), Event{Type: NewSuggestions, Key: "new_suggestions:batch1", Title: "New Side quests", URL: "/suggestions"}); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
r := onlyRow(t, st, "new_suggestions:batch1", ChannelPush)
|
||||||
|
if want := `{"event":"new_suggestions","title":"New Side quests","body":"","url":"/suggestions","tag":"new_suggestions:batch1"}`; r.Payload != want {
|
||||||
|
t.Errorf("payload = %s, want %s", r.Payload, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestEmitIgnoresDuplicateKey(t *testing.T) {
|
||||||
|
ctx := context.Background()
|
||||||
|
svc, st, c := newTestService(t)
|
||||||
|
setPref(t, st, PlanReady, true, false)
|
||||||
|
const key = "plan_ready:2026-10-12"
|
||||||
|
if err := svc.Emit(ctx, planReady(key)); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
first := onlyRow(t, st, key, ChannelPush)
|
||||||
|
|
||||||
|
c.set(start.Add(time.Minute))
|
||||||
|
again := planReady(key)
|
||||||
|
again.Title = "A different title"
|
||||||
|
if err := svc.Emit(ctx, again); err != nil {
|
||||||
|
t.Fatalf("duplicate emit: %v", err)
|
||||||
|
}
|
||||||
|
if got := onlyRow(t, st, key, ChannelPush); got != first {
|
||||||
|
t.Fatalf("duplicate emit changed the row:\n got %+v\nwant %+v", got, first)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Once sent, a duplicate still leaves the row as it is.
|
||||||
|
sender := &fakeSender{}
|
||||||
|
if err := svc.Register(ChannelPush, sender); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if _, err := svc.deliverDue(ctx); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
sent := onlyRow(t, st, key, ChannelPush)
|
||||||
|
if sent.Status != "sent" {
|
||||||
|
t.Fatalf("status = %s, want sent", sent.Status)
|
||||||
|
}
|
||||||
|
c.set(start.Add(2 * time.Minute))
|
||||||
|
if err := svc.Emit(ctx, again); err != nil {
|
||||||
|
t.Fatalf("emit after sent: %v", err)
|
||||||
|
}
|
||||||
|
if got := onlyRow(t, st, key, ChannelPush); got != sent {
|
||||||
|
t.Fatalf("emit after sent changed the row:\n got %+v\nwant %+v", got, sent)
|
||||||
|
}
|
||||||
|
if _, err := svc.deliverDue(ctx); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if n := len(sender.messages()); n != 1 {
|
||||||
|
t.Fatalf("%d deliveries, want 1", n)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestEmitRejectsInvalidEvents(t *testing.T) {
|
||||||
|
svc, st, _ := newTestService(t)
|
||||||
|
valid := planReady("plan_ready:2026-10-12")
|
||||||
|
for name, mutate := range map[string]func(*Event){
|
||||||
|
"unknown type": func(e *Event) { e.Type = "dinner_burnt" },
|
||||||
|
"empty key": func(e *Event) { e.Key = "" },
|
||||||
|
"key without id": func(e *Event) { e.Key = "plan_ready:" },
|
||||||
|
"key of another type": func(e *Event) { e.Key = "list_ready:2026-10-12" },
|
||||||
|
"blank title": func(e *Event) { e.Title = " " },
|
||||||
|
"empty url": func(e *Event) { e.URL = "" },
|
||||||
|
"relative url": func(e *Event) { e.URL = "plan" },
|
||||||
|
"absolute url": func(e *Event) { e.URL = "https://evil.example/plan" },
|
||||||
|
"protocol-relative": func(e *Event) { e.URL = "//evil.example/plan" },
|
||||||
|
"backslash url": func(e *Event) { e.URL = "/\\evil.example" },
|
||||||
|
"url with a tab": func(e *Event) { e.URL = "/\t/evil.example" },
|
||||||
|
"url with a bad query": func(e *Event) { e.URL = "/plan%zz" },
|
||||||
|
} {
|
||||||
|
t.Run(name, func(t *testing.T) {
|
||||||
|
ev := valid
|
||||||
|
mutate(&ev)
|
||||||
|
if err := svc.Emit(context.Background(), ev); !errors.Is(err, ErrInvalidEvent) {
|
||||||
|
t.Fatalf("Emit = %v, want ErrInvalidEvent", err)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
var n int
|
||||||
|
if err := st.Reader.QueryRow(`SELECT COUNT(*) FROM notification_outbox`).Scan(&n); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if n != 0 {
|
||||||
|
t.Fatalf("%d rows stored, want 0", n)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestEmitWithoutPrefIsAnError(t *testing.T) {
|
||||||
|
svc, st, _ := newTestService(t)
|
||||||
|
if _, err := st.Writer.Exec(`DELETE FROM notification_prefs WHERE event = 'plan_ready'`); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
err := svc.Emit(context.Background(), planReady("plan_ready:2026-10-12"))
|
||||||
|
if err == nil || errors.Is(err, ErrInvalidEvent) {
|
||||||
|
t.Fatalf("Emit = %v, want a missing-preference error", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDeliveryMarkedSent(t *testing.T) {
|
||||||
|
ctx := context.Background()
|
||||||
|
svc, st, c := newTestService(t)
|
||||||
|
setPref(t, st, PlanReady, true, true)
|
||||||
|
push, chat := &fakeSender{}, &fakeSender{}
|
||||||
|
if err := svc.Register(ChannelPush, push); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := svc.Register(ChannelChat, chat); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
const key = "plan_ready:2026-10-12"
|
||||||
|
if err := svc.Emit(ctx, planReady(key)); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
sentAt := start.Add(3 * time.Second)
|
||||||
|
c.set(sentAt)
|
||||||
|
wait, err := svc.deliverDue(ctx)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if wait != -1 {
|
||||||
|
t.Errorf("wait = %v, want -1 with nothing pending", wait)
|
||||||
|
}
|
||||||
|
|
||||||
|
rows := outbox(t, st, key)
|
||||||
|
wantPayload := Payload{Event: PlanReady, Title: "This week's plan is ready", Body: "Five dinners on the Draft board.", URL: "/plan", Tag: key, Week: "2026-10-12"}
|
||||||
|
for ch, sender := range map[Channel]*fakeSender{ChannelPush: push, ChannelChat: chat} {
|
||||||
|
r := rows[ch]
|
||||||
|
if r.Status != "sent" || r.Attempts != 1 || r.NextAttemptAt.Valid || r.UpdatedAt != timestamp(sentAt) || r.LastError.Valid {
|
||||||
|
t.Errorf("%s row = %+v, want sent at %s after 1 attempt", ch, r, timestamp(sentAt))
|
||||||
|
}
|
||||||
|
got := sender.messages()
|
||||||
|
want := Message{ID: r.ID, EventKey: key, Channel: ch, Payload: wantPayload, Attempt: 1}
|
||||||
|
if len(got) != 1 || got[0] != want {
|
||||||
|
t.Errorf("%s sender got %+v, want [%+v]", ch, got, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRetryBackoffSchedule(t *testing.T) {
|
||||||
|
ctx := context.Background()
|
||||||
|
svc, st, c := newTestService(t)
|
||||||
|
setPref(t, st, PlanReady, true, false)
|
||||||
|
sender := &fakeSender{fail: errors.New("push service unavailable")}
|
||||||
|
if err := svc.Register(ChannelPush, sender); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
const key = "plan_ready:2026-10-12"
|
||||||
|
if err := svc.Emit(ctx, planReady(key)); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
now := start
|
||||||
|
for i, wantDelay := range []time.Duration{
|
||||||
|
time.Minute, 2 * time.Minute, 4 * time.Minute, 8 * time.Minute, 16 * time.Minute,
|
||||||
|
32 * time.Minute, time.Hour, time.Hour, time.Hour,
|
||||||
|
} {
|
||||||
|
c.set(now)
|
||||||
|
wait, err := svc.deliverDue(ctx)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
r := onlyRow(t, st, key, ChannelPush)
|
||||||
|
wantNext := now.Add(wantDelay)
|
||||||
|
if r.Status != "pending" || r.Attempts != int64(i+1) || r.NextAttemptAt.String != timestamp(wantNext) ||
|
||||||
|
r.LastError.String != "push service unavailable" {
|
||||||
|
t.Fatalf("after failure %d row = %+v, want pending, attempts %d, next %s", i+1, r, i+1, timestamp(wantNext))
|
||||||
|
}
|
||||||
|
if wait != wantDelay {
|
||||||
|
t.Fatalf("after failure %d wait = %v, want %v", i+1, wait, wantDelay)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Not due a moment before the retry time.
|
||||||
|
c.set(wantNext.Add(-time.Millisecond))
|
||||||
|
if _, err := svc.deliverDue(ctx); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if n := len(sender.messages()); n != i+1 {
|
||||||
|
t.Fatalf("%d deliveries before the retry was due, want %d", n, i+1)
|
||||||
|
}
|
||||||
|
now = wantNext
|
||||||
|
}
|
||||||
|
for i, m := range sender.messages() {
|
||||||
|
if m.Attempt != int64(i+1) {
|
||||||
|
t.Errorf("delivery %d has Attempt %d", i+1, m.Attempt)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRetryThenSent(t *testing.T) {
|
||||||
|
ctx := context.Background()
|
||||||
|
svc, st, c := newTestService(t)
|
||||||
|
setPref(t, st, ListReady, false, true)
|
||||||
|
sender := &fakeSender{fail: errors.New("chat returned 503")}
|
||||||
|
if err := svc.Register(ChannelChat, sender); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
const key = "list_ready:2026-10-12"
|
||||||
|
if err := svc.Emit(ctx, Event{Type: ListReady, Key: key, Title: "Supply run ready", URL: "/supply-run"}); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if _, err := svc.deliverDue(ctx); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
sender.mu.Lock()
|
||||||
|
sender.fail = nil
|
||||||
|
sender.mu.Unlock()
|
||||||
|
c.set(start.Add(time.Minute))
|
||||||
|
if _, err := svc.deliverDue(ctx); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
r := onlyRow(t, st, key, ChannelChat)
|
||||||
|
if r.Status != "sent" || r.Attempts != 2 || r.UpdatedAt != timestamp(start.Add(time.Minute)) {
|
||||||
|
t.Fatalf("row = %+v, want sent on attempt 2", r)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGiveUpAfter24Hours(t *testing.T) {
|
||||||
|
ctx := context.Background()
|
||||||
|
svc, st, c := newTestService(t)
|
||||||
|
setPref(t, st, DayOfReminder, true, false)
|
||||||
|
sender := &fakeSender{fail: errors.New("push service unavailable")}
|
||||||
|
if err := svc.Register(ChannelPush, sender); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
const key = "day_of_reminder:2026-10-09"
|
||||||
|
if err := svc.Emit(ctx, Event{Type: DayOfReminder, Key: key, Title: "Tonight: tacos", URL: "/"}); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Deliver each retry as it falls due until the row leaves pending.
|
||||||
|
deadline := start.Add(24 * time.Hour)
|
||||||
|
var times []time.Time
|
||||||
|
for range 100 {
|
||||||
|
r := onlyRow(t, st, key, ChannelPush)
|
||||||
|
if r.Status != "pending" {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
due, err := parseTimestamp(r.NextAttemptAt.String)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if due.After(deadline) {
|
||||||
|
t.Fatalf("retry due %s, after the 24 h deadline %s", due, deadline)
|
||||||
|
}
|
||||||
|
c.set(due)
|
||||||
|
times = append(times, due)
|
||||||
|
if _, err := svc.deliverDue(ctx); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Attempts at 0, 1, 3, 7, 15, 31 and 63 minutes, then hourly to 1383
|
||||||
|
// minutes (22 more): 29 in all. The row is due once more at the
|
||||||
|
// deadline, where it is failed without being sent.
|
||||||
|
const wantAttempts = 29
|
||||||
|
r := onlyRow(t, st, key, ChannelPush)
|
||||||
|
if r.Status != "failed" || r.Attempts != wantAttempts || r.NextAttemptAt.Valid ||
|
||||||
|
r.LastError.String != "not delivered within 24 hours of creation; last error: push service unavailable" ||
|
||||||
|
r.UpdatedAt != timestamp(deadline) {
|
||||||
|
t.Fatalf("row = %+v, want failed at %s after %d attempts", r, timestamp(deadline), wantAttempts)
|
||||||
|
}
|
||||||
|
if len(times) != wantAttempts+1 || !times[len(times)-1].Equal(deadline) || !times[len(times)-2].Equal(start.Add(1383*time.Minute)) {
|
||||||
|
t.Fatalf("due at %v, want %d attempts ending 1383 min after creation, then the deadline", times, wantAttempts)
|
||||||
|
}
|
||||||
|
if n := len(sender.messages()); n != wantAttempts {
|
||||||
|
t.Fatalf("%d deliveries, want %d", n, wantAttempts)
|
||||||
|
}
|
||||||
|
|
||||||
|
// A failed row is never delivered again.
|
||||||
|
c.set(deadline.Add(48 * time.Hour))
|
||||||
|
if _, err := svc.deliverDue(ctx); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if n := len(sender.messages()); n != wantAttempts {
|
||||||
|
t.Fatalf("%d deliveries, want %d", n, wantAttempts)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestOverdueRowIsNeverDelivered covers a row first claimed 24 hours or
|
||||||
|
// more after its creation, as when the service was down: it is failed
|
||||||
|
// without calling the sender, whether the sender would succeed or fail.
|
||||||
|
func TestOverdueRowIsNeverDelivered(t *testing.T) {
|
||||||
|
for _, tc := range []struct {
|
||||||
|
name string
|
||||||
|
at time.Duration
|
||||||
|
fail error
|
||||||
|
}{
|
||||||
|
{"succeeding sender at 24 h", 24 * time.Hour, nil},
|
||||||
|
{"succeeding sender after 24 h", 25 * time.Hour, nil},
|
||||||
|
{"failing sender at 24 h", 24 * time.Hour, errors.New("gone")},
|
||||||
|
{"failing sender after 24 h", 25 * time.Hour, errors.New("gone")},
|
||||||
|
} {
|
||||||
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
|
ctx := context.Background()
|
||||||
|
svc, st, c := newTestService(t)
|
||||||
|
setPref(t, st, PlanReady, true, false)
|
||||||
|
sender := &fakeSender{fail: tc.fail}
|
||||||
|
if err := svc.Register(ChannelPush, sender); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
const key = "plan_ready:2026-10-12"
|
||||||
|
if err := svc.Emit(ctx, planReady(key)); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
c.set(start.Add(tc.at))
|
||||||
|
if _, err := svc.deliverDue(ctx); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if n := len(sender.messages()); n != 0 {
|
||||||
|
t.Fatalf("%d deliveries of an overdue row, want 0", n)
|
||||||
|
}
|
||||||
|
r := onlyRow(t, st, key, ChannelPush)
|
||||||
|
if r.Status != "failed" || r.Attempts != 0 || r.NextAttemptAt.Valid ||
|
||||||
|
r.LastError.String != "not delivered within 24 hours of creation" || r.UpdatedAt != timestamp(start.Add(tc.at)) {
|
||||||
|
t.Fatalf("row = %+v, want failed without an attempt", r)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestRowJustBeforeDeadlineIsDelivered pins the boundary from the other
|
||||||
|
// side: a row claimed just under 24 hours after its creation is sent.
|
||||||
|
func TestRowJustBeforeDeadlineIsDelivered(t *testing.T) {
|
||||||
|
ctx := context.Background()
|
||||||
|
svc, st, c := newTestService(t)
|
||||||
|
setPref(t, st, PlanReady, true, false)
|
||||||
|
sender := &fakeSender{}
|
||||||
|
if err := svc.Register(ChannelPush, sender); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
const key = "plan_ready:2026-10-12"
|
||||||
|
if err := svc.Emit(ctx, planReady(key)); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
c.set(start.Add(24*time.Hour - time.Millisecond))
|
||||||
|
if _, err := svc.deliverDue(ctx); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if r := onlyRow(t, st, key, ChannelPush); r.Status != "sent" || r.Attempts != 1 || len(sender.messages()) != 1 {
|
||||||
|
t.Fatalf("row = %+v after %d deliveries, want sent once", r, len(sender.messages()))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestChannelWithoutSenderFails(t *testing.T) {
|
||||||
|
ctx := context.Background()
|
||||||
|
svc, st, _ := newTestService(t)
|
||||||
|
setPref(t, st, PlanReady, true, true)
|
||||||
|
push := &fakeSender{}
|
||||||
|
if err := svc.Register(ChannelPush, push); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
const key = "plan_ready:2026-10-12"
|
||||||
|
if err := svc.Emit(ctx, planReady(key)); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if _, err := svc.deliverDue(ctx); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
rows := outbox(t, st, key)
|
||||||
|
if r := rows[ChannelChat]; r.Status != "failed" || r.Attempts != 0 || r.LastError.String != "no sender registered for channel chat" {
|
||||||
|
t.Fatalf("chat row = %+v, want failed with no sender", r)
|
||||||
|
}
|
||||||
|
if r := rows[ChannelPush]; r.Status != "sent" {
|
||||||
|
t.Fatalf("push row = %+v, want sent", r)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLeasedRowIsNotClaimedAgain(t *testing.T) {
|
||||||
|
ctx := context.Background()
|
||||||
|
svc, st, c := newTestService(t)
|
||||||
|
setPref(t, st, PlanReady, true, false)
|
||||||
|
sender := &fakeSender{}
|
||||||
|
if err := svc.Register(ChannelPush, sender); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
const key = "plan_ready:2026-10-12"
|
||||||
|
if err := svc.Emit(ctx, planReady(key)); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
// Another tick claims the row and stops before recording the delivery.
|
||||||
|
lease := start.Add(leaseFor)
|
||||||
|
if _, err := db.New(st.Writer).ClaimDueNotification(ctx, db.ClaimDueNotificationParams{
|
||||||
|
LeaseUntil: sql.NullString{String: timestamp(lease), Valid: true}, Now: timestamp(start),
|
||||||
|
}); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
c.set(lease.Add(-time.Millisecond))
|
||||||
|
if _, err := svc.deliverDue(ctx); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if n := len(sender.messages()); n != 0 {
|
||||||
|
t.Fatalf("%d deliveries of a leased row, want 0", n)
|
||||||
|
}
|
||||||
|
// Once the lease ends the row is delivered.
|
||||||
|
c.set(lease)
|
||||||
|
if _, err := svc.deliverDue(ctx); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if r := onlyRow(t, st, key, ChannelPush); r.Status != "sent" || r.Attempts != 1 || len(sender.messages()) != 1 {
|
||||||
|
t.Fatalf("row = %+v after %d deliveries, want sent once", r, len(sender.messages()))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// blockingSender holds every Send until release is closed.
|
||||||
|
type blockingSender struct {
|
||||||
|
fakeSender
|
||||||
|
entered chan struct{}
|
||||||
|
release chan struct{}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (b *blockingSender) Send(ctx context.Context, m Message) error {
|
||||||
|
b.entered <- struct{}{}
|
||||||
|
<-b.release
|
||||||
|
return b.fakeSender.Send(ctx, m)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestConcurrentTicksDeliverOnce(t *testing.T) {
|
||||||
|
ctx := context.Background()
|
||||||
|
svc, st, _ := newTestService(t)
|
||||||
|
setPref(t, st, PlanReady, true, false)
|
||||||
|
sender := &blockingSender{entered: make(chan struct{}, 10), release: make(chan struct{})}
|
||||||
|
if err := svc.Register(ChannelPush, sender); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
const key = "plan_ready:2026-10-12"
|
||||||
|
if err := svc.Emit(ctx, planReady(key)); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
var wg sync.WaitGroup
|
||||||
|
first := make(chan error, 1)
|
||||||
|
wg.Go(func() {
|
||||||
|
_, err := svc.deliverDue(ctx)
|
||||||
|
first <- err
|
||||||
|
})
|
||||||
|
<-sender.entered // the first tick holds the row
|
||||||
|
if _, err := svc.deliverDue(ctx); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
close(sender.release)
|
||||||
|
wg.Wait()
|
||||||
|
if err := <-first; err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if n := len(sender.messages()); n != 1 {
|
||||||
|
t.Fatalf("%d deliveries, want 1", n)
|
||||||
|
}
|
||||||
|
if r := onlyRow(t, st, key, ChannelPush); r.Status != "sent" {
|
||||||
|
t.Fatalf("row = %+v, want sent", r)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRetryDelay(t *testing.T) {
|
||||||
|
for attempt, want := range map[int64]time.Duration{
|
||||||
|
1: time.Minute, 2: 2 * time.Minute, 3: 4 * time.Minute, 6: 32 * time.Minute,
|
||||||
|
7: time.Hour, 8: time.Hour, 1000: time.Hour,
|
||||||
|
} {
|
||||||
|
if got := retryDelay(attempt); got != want {
|
||||||
|
t.Errorf("retryDelay(%d) = %v, want %v", attempt, got, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRegister(t *testing.T) {
|
||||||
|
svc, _, _ := newTestService(t)
|
||||||
|
if err := svc.Register("email", &fakeSender{}); err == nil {
|
||||||
|
t.Error("registered an unknown channel")
|
||||||
|
}
|
||||||
|
if err := svc.Register(ChannelPush, nil); err == nil {
|
||||||
|
t.Error("registered a nil sender")
|
||||||
|
}
|
||||||
|
if err := svc.Register(ChannelPush, &fakeSender{}); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := svc.Register(ChannelPush, &fakeSender{}); err == nil {
|
||||||
|
t.Error("registered a second push sender")
|
||||||
|
}
|
||||||
|
ctx, cancel := context.WithCancel(context.Background())
|
||||||
|
if err := svc.Start(ctx); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := svc.Register(ChannelChat, &fakeSender{}); err == nil {
|
||||||
|
t.Error("registered a sender after Start")
|
||||||
|
}
|
||||||
|
if err := svc.Start(ctx); err == nil {
|
||||||
|
t.Error("started twice")
|
||||||
|
}
|
||||||
|
cancel()
|
||||||
|
svc.Wait()
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestWorkerDeliversEmittedEvent runs the worker on the real clock: Emit
|
||||||
|
// wakes it, it delivers, and it stops when its context is cancelled.
|
||||||
|
func TestWorkerDeliversEmittedEvent(t *testing.T) {
|
||||||
|
svc, st, _ := newTestService(t)
|
||||||
|
svc.now = time.Now
|
||||||
|
setPref(t, st, PlanReady, true, false)
|
||||||
|
sender := &fakeSender{}
|
||||||
|
if err := svc.Register(ChannelPush, sender); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
ctx, cancel := context.WithCancel(context.Background())
|
||||||
|
if err := svc.Start(ctx); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
const key = "plan_ready:2026-10-12"
|
||||||
|
if err := svc.Emit(context.Background(), planReady(key)); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
deadline := time.Now().Add(5 * time.Second)
|
||||||
|
for onlyRow(t, st, key, ChannelPush).Status != "sent" {
|
||||||
|
if time.Now().After(deadline) {
|
||||||
|
t.Fatal("worker did not deliver the notification")
|
||||||
|
}
|
||||||
|
time.Sleep(10 * time.Millisecond)
|
||||||
|
}
|
||||||
|
cancel()
|
||||||
|
svc.Wait()
|
||||||
|
if n := len(sender.messages()); n != 1 {
|
||||||
|
t.Fatalf("%d deliveries, want 1", n)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,299 @@
|
|||||||
|
package notify
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"database/sql"
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"log/slog"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/db"
|
||||||
|
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/jobs"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Delivery timing.
|
||||||
|
const (
|
||||||
|
// retryFirst is the wait after a row's first failed delivery; each
|
||||||
|
// further failure doubles it, up to retryMax.
|
||||||
|
retryFirst = time.Minute
|
||||||
|
retryMax = time.Hour
|
||||||
|
// giveUpAfter is how long after its creation a row may be delivered.
|
||||||
|
// The last retry is due at that moment; a row claimed then or later is
|
||||||
|
// marked failed without being sent, and a delivery failing at or
|
||||||
|
// after it marks the row failed.
|
||||||
|
giveUpAfter = 24 * time.Hour
|
||||||
|
// sendTimeout bounds one Send.
|
||||||
|
sendTimeout = 30 * time.Second
|
||||||
|
// leaseFor is how long a claimed row is not due again. It outlasts
|
||||||
|
// sendTimeout and the final write, so a row is only claimed again when
|
||||||
|
// the process that claimed it stopped before recording the delivery.
|
||||||
|
leaseFor = 2 * time.Minute
|
||||||
|
// pollRetryMin and pollRetryMax bound the backoff after a database
|
||||||
|
// error while claiming rows.
|
||||||
|
pollRetryMin = time.Second
|
||||||
|
pollRetryMax = time.Minute
|
||||||
|
)
|
||||||
|
|
||||||
|
// Message is one notification for a Sender to deliver.
|
||||||
|
type Message struct {
|
||||||
|
// ID is the notification_outbox row id.
|
||||||
|
ID string
|
||||||
|
// EventKey is the event's idempotency key.
|
||||||
|
EventKey string
|
||||||
|
Channel Channel
|
||||||
|
Payload Payload
|
||||||
|
// Attempt is this delivery's number, from 1.
|
||||||
|
Attempt int64
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sender delivers notifications on one channel. Send returns nil once the
|
||||||
|
// channel has accepted m, and an error otherwise; the worker retries it.
|
||||||
|
// The error's text is stored in notification_outbox.last_error and logged,
|
||||||
|
// so it must never carry a secret: no webhook URL, key or token (a
|
||||||
|
// *url.Error quotes its URL).
|
||||||
|
type Sender interface {
|
||||||
|
Send(ctx context.Context, m Message) error
|
||||||
|
}
|
||||||
|
|
||||||
|
// Register makes sender the Sender of ch. It must be called before Start,
|
||||||
|
// once per channel.
|
||||||
|
func (s *Service) Register(ch Channel, sender Sender) error {
|
||||||
|
if ch != ChannelPush && ch != ChannelChat {
|
||||||
|
return fmt.Errorf("notify: register: unknown channel %q", ch)
|
||||||
|
}
|
||||||
|
if sender == nil {
|
||||||
|
return fmt.Errorf("notify: register %s: nil sender", ch)
|
||||||
|
}
|
||||||
|
s.mu.Lock()
|
||||||
|
defer s.mu.Unlock()
|
||||||
|
if s.started {
|
||||||
|
return fmt.Errorf("notify: register %s: worker already started", ch)
|
||||||
|
}
|
||||||
|
if _, ok := s.senders[ch]; ok {
|
||||||
|
return fmt.Errorf("notify: register %s: channel already has a sender", ch)
|
||||||
|
}
|
||||||
|
s.senders[ch] = sender
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Start starts the worker. It stops when ctx is cancelled; Wait waits for
|
||||||
|
// it. A row whose delivery is cut short by ctx keeps its lease and is
|
||||||
|
// delivered again once the lease ends.
|
||||||
|
func (s *Service) Start(ctx context.Context) error {
|
||||||
|
s.mu.Lock()
|
||||||
|
defer s.mu.Unlock()
|
||||||
|
if s.started {
|
||||||
|
return errors.New("notify: worker already started")
|
||||||
|
}
|
||||||
|
s.started = true
|
||||||
|
s.wg.Add(1)
|
||||||
|
go s.work(ctx)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Wait blocks until the worker has stopped.
|
||||||
|
func (s *Service) Wait() {
|
||||||
|
s.wg.Wait()
|
||||||
|
}
|
||||||
|
|
||||||
|
// wakeWorker gives the worker a token to look for due rows.
|
||||||
|
func (s *Service) wakeWorker() {
|
||||||
|
select {
|
||||||
|
case s.wake <- struct{}{}:
|
||||||
|
default: // the worker already has a token and will look again
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// work delivers due rows until ctx is cancelled, then sleeps until the next
|
||||||
|
// pending row is due or Emit stores one. A database error is retried with
|
||||||
|
// backoff.
|
||||||
|
func (s *Service) work(ctx context.Context) {
|
||||||
|
defer s.wg.Done()
|
||||||
|
backoff := pollRetryMin
|
||||||
|
for ctx.Err() == nil {
|
||||||
|
wait, err := s.deliverDue(ctx)
|
||||||
|
if err != nil {
|
||||||
|
if ctx.Err() != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
s.logger.ErrorContext(ctx, "deliver notifications failed; retrying", "error", err, "retry_in", backoff)
|
||||||
|
wait, backoff = backoff, min(2*backoff, pollRetryMax)
|
||||||
|
} else {
|
||||||
|
backoff = pollRetryMin
|
||||||
|
}
|
||||||
|
var timeout <-chan time.Time
|
||||||
|
var timer *time.Timer
|
||||||
|
if wait >= 0 {
|
||||||
|
timer = time.NewTimer(wait)
|
||||||
|
timeout = timer.C
|
||||||
|
}
|
||||||
|
select {
|
||||||
|
case <-s.wake:
|
||||||
|
case <-timeout:
|
||||||
|
case <-ctx.Done():
|
||||||
|
}
|
||||||
|
if timer != nil {
|
||||||
|
timer.Stop()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// deliverDue delivers every row due now and returns how long until the next
|
||||||
|
// pending row is due, or -1 when none is pending.
|
||||||
|
func (s *Service) deliverDue(ctx context.Context) (time.Duration, error) {
|
||||||
|
q := db.New(s.st.Writer)
|
||||||
|
for ctx.Err() == nil {
|
||||||
|
now := s.now()
|
||||||
|
lease := sql.NullString{String: timestamp(now.Add(leaseFor)), Valid: true}
|
||||||
|
row, err := q.ClaimDueNotification(ctx, db.ClaimDueNotificationParams{LeaseUntil: lease, Now: timestamp(now)})
|
||||||
|
if errors.Is(err, sql.ErrNoRows) {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return 0, fmt.Errorf("notify: claim notification: %w", err)
|
||||||
|
}
|
||||||
|
s.deliver(ctx, row)
|
||||||
|
}
|
||||||
|
if err := ctx.Err(); err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
next, err := db.New(s.st.Reader).NextNotificationDue(ctx)
|
||||||
|
if errors.Is(err, sql.ErrNoRows) {
|
||||||
|
return -1, nil
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return 0, fmt.Errorf("notify: read next due notification: %w", err)
|
||||||
|
}
|
||||||
|
due, err := parseTimestamp(next.String)
|
||||||
|
if err != nil {
|
||||||
|
return 0, fmt.Errorf("notify: next due notification: %w", err)
|
||||||
|
}
|
||||||
|
return max(due.Sub(s.now()), 0), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// deliver sends a claimed row through its channel's sender and records the
|
||||||
|
// outcome. A row that cannot be sent at all, because its payload does not
|
||||||
|
// decode or its channel has no sender, is failed at once: retrying cannot
|
||||||
|
// help. A row 24 hours or more past its creation is failed without being
|
||||||
|
// sent: it is too stale to deliver.
|
||||||
|
func (s *Service) deliver(ctx context.Context, row db.NotificationOutbox) {
|
||||||
|
logger := s.logger.With("notification_id", row.ID, "event_key", row.EventKey, "channel", row.Channel)
|
||||||
|
var payload Payload
|
||||||
|
if err := json.Unmarshal([]byte(row.Payload), &payload); err != nil {
|
||||||
|
logger.ErrorContext(ctx, "notification failed", "reason", "payload does not decode")
|
||||||
|
s.giveUp(ctx, logger, row, 0, "payload does not decode")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
s.mu.Lock()
|
||||||
|
sender, ok := s.senders[Channel(row.Channel)]
|
||||||
|
s.mu.Unlock()
|
||||||
|
if !ok {
|
||||||
|
reason := fmt.Sprintf("no sender registered for channel %s", row.Channel)
|
||||||
|
logger.ErrorContext(ctx, "notification failed", "reason", reason)
|
||||||
|
s.giveUp(ctx, logger, row, 0, reason)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
created, err := parseTimestamp(row.CreatedAt)
|
||||||
|
if err != nil {
|
||||||
|
reason := fmt.Sprintf("created_at does not parse: %v", err)
|
||||||
|
logger.ErrorContext(ctx, "notification failed", "reason", reason)
|
||||||
|
s.giveUp(ctx, logger, row, 0, reason)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
deadline := created.Add(giveUpAfter)
|
||||||
|
if !s.now().Before(deadline) {
|
||||||
|
reason := "not delivered within 24 hours of creation"
|
||||||
|
if row.LastError.Valid {
|
||||||
|
reason += "; last error: " + row.LastError.String
|
||||||
|
}
|
||||||
|
logger.ErrorContext(ctx, "notification failed; giving up", "reason", reason)
|
||||||
|
s.giveUp(ctx, logger, row, 0, reason)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
attempt := row.Attempts + 1
|
||||||
|
sendCtx, cancel := context.WithTimeout(ctx, sendTimeout)
|
||||||
|
err = sender.Send(sendCtx, Message{
|
||||||
|
ID: row.ID, EventKey: row.EventKey, Channel: Channel(row.Channel), Payload: payload, Attempt: attempt,
|
||||||
|
})
|
||||||
|
cancel()
|
||||||
|
now := s.now()
|
||||||
|
if err == nil {
|
||||||
|
s.record(ctx, logger, "record notification sent", func(write context.Context) (int64, error) {
|
||||||
|
return db.New(s.st.Writer).MarkNotificationSent(write, db.MarkNotificationSentParams{
|
||||||
|
Now: timestamp(now), ID: row.ID, LeaseUntil: row.NextAttemptAt,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
logger.InfoContext(ctx, "notification sent", "attempt", attempt)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if ctx.Err() != nil {
|
||||||
|
logger.WarnContext(context.WithoutCancel(ctx), "notification delivery interrupted by shutdown; it is retried when its lease ends")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if !now.Before(deadline) {
|
||||||
|
logger.ErrorContext(ctx, "notification failed; giving up", "attempt", attempt, "error", err)
|
||||||
|
s.giveUp(ctx, logger, row, 1, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
next := now.Add(retryDelay(attempt))
|
||||||
|
if next.After(deadline) {
|
||||||
|
next = deadline
|
||||||
|
}
|
||||||
|
logger.WarnContext(ctx, "notification delivery failed; retrying", "attempt", attempt, "error", err, "retry_at", timestamp(next))
|
||||||
|
s.record(ctx, logger, "record notification retry", func(write context.Context) (int64, error) {
|
||||||
|
return db.New(s.st.Writer).RetryNotification(write, db.RetryNotificationParams{
|
||||||
|
NextAttemptAt: sql.NullString{String: timestamp(next), Valid: true},
|
||||||
|
LastError: sql.NullString{String: err.Error(), Valid: true},
|
||||||
|
Now: timestamp(now),
|
||||||
|
ID: row.ID,
|
||||||
|
LeaseUntil: row.NextAttemptAt,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// giveUp marks a claimed row failed with reason. attempted is 1 when a
|
||||||
|
// delivery was attempted, else 0.
|
||||||
|
func (s *Service) giveUp(ctx context.Context, logger *slog.Logger, row db.NotificationOutbox, attempted int64, reason string) {
|
||||||
|
now := s.now()
|
||||||
|
s.record(ctx, logger, "record notification failed", func(write context.Context) (int64, error) {
|
||||||
|
return db.New(s.st.Writer).FailNotification(write, db.FailNotificationParams{
|
||||||
|
Attempted: attempted,
|
||||||
|
LastError: sql.NullString{String: reason, Valid: true},
|
||||||
|
Now: timestamp(now),
|
||||||
|
ID: row.ID,
|
||||||
|
LeaseUntil: row.NextAttemptAt,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// record makes a row's final write for one delivery, even when ctx has been
|
||||||
|
// cancelled, bounded by jobs.FinalWriteTimeout. A write that fails, or that
|
||||||
|
// finds the row no longer leased to this claim, is logged; the row keeps
|
||||||
|
// its lease and is delivered again once the lease ends.
|
||||||
|
func (s *Service) record(ctx context.Context, logger *slog.Logger, what string, write func(context.Context) (int64, error)) {
|
||||||
|
attempt, cancel := jobs.FinalWriteContext(ctx)
|
||||||
|
defer cancel()
|
||||||
|
n, err := write(attempt)
|
||||||
|
if err != nil {
|
||||||
|
logger.ErrorContext(context.WithoutCancel(ctx), what+" failed; it is delivered again when its lease ends", "error", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if n != 1 {
|
||||||
|
logger.ErrorContext(context.WithoutCancel(ctx), what, "error", "notification is no longer leased to this delivery")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// retryDelay is the wait after the attempt-th failed delivery: retryFirst,
|
||||||
|
// doubling with each failure, at most retryMax.
|
||||||
|
func retryDelay(attempt int64) time.Duration {
|
||||||
|
d := retryFirst
|
||||||
|
for i := int64(1); i < attempt && d < retryMax; i++ {
|
||||||
|
d *= 2
|
||||||
|
}
|
||||||
|
return min(d, retryMax)
|
||||||
|
}
|
||||||
@@ -0,0 +1,203 @@
|
|||||||
|
// Package push keeps the household's Web Push subscriptions, one per
|
||||||
|
// browser, keyed by the push service endpoint the browser was given, and
|
||||||
|
// the VAPID public key browsers subscribe with.
|
||||||
|
package push
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"crypto/ecdh"
|
||||||
|
"database/sql"
|
||||||
|
"encoding/base64"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"net/url"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/db"
|
||||||
|
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/store"
|
||||||
|
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/vapid"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Limits on a subscription's fields.
|
||||||
|
const (
|
||||||
|
MaxEndpointBytes = 2048
|
||||||
|
MaxUserAgentBytes = 512
|
||||||
|
p256dhSize = 65 // uncompressed P-256 point
|
||||||
|
authSize = 16
|
||||||
|
)
|
||||||
|
|
||||||
|
// keyEncoding is the unpadded base64url PushSubscription.toJSON() gives
|
||||||
|
// the keys in.
|
||||||
|
var keyEncoding = base64.RawURLEncoding
|
||||||
|
|
||||||
|
// Subscription is a stored push subscription. Its keys are never returned:
|
||||||
|
// only the sender needs them.
|
||||||
|
type Subscription struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
Endpoint string `json:"endpoint"`
|
||||||
|
UserAgent *string `json:"user_agent"`
|
||||||
|
CreatedAt string `json:"created_at"`
|
||||||
|
UpdatedAt string `json:"updated_at"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// SubscribeRequest is a browser's push subscription: the endpoint and keys
|
||||||
|
// of its PushSubscription, and optionally a user agent to tell the
|
||||||
|
// household's devices apart.
|
||||||
|
type SubscribeRequest struct {
|
||||||
|
Endpoint string
|
||||||
|
P256dh string
|
||||||
|
Auth string
|
||||||
|
UserAgent *string
|
||||||
|
}
|
||||||
|
|
||||||
|
// Problem is one invalid field of a request, named by its JSON key.
|
||||||
|
type Problem struct {
|
||||||
|
Field string
|
||||||
|
Reason string
|
||||||
|
}
|
||||||
|
|
||||||
|
// ValidationError lists every invalid field of a request.
|
||||||
|
type ValidationError struct {
|
||||||
|
Problems []Problem
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *ValidationError) Error() string {
|
||||||
|
parts := make([]string, len(e.Problems))
|
||||||
|
for i, p := range e.Problems {
|
||||||
|
parts[i] = p.Field + ": " + p.Reason
|
||||||
|
}
|
||||||
|
return "invalid push subscription: " + strings.Join(parts, "; ")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Service stores push subscriptions.
|
||||||
|
type Service struct {
|
||||||
|
st *store.Store
|
||||||
|
publicKey string
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewService returns a Service over st handing out publicKey, the VAPID
|
||||||
|
// public key in vapid's encoding.
|
||||||
|
func NewService(st *store.Store, publicKey string) (*Service, error) {
|
||||||
|
if st == nil {
|
||||||
|
return nil, errors.New("push: nil store")
|
||||||
|
}
|
||||||
|
if _, err := vapid.ParsePublicKey(publicKey); err != nil {
|
||||||
|
return nil, fmt.Errorf("push: VAPID public key: %w", err)
|
||||||
|
}
|
||||||
|
return &Service{st: st, publicKey: publicKey}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// PublicKey returns the VAPID public key, for applicationServerKey.
|
||||||
|
func (s *Service) PublicKey() string { return s.publicKey }
|
||||||
|
|
||||||
|
// Subscribe stores req by its endpoint. An endpoint already stored keeps
|
||||||
|
// its id and created_at and has its keys and user agent replaced, so
|
||||||
|
// subscribing twice leaves one subscription. An invalid request is a
|
||||||
|
// *ValidationError.
|
||||||
|
func (s *Service) Subscribe(ctx context.Context, req SubscribeRequest) (Subscription, error) {
|
||||||
|
var problems []Problem
|
||||||
|
if reason := checkEndpoint(req.Endpoint); reason != "" {
|
||||||
|
problems = append(problems, Problem{"endpoint", reason})
|
||||||
|
}
|
||||||
|
if reason := checkP256dh(req.P256dh); reason != "" {
|
||||||
|
problems = append(problems, Problem{"keys.p256dh", reason})
|
||||||
|
}
|
||||||
|
if reason := checkAuth(req.Auth); reason != "" {
|
||||||
|
problems = append(problems, Problem{"keys.auth", reason})
|
||||||
|
}
|
||||||
|
var ua sql.NullString
|
||||||
|
if req.UserAgent != nil {
|
||||||
|
if reason := checkUserAgent(*req.UserAgent); reason != "" {
|
||||||
|
problems = append(problems, Problem{"user_agent", reason})
|
||||||
|
}
|
||||||
|
ua = sql.NullString{String: *req.UserAgent, Valid: true}
|
||||||
|
}
|
||||||
|
if len(problems) > 0 {
|
||||||
|
return Subscription{}, &ValidationError{Problems: problems}
|
||||||
|
}
|
||||||
|
|
||||||
|
now := store.Now().String()
|
||||||
|
row, err := db.New(s.st.Writer).UpsertPushSubscription(ctx, db.UpsertPushSubscriptionParams{
|
||||||
|
ID: store.NewID(),
|
||||||
|
Endpoint: req.Endpoint,
|
||||||
|
P256dh: req.P256dh,
|
||||||
|
Auth: req.Auth,
|
||||||
|
UserAgent: ua,
|
||||||
|
CreatedAt: now,
|
||||||
|
UpdatedAt: now,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return Subscription{}, fmt.Errorf("push: store subscription: %w", err)
|
||||||
|
}
|
||||||
|
sub := Subscription{ID: row.ID, Endpoint: row.Endpoint, CreatedAt: row.CreatedAt, UpdatedAt: row.UpdatedAt}
|
||||||
|
if row.UserAgent.Valid {
|
||||||
|
sub.UserAgent = &row.UserAgent.String
|
||||||
|
}
|
||||||
|
return sub, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Unsubscribe removes the subscription for endpoint and reports whether
|
||||||
|
// one was stored. An endpoint that is not stored is not an error: the
|
||||||
|
// browser may unsubscribe after a failed delivery already removed it. An
|
||||||
|
// invalid endpoint is a *ValidationError.
|
||||||
|
func (s *Service) Unsubscribe(ctx context.Context, endpoint string) (bool, error) {
|
||||||
|
if reason := checkEndpoint(endpoint); reason != "" {
|
||||||
|
return false, &ValidationError{Problems: []Problem{{"endpoint", reason}}}
|
||||||
|
}
|
||||||
|
n, err := db.New(s.st.Writer).DeletePushSubscription(ctx, endpoint)
|
||||||
|
if err != nil {
|
||||||
|
return false, fmt.Errorf("push: delete subscription: %w", err)
|
||||||
|
}
|
||||||
|
return n > 0, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// checkEndpoint returns why endpoint is not a push service URL, or "".
|
||||||
|
// Push services are always reached over https.
|
||||||
|
func checkEndpoint(endpoint string) string {
|
||||||
|
if endpoint == "" {
|
||||||
|
return "required"
|
||||||
|
}
|
||||||
|
if len(endpoint) > MaxEndpointBytes {
|
||||||
|
return fmt.Sprintf("longer than %d bytes", MaxEndpointBytes)
|
||||||
|
}
|
||||||
|
u, err := url.Parse(endpoint)
|
||||||
|
if err != nil || u.Scheme != "https" || u.Host == "" || u.User != nil || u.Fragment != "" {
|
||||||
|
return "want an absolute https:// URL without credentials or fragment"
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
func checkP256dh(s string) string {
|
||||||
|
if s == "" {
|
||||||
|
return "required"
|
||||||
|
}
|
||||||
|
b, err := keyEncoding.DecodeString(s)
|
||||||
|
if err != nil || len(b) != p256dhSize {
|
||||||
|
return fmt.Sprintf("want the %d-byte uncompressed P-256 point in unpadded base64url", p256dhSize)
|
||||||
|
}
|
||||||
|
if _, err := ecdh.P256().NewPublicKey(b); err != nil {
|
||||||
|
return "not a point on P-256"
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
func checkAuth(s string) string {
|
||||||
|
if s == "" {
|
||||||
|
return "required"
|
||||||
|
}
|
||||||
|
b, err := keyEncoding.DecodeString(s)
|
||||||
|
if err != nil || len(b) != authSize {
|
||||||
|
return fmt.Sprintf("want %d bytes in unpadded base64url", authSize)
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
func checkUserAgent(s string) string {
|
||||||
|
switch {
|
||||||
|
case strings.TrimSpace(s) == "":
|
||||||
|
return "must not be blank; omit it instead"
|
||||||
|
case len(s) > MaxUserAgentBytes:
|
||||||
|
return fmt.Sprintf("longer than %d bytes", MaxUserAgentBytes)
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
@@ -0,0 +1,224 @@
|
|||||||
|
package push
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"crypto/ecdh"
|
||||||
|
"crypto/rand"
|
||||||
|
"encoding/base64"
|
||||||
|
"errors"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/store"
|
||||||
|
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/vapid"
|
||||||
|
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/migrations"
|
||||||
|
)
|
||||||
|
|
||||||
|
const endpoint = "https://fcm.googleapis.com/fcm/send/abc123"
|
||||||
|
|
||||||
|
func newTestService(t *testing.T) (*Service, *store.Store) {
|
||||||
|
t.Helper()
|
||||||
|
ctx := context.Background()
|
||||||
|
st, err := store.Open(ctx, t.TempDir())
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
t.Cleanup(func() {
|
||||||
|
if err := st.Close(); err != nil {
|
||||||
|
t.Error(err)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
if err := store.Migrate(ctx, st.Writer, migrations.FS); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
pair, err := vapid.Generate()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
svc, err := NewService(st, pair.PublicKey)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
return svc, st
|
||||||
|
}
|
||||||
|
|
||||||
|
// browserKeys returns a p256dh and auth as a browser's
|
||||||
|
// PushSubscription.toJSON() gives them.
|
||||||
|
func browserKeys(t *testing.T) (string, string) {
|
||||||
|
t.Helper()
|
||||||
|
k, err := ecdh.P256().GenerateKey(rand.Reader)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
auth := make([]byte, 16)
|
||||||
|
rand.Read(auth)
|
||||||
|
return base64.RawURLEncoding.EncodeToString(k.PublicKey().Bytes()), base64.RawURLEncoding.EncodeToString(auth)
|
||||||
|
}
|
||||||
|
|
||||||
|
type storedRow struct {
|
||||||
|
id, p256dh, auth, createdAt, updatedAt string
|
||||||
|
ua *string
|
||||||
|
failures int
|
||||||
|
}
|
||||||
|
|
||||||
|
func rows(t *testing.T, st *store.Store) map[string]storedRow {
|
||||||
|
t.Helper()
|
||||||
|
rs, err := st.Reader.Query(`SELECT endpoint, id, p256dh, auth, user_agent, failure_count, created_at, updated_at FROM push_subscriptions`)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
defer rs.Close()
|
||||||
|
got := map[string]storedRow{}
|
||||||
|
for rs.Next() {
|
||||||
|
var ep string
|
||||||
|
var r storedRow
|
||||||
|
if err := rs.Scan(&ep, &r.id, &r.p256dh, &r.auth, &r.ua, &r.failures, &r.createdAt, &r.updatedAt); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
got[ep] = r
|
||||||
|
}
|
||||||
|
if err := rs.Err(); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
return got
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestNewServiceRefusesBadPublicKey(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
_, st := newTestService(t)
|
||||||
|
if _, err := NewService(st, "not-a-key"); err == nil {
|
||||||
|
t.Error("NewService accepted a malformed public key")
|
||||||
|
}
|
||||||
|
if _, err := NewService(nil, ""); err == nil {
|
||||||
|
t.Error("NewService accepted a nil store")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSubscribeUpsertsByEndpoint(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
svc, st := newTestService(t)
|
||||||
|
ctx := context.Background()
|
||||||
|
p1, a1 := browserKeys(t)
|
||||||
|
ua := "Mozilla/5.0 (iPad)"
|
||||||
|
|
||||||
|
first, err := svc.Subscribe(ctx, SubscribeRequest{Endpoint: endpoint, P256dh: p1, Auth: a1, UserAgent: &ua})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if first.ID == "" || first.Endpoint != endpoint || first.UserAgent == nil || *first.UserAgent != ua || first.CreatedAt != first.UpdatedAt {
|
||||||
|
t.Errorf("first Subscribe = %+v", first)
|
||||||
|
}
|
||||||
|
if _, err := st.Writer.Exec(`UPDATE push_subscriptions SET failure_count = 3`); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// The same endpoint again, with the same keys and then rotated ones,
|
||||||
|
// updates the one row.
|
||||||
|
again, err := svc.Subscribe(ctx, SubscribeRequest{Endpoint: endpoint, P256dh: p1, Auth: a1, UserAgent: &ua})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
p2, a2 := browserKeys(t)
|
||||||
|
rotated, err := svc.Subscribe(ctx, SubscribeRequest{Endpoint: endpoint, P256dh: p2, Auth: a2})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
for _, s := range []Subscription{again, rotated} {
|
||||||
|
if s.ID != first.ID || s.CreatedAt != first.CreatedAt {
|
||||||
|
t.Errorf("resubscribe = %+v, want id %s and created_at %s kept", s, first.ID, first.CreatedAt)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if rotated.UserAgent != nil {
|
||||||
|
t.Errorf("resubscribe without user_agent kept %q", *rotated.UserAgent)
|
||||||
|
}
|
||||||
|
got := rows(t, st)
|
||||||
|
if len(got) != 1 {
|
||||||
|
t.Fatalf("rows = %d, want 1: %+v", len(got), got)
|
||||||
|
}
|
||||||
|
r := got[endpoint]
|
||||||
|
if r.p256dh != p2 || r.auth != a2 || r.ua != nil || r.failures != 0 || r.updatedAt < r.createdAt {
|
||||||
|
t.Errorf("stored row = %+v, want rotated keys, no user agent, failures cleared", r)
|
||||||
|
}
|
||||||
|
|
||||||
|
// A second browser is a second row.
|
||||||
|
other := endpoint + "-other"
|
||||||
|
if _, err := svc.Subscribe(ctx, SubscribeRequest{Endpoint: other, P256dh: p1, Auth: a1}); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if got := rows(t, st); len(got) != 2 {
|
||||||
|
t.Errorf("rows after a second endpoint = %d, want 2", len(got))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestUnsubscribe(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
svc, st := newTestService(t)
|
||||||
|
ctx := context.Background()
|
||||||
|
p, a := browserKeys(t)
|
||||||
|
for _, ep := range []string{endpoint, endpoint + "-keep"} {
|
||||||
|
if _, err := svc.Subscribe(ctx, SubscribeRequest{Endpoint: ep, P256dh: p, Auth: a}); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
removed, err := svc.Unsubscribe(ctx, endpoint)
|
||||||
|
if err != nil || !removed {
|
||||||
|
t.Errorf("Unsubscribe = %v, %v; want true, nil", removed, err)
|
||||||
|
}
|
||||||
|
got := rows(t, st)
|
||||||
|
if _, ok := got[endpoint]; ok || len(got) != 1 {
|
||||||
|
t.Errorf("rows after Unsubscribe = %+v, want only the other endpoint", got)
|
||||||
|
}
|
||||||
|
removed, err = svc.Unsubscribe(ctx, endpoint)
|
||||||
|
if err != nil || removed {
|
||||||
|
t.Errorf("Unsubscribe of an endpoint not stored = %v, %v; want false, nil", removed, err)
|
||||||
|
}
|
||||||
|
var invalid *ValidationError
|
||||||
|
if _, err := svc.Unsubscribe(ctx, "http://push.example.com/x"); !errors.As(err, &invalid) {
|
||||||
|
t.Errorf("Unsubscribe of an http endpoint = %v, want *ValidationError", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSubscribeValidates(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
svc, st := newTestService(t)
|
||||||
|
p, a := browserKeys(t)
|
||||||
|
pair, err := vapid.Generate()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
blank, long := " ", strings.Repeat("x", MaxUserAgentBytes+1)
|
||||||
|
offCurve := make([]byte, 65)
|
||||||
|
offCurve[0] = 0x04
|
||||||
|
raw := base64.RawURLEncoding.EncodeToString
|
||||||
|
|
||||||
|
for _, tc := range []struct {
|
||||||
|
name string
|
||||||
|
req SubscribeRequest
|
||||||
|
field string
|
||||||
|
}{
|
||||||
|
{"missing endpoint", SubscribeRequest{P256dh: p, Auth: a}, "endpoint"},
|
||||||
|
{"http endpoint", SubscribeRequest{Endpoint: "http://push.example.com/x", P256dh: p, Auth: a}, "endpoint"},
|
||||||
|
{"relative endpoint", SubscribeRequest{Endpoint: "/fcm/send/x", P256dh: p, Auth: a}, "endpoint"},
|
||||||
|
{"endpoint with credentials", SubscribeRequest{Endpoint: "https://u:p@push.example.com/x", P256dh: p, Auth: a}, "endpoint"},
|
||||||
|
{"endpoint too long", SubscribeRequest{Endpoint: "https://push.example.com/" + strings.Repeat("x", MaxEndpointBytes), P256dh: p, Auth: a}, "endpoint"},
|
||||||
|
{"missing p256dh", SubscribeRequest{Endpoint: endpoint, Auth: a}, "keys.p256dh"},
|
||||||
|
{"padded p256dh", SubscribeRequest{Endpoint: endpoint, P256dh: p + "=", Auth: a}, "keys.p256dh"},
|
||||||
|
{"short p256dh", SubscribeRequest{Endpoint: endpoint, P256dh: a, Auth: a}, "keys.p256dh"},
|
||||||
|
{"p256dh off the curve", SubscribeRequest{Endpoint: endpoint, P256dh: raw(offCurve), Auth: a}, "keys.p256dh"},
|
||||||
|
{"private key as p256dh", SubscribeRequest{Endpoint: endpoint, P256dh: pair.PrivateKey, Auth: a}, "keys.p256dh"},
|
||||||
|
{"missing auth", SubscribeRequest{Endpoint: endpoint, P256dh: p}, "keys.auth"},
|
||||||
|
{"long auth", SubscribeRequest{Endpoint: endpoint, P256dh: p, Auth: raw(make([]byte, 32))}, "keys.auth"},
|
||||||
|
{"blank user agent", SubscribeRequest{Endpoint: endpoint, P256dh: p, Auth: a, UserAgent: &blank}, "user_agent"},
|
||||||
|
{"long user agent", SubscribeRequest{Endpoint: endpoint, P256dh: p, Auth: a, UserAgent: &long}, "user_agent"},
|
||||||
|
} {
|
||||||
|
_, err := svc.Subscribe(context.Background(), tc.req)
|
||||||
|
var invalid *ValidationError
|
||||||
|
if !errors.As(err, &invalid) || len(invalid.Problems) != 1 || invalid.Problems[0].Field != tc.field {
|
||||||
|
t.Errorf("%s: Subscribe error = %v, want one problem for %s", tc.name, err, tc.field)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if got := rows(t, st); len(got) != 0 {
|
||||||
|
t.Errorf("invalid requests stored %d rows", len(got))
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,195 @@
|
|||||||
|
package push
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"database/sql"
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"log/slog"
|
||||||
|
"net/http"
|
||||||
|
"net/url"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
webpush "github.com/SherClockHolmes/webpush-go"
|
||||||
|
|
||||||
|
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/db"
|
||||||
|
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/notify"
|
||||||
|
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/store"
|
||||||
|
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/vapid"
|
||||||
|
)
|
||||||
|
|
||||||
|
// pushTTL is how long a push service keeps a notification for a device
|
||||||
|
// that is offline.
|
||||||
|
const pushTTL = 12 * time.Hour
|
||||||
|
|
||||||
|
// maxDrainBytes bounds how much of a push service response body is read.
|
||||||
|
const maxDrainBytes = 4096
|
||||||
|
|
||||||
|
// notice is what the service worker shows (web/src/push/notification.ts).
|
||||||
|
type notice struct {
|
||||||
|
Title string `json:"title"`
|
||||||
|
Body string `json:"body"`
|
||||||
|
URL string `json:"url"`
|
||||||
|
Tag string `json:"tag"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sender is the notify.Sender of the push channel: it sends a notification
|
||||||
|
// to every stored subscription, signed with the VAPID key pair.
|
||||||
|
//
|
||||||
|
// Per subscription, a notification the push service accepts records
|
||||||
|
// last_success_at; one it refuses or cannot be sent increments
|
||||||
|
// failure_count, and a 404 or 410 deletes the subscription, which the push
|
||||||
|
// service no longer has. Send succeeds when at least one subscription
|
||||||
|
// accepted the notification, and when there are no subscriptions at all.
|
||||||
|
//
|
||||||
|
// Errors and logs name a subscription by its id, never by its endpoint,
|
||||||
|
// which is a capability URL.
|
||||||
|
type Sender struct {
|
||||||
|
st *store.Store
|
||||||
|
logger *slog.Logger
|
||||||
|
// subscriber is the VAPID subject as webpush-go takes it: an https://
|
||||||
|
// URL, or the bare email address of a mailto: subject.
|
||||||
|
subscriber string
|
||||||
|
publicKey string
|
||||||
|
privateKey string
|
||||||
|
// client sends the push requests; tests replace it.
|
||||||
|
client webpush.HTTPClient
|
||||||
|
// now is the clock last_success_at is recorded on; tests replace it.
|
||||||
|
now func() time.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewSender returns the push channel's sender over st, signing with the
|
||||||
|
// VAPID key pair publicKey and privateKey (vapid's encoding) and naming
|
||||||
|
// subject, a mailto: URI or https:// URL, as the contact.
|
||||||
|
func NewSender(st *store.Store, logger *slog.Logger, publicKey, privateKey, subject string) (*Sender, error) {
|
||||||
|
if st == nil {
|
||||||
|
return nil, errors.New("push: nil store")
|
||||||
|
}
|
||||||
|
if logger == nil {
|
||||||
|
return nil, errors.New("push: nil logger")
|
||||||
|
}
|
||||||
|
pub, err := vapid.ParsePublicKey(publicKey)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("push: VAPID public key: %w", err)
|
||||||
|
}
|
||||||
|
priv, err := vapid.ParsePrivateKey(privateKey)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("push: VAPID private key: %w", err)
|
||||||
|
}
|
||||||
|
if err := vapid.CheckPair(pub, priv); err != nil {
|
||||||
|
return nil, fmt.Errorf("push: VAPID keys: %w", err)
|
||||||
|
}
|
||||||
|
// webpush-go prefixes mailto: to any subject not starting with https:,
|
||||||
|
// so a mailto: subject is handed over as its bare address.
|
||||||
|
var subscriber string
|
||||||
|
if addr, ok := strings.CutPrefix(subject, "mailto:"); ok && addr != "" {
|
||||||
|
subscriber = addr
|
||||||
|
} else if strings.HasPrefix(subject, "https://") {
|
||||||
|
subscriber = subject
|
||||||
|
} else {
|
||||||
|
return nil, fmt.Errorf("push: VAPID subject %q is not a mailto: URI or https:// URL", subject)
|
||||||
|
}
|
||||||
|
return &Sender{
|
||||||
|
st: st,
|
||||||
|
logger: logger,
|
||||||
|
subscriber: subscriber,
|
||||||
|
publicKey: publicKey,
|
||||||
|
privateKey: privateKey,
|
||||||
|
client: &http.Client{},
|
||||||
|
now: time.Now,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Send sends m to every stored subscription. It returns nil when at least
|
||||||
|
// one accepted it or none is stored, and otherwise an error listing why
|
||||||
|
// each failed, so the outbox retries. A database error is returned at once.
|
||||||
|
func (s *Sender) Send(ctx context.Context, m notify.Message) error {
|
||||||
|
logger := s.logger.With("notification_id", m.ID, "event_key", m.EventKey)
|
||||||
|
subs, err := db.New(s.st.Reader).ListPushSubscriptions(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("push: list subscriptions: %w", err)
|
||||||
|
}
|
||||||
|
if len(subs) == 0 {
|
||||||
|
logger.InfoContext(ctx, "push notification has no subscriptions to send to; nothing sent")
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
message, err := json.Marshal(notice{Title: m.Payload.Title, Body: m.Payload.Body, URL: m.Payload.URL, Tag: m.Payload.Tag})
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("push: encode notification: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
q := db.New(s.st.Writer)
|
||||||
|
delivered := 0
|
||||||
|
var failures []string
|
||||||
|
for _, sub := range subs {
|
||||||
|
status, err := s.sendOne(ctx, message, sub)
|
||||||
|
switch {
|
||||||
|
case err == nil && status >= 200 && status < 300:
|
||||||
|
delivered++
|
||||||
|
at := sql.NullString{String: timestamp(s.now()), Valid: true}
|
||||||
|
if err := q.RecordPushSuccess(ctx, db.RecordPushSuccessParams{LastSuccessAt: at, ID: sub.ID}); err != nil {
|
||||||
|
return fmt.Errorf("push: record success of subscription %s: %w", sub.ID, err)
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
case err == nil && (status == http.StatusNotFound || status == http.StatusGone):
|
||||||
|
failures = append(failures, fmt.Sprintf("subscription %s gone (status %d), removed", sub.ID, status))
|
||||||
|
logger.InfoContext(ctx, "push subscription gone; removed", "subscription_id", sub.ID, "status", status)
|
||||||
|
if err := q.DeletePushSubscriptionByID(ctx, sub.ID); err != nil {
|
||||||
|
return fmt.Errorf("push: remove gone subscription %s: %w", sub.ID, err)
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
case err == nil:
|
||||||
|
failures = append(failures, fmt.Sprintf("subscription %s: status %d", sub.ID, status))
|
||||||
|
default:
|
||||||
|
failures = append(failures, fmt.Sprintf("subscription %s: %v", sub.ID, err))
|
||||||
|
}
|
||||||
|
if err := q.RecordPushFailure(ctx, sub.ID); err != nil {
|
||||||
|
return fmt.Errorf("push: record failure of subscription %s: %w", sub.ID, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if delivered == 0 {
|
||||||
|
return fmt.Errorf("push: no subscription accepted the notification: %s", strings.Join(failures, "; "))
|
||||||
|
}
|
||||||
|
if len(failures) > 0 {
|
||||||
|
logger.WarnContext(ctx, "push notification not accepted by every subscription",
|
||||||
|
"delivered", delivered, "failures", strings.Join(failures, "; "))
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// sendOne sends message to sub and returns the push service's status. Its
|
||||||
|
// error never quotes the endpoint.
|
||||||
|
func (s *Sender) sendOne(ctx context.Context, message []byte, sub db.ListPushSubscriptionsRow) (int, error) {
|
||||||
|
resp, err := webpush.SendNotificationWithContext(ctx, message, &webpush.Subscription{
|
||||||
|
Endpoint: sub.Endpoint,
|
||||||
|
Keys: webpush.Keys{Auth: sub.Auth, P256dh: sub.P256dh},
|
||||||
|
}, &webpush.Options{
|
||||||
|
HTTPClient: s.client,
|
||||||
|
Subscriber: s.subscriber,
|
||||||
|
TTL: int(pushTTL / time.Second),
|
||||||
|
VAPIDPublicKey: s.publicKey,
|
||||||
|
VAPIDPrivateKey: s.privateKey,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
// A *url.Error quotes the request URL, the endpoint; keep only
|
||||||
|
// its cause.
|
||||||
|
var ue *url.Error
|
||||||
|
if errors.As(err, &ue) {
|
||||||
|
return 0, fmt.Errorf("request failed: %w", ue.Err)
|
||||||
|
}
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
// The status is the outcome; the body is read only so the connection
|
||||||
|
// can be reused.
|
||||||
|
io.CopyN(io.Discard, resp.Body, maxDrainBytes)
|
||||||
|
return resp.StatusCode, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// timestamp returns t in the stored UTC RFC 3339 form.
|
||||||
|
func timestamp(t time.Time) string {
|
||||||
|
return store.Timestamp{Time: t.UTC().Truncate(time.Millisecond)}.String()
|
||||||
|
}
|
||||||
@@ -0,0 +1,520 @@
|
|||||||
|
package push
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"crypto/aes"
|
||||||
|
"crypto/cipher"
|
||||||
|
"crypto/ecdh"
|
||||||
|
"crypto/hkdf"
|
||||||
|
"crypto/rand"
|
||||||
|
"crypto/sha256"
|
||||||
|
"database/sql"
|
||||||
|
"encoding/base64"
|
||||||
|
"encoding/binary"
|
||||||
|
"encoding/json"
|
||||||
|
"io"
|
||||||
|
"log/slog"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/household"
|
||||||
|
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/notify"
|
||||||
|
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/store"
|
||||||
|
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/vapid"
|
||||||
|
)
|
||||||
|
|
||||||
|
const subject = "mailto:kitchen@example.com"
|
||||||
|
|
||||||
|
// sentAt is the sender's fake clock.
|
||||||
|
var sentAt = time.Date(2026, 10, 9, 17, 0, 0, 0, time.UTC)
|
||||||
|
|
||||||
|
// device is a browser's push subscription with the private half of its
|
||||||
|
// keys, so a test can read what was sent to it.
|
||||||
|
type device struct {
|
||||||
|
path string
|
||||||
|
key *ecdh.PrivateKey
|
||||||
|
auth []byte
|
||||||
|
}
|
||||||
|
|
||||||
|
func newDevice(t *testing.T, path string) device {
|
||||||
|
t.Helper()
|
||||||
|
k, err := ecdh.P256().GenerateKey(rand.Reader)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
auth := make([]byte, 16)
|
||||||
|
rand.Read(auth)
|
||||||
|
return device{path: path, key: k, auth: auth}
|
||||||
|
}
|
||||||
|
|
||||||
|
// request is one request the push service received.
|
||||||
|
type request struct {
|
||||||
|
path string
|
||||||
|
header http.Header
|
||||||
|
body []byte
|
||||||
|
}
|
||||||
|
|
||||||
|
// pushService is an httptest push service answering each path with its
|
||||||
|
// status.
|
||||||
|
type pushService struct {
|
||||||
|
srv *httptest.Server
|
||||||
|
status map[string]int
|
||||||
|
|
||||||
|
mu sync.Mutex
|
||||||
|
got []request
|
||||||
|
}
|
||||||
|
|
||||||
|
func newPushService(t *testing.T, status map[string]int) *pushService {
|
||||||
|
t.Helper()
|
||||||
|
ps := &pushService{status: status}
|
||||||
|
ps.srv = httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
body, err := io.ReadAll(r.Body)
|
||||||
|
if err != nil {
|
||||||
|
t.Error(err)
|
||||||
|
}
|
||||||
|
ps.mu.Lock()
|
||||||
|
ps.got = append(ps.got, request{path: r.URL.Path, header: r.Header.Clone(), body: body})
|
||||||
|
ps.mu.Unlock()
|
||||||
|
code, ok := ps.status[r.URL.Path]
|
||||||
|
if !ok {
|
||||||
|
t.Errorf("unexpected push to %s", r.URL.Path)
|
||||||
|
code = http.StatusBadRequest
|
||||||
|
}
|
||||||
|
w.WriteHeader(code)
|
||||||
|
}))
|
||||||
|
t.Cleanup(ps.srv.Close)
|
||||||
|
return ps
|
||||||
|
}
|
||||||
|
|
||||||
|
func (ps *pushService) requests() []request {
|
||||||
|
ps.mu.Lock()
|
||||||
|
defer ps.mu.Unlock()
|
||||||
|
return append([]request(nil), ps.got...)
|
||||||
|
}
|
||||||
|
|
||||||
|
// newTestSender returns a sender over a migrated store with the devices
|
||||||
|
// subscribed to ps, and the VAPID key pair it signs with.
|
||||||
|
func newTestSender(t *testing.T, ps *pushService, devices ...device) (*Sender, *store.Store, vapid.KeyPair) {
|
||||||
|
t.Helper()
|
||||||
|
svc, st := newTestService(t)
|
||||||
|
for _, d := range devices {
|
||||||
|
_, err := svc.Subscribe(context.Background(), SubscribeRequest{
|
||||||
|
Endpoint: ps.srv.URL + d.path,
|
||||||
|
P256dh: keyEncoding.EncodeToString(d.key.PublicKey().Bytes()),
|
||||||
|
Auth: keyEncoding.EncodeToString(d.auth),
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
pair, err := vapid.Generate()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
s, err := NewSender(st, slog.New(slog.NewTextHandler(io.Discard, nil)), pair.PublicKey, pair.PrivateKey, subject)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
s.client = ps.srv.Client()
|
||||||
|
s.now = func() time.Time { return sentAt }
|
||||||
|
return s, st, pair
|
||||||
|
}
|
||||||
|
|
||||||
|
func message() notify.Message {
|
||||||
|
return notify.Message{
|
||||||
|
ID: "01NOTIFICATION",
|
||||||
|
EventKey: "plan_ready:2026-10-12",
|
||||||
|
Channel: notify.ChannelPush,
|
||||||
|
Payload: notify.Payload{
|
||||||
|
Event: notify.PlanReady,
|
||||||
|
Title: "This week's plan is ready",
|
||||||
|
Body: "Five dinners on the Draft board.",
|
||||||
|
URL: "/plan",
|
||||||
|
Tag: "plan_ready:2026-10-12",
|
||||||
|
Week: "2026-10-12",
|
||||||
|
},
|
||||||
|
Attempt: 1,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// pushState is a subscription's delivery record.
|
||||||
|
type pushState struct {
|
||||||
|
failures int
|
||||||
|
lastSuccess sql.NullString
|
||||||
|
}
|
||||||
|
|
||||||
|
// states returns the delivery record of each stored subscription by path.
|
||||||
|
func states(t *testing.T, st *store.Store, ps *pushService) map[string]pushState {
|
||||||
|
t.Helper()
|
||||||
|
rs, err := st.Reader.Query(`SELECT endpoint, failure_count, last_success_at FROM push_subscriptions`)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
defer rs.Close()
|
||||||
|
got := map[string]pushState{}
|
||||||
|
for rs.Next() {
|
||||||
|
var ep string
|
||||||
|
var s pushState
|
||||||
|
if err := rs.Scan(&ep, &s.failures, &s.lastSuccess); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
got[strings.TrimPrefix(ep, ps.srv.URL)] = s
|
||||||
|
}
|
||||||
|
if err := rs.Err(); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
return got
|
||||||
|
}
|
||||||
|
|
||||||
|
// decrypt reads an aes128gcm push message body (RFC 8291) as d.
|
||||||
|
func decrypt(t *testing.T, d device, body []byte) []byte {
|
||||||
|
t.Helper()
|
||||||
|
if len(body) < 21 {
|
||||||
|
t.Fatalf("body of %d bytes has no header", len(body))
|
||||||
|
}
|
||||||
|
salt, rs, idLen := body[:16], binary.BigEndian.Uint32(body[16:20]), int(body[20])
|
||||||
|
if rs != 4096 || idLen != 65 {
|
||||||
|
t.Fatalf("record size %d, key id length %d", rs, idLen)
|
||||||
|
}
|
||||||
|
asPublic, ciphertext := body[21:21+idLen], body[21+idLen:]
|
||||||
|
peer, err := ecdh.P256().NewPublicKey(asPublic)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
secret, err := d.key.ECDH(peer)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
info := append(append([]byte("WebPush: info\x00"), d.key.PublicKey().Bytes()...), asPublic...)
|
||||||
|
ikm, err := hkdf.Key(sha256.New, secret, d.auth, string(info), 32)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
cek, err := hkdf.Key(sha256.New, ikm, salt, "Content-Encoding: aes128gcm\x00", 16)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
nonce, err := hkdf.Key(sha256.New, ikm, salt, "Content-Encoding: nonce\x00", 12)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
block, err := aes.NewCipher(cek)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
gcm, err := cipher.NewGCM(block)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
plain, err := gcm.Open(nil, nonce, ciphertext, nil)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("decrypt: %v", err)
|
||||||
|
}
|
||||||
|
plain = bytes.TrimRight(plain, "\x00")
|
||||||
|
if len(plain) == 0 || plain[len(plain)-1] != 0x02 {
|
||||||
|
t.Fatal("no last-record delimiter")
|
||||||
|
}
|
||||||
|
return plain[:len(plain)-1]
|
||||||
|
}
|
||||||
|
|
||||||
|
// vapidClaims checks the VAPID Authorization header names pair's public key
|
||||||
|
// and returns its token's claims (the signature is not checked).
|
||||||
|
func vapidClaims(t *testing.T, header string, pair vapid.KeyPair) map[string]any {
|
||||||
|
t.Helper()
|
||||||
|
token, key, ok := strings.Cut(strings.TrimPrefix(header, "vapid t="), ", k=")
|
||||||
|
if !ok || !strings.HasPrefix(header, "vapid t=") {
|
||||||
|
t.Fatalf("Authorization = %q, want vapid t=…, k=…", header)
|
||||||
|
}
|
||||||
|
if key != pair.PublicKey {
|
||||||
|
t.Errorf("k = %q, want the VAPID public key %q", key, pair.PublicKey)
|
||||||
|
}
|
||||||
|
parts := strings.Split(token, ".")
|
||||||
|
if len(parts) != 3 {
|
||||||
|
t.Fatalf("token has %d parts", len(parts))
|
||||||
|
}
|
||||||
|
raw, err := base64.RawURLEncoding.DecodeString(parts[1])
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
var claims map[string]any
|
||||||
|
if err := json.Unmarshal(raw, &claims); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
return claims
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSendDeliversToEveryDevice(t *testing.T) {
|
||||||
|
phone, tablet := newDevice(t, "/push/phone"), newDevice(t, "/push/tablet")
|
||||||
|
ps := newPushService(t, map[string]int{phone.path: http.StatusCreated, tablet.path: http.StatusCreated})
|
||||||
|
s, st, pair := newTestSender(t, ps, phone, tablet)
|
||||||
|
|
||||||
|
if err := s.Send(context.Background(), message()); err != nil {
|
||||||
|
t.Fatalf("Send: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
reqs := ps.requests()
|
||||||
|
if len(reqs) != 2 {
|
||||||
|
t.Fatalf("push service got %d requests, want 2", len(reqs))
|
||||||
|
}
|
||||||
|
for _, r := range reqs {
|
||||||
|
d := phone
|
||||||
|
if r.path == tablet.path {
|
||||||
|
d = tablet
|
||||||
|
}
|
||||||
|
if got := r.header.Get("TTL"); got != "43200" {
|
||||||
|
t.Errorf("%s: TTL = %q, want 43200 (12 hours)", r.path, got)
|
||||||
|
}
|
||||||
|
if got := r.header.Get("Content-Encoding"); got != "aes128gcm" {
|
||||||
|
t.Errorf("%s: Content-Encoding = %q", r.path, got)
|
||||||
|
}
|
||||||
|
claims := vapidClaims(t, r.header.Get("Authorization"), pair)
|
||||||
|
if claims["sub"] != subject {
|
||||||
|
t.Errorf("%s: VAPID sub = %v, want %s", r.path, claims["sub"], subject)
|
||||||
|
}
|
||||||
|
if claims["aud"] != ps.srv.URL {
|
||||||
|
t.Errorf("%s: VAPID aud = %v, want %s", r.path, claims["aud"], ps.srv.URL)
|
||||||
|
}
|
||||||
|
var got map[string]string
|
||||||
|
if err := json.Unmarshal(decrypt(t, d, r.body), &got); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
want := map[string]string{
|
||||||
|
"title": "This week's plan is ready",
|
||||||
|
"body": "Five dinners on the Draft board.",
|
||||||
|
"url": "/plan",
|
||||||
|
"tag": "plan_ready:2026-10-12",
|
||||||
|
}
|
||||||
|
if len(got) != len(want) {
|
||||||
|
t.Errorf("%s: payload = %v, want %v", r.path, got, want)
|
||||||
|
}
|
||||||
|
for k, v := range want {
|
||||||
|
if got[k] != v {
|
||||||
|
t.Errorf("%s: payload %s = %q, want %q", r.path, k, got[k], v)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
want := pushState{lastSuccess: sql.NullString{String: "2026-10-09T17:00:00Z", Valid: true}}
|
||||||
|
for path, got := range states(t, st, ps) {
|
||||||
|
if got.failures != want.failures || got.lastSuccess.Valid != want.lastSuccess.Valid ||
|
||||||
|
!sameInstant(t, got.lastSuccess.String, want.lastSuccess.String) {
|
||||||
|
t.Errorf("%s: state = %+v, want %+v", path, got, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func sameInstant(t *testing.T, a, b string) bool {
|
||||||
|
t.Helper()
|
||||||
|
ta, err := time.Parse(time.RFC3339Nano, a)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("last_success_at %q is not RFC 3339: %v", a, err)
|
||||||
|
}
|
||||||
|
tb, err := time.Parse(time.RFC3339Nano, b)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
return ta.Equal(tb) && strings.HasSuffix(a, "Z")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSendRemovesGoneSubscriptions(t *testing.T) {
|
||||||
|
for _, status := range []int{http.StatusNotFound, http.StatusGone} {
|
||||||
|
t.Run(http.StatusText(status), func(t *testing.T) {
|
||||||
|
gone := newDevice(t, "/push/gone")
|
||||||
|
ps := newPushService(t, map[string]int{gone.path: status})
|
||||||
|
s, st, _ := newTestSender(t, ps, gone)
|
||||||
|
|
||||||
|
err := s.Send(context.Background(), message())
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("Send with only a gone subscription succeeded, want an error so the outbox retries")
|
||||||
|
}
|
||||||
|
if strings.Contains(err.Error(), ps.srv.URL) {
|
||||||
|
t.Errorf("error %q quotes the endpoint", err)
|
||||||
|
}
|
||||||
|
if got := states(t, st, ps); len(got) != 0 {
|
||||||
|
t.Errorf("subscriptions = %v, want the gone one removed", got)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSendSucceedsWhenOneDeviceAccepts(t *testing.T) {
|
||||||
|
ok, gone, broken := newDevice(t, "/push/ok"), newDevice(t, "/push/gone"), newDevice(t, "/push/broken")
|
||||||
|
ps := newPushService(t, map[string]int{
|
||||||
|
ok.path: http.StatusCreated, gone.path: http.StatusGone, broken.path: http.StatusInternalServerError,
|
||||||
|
})
|
||||||
|
s, st, _ := newTestSender(t, ps, ok, gone, broken)
|
||||||
|
|
||||||
|
if err := s.Send(context.Background(), message()); err != nil {
|
||||||
|
t.Fatalf("Send: %v", err)
|
||||||
|
}
|
||||||
|
got := states(t, st, ps)
|
||||||
|
if len(got) != 2 {
|
||||||
|
t.Fatalf("subscriptions = %v, want ok and broken kept, gone removed", got)
|
||||||
|
}
|
||||||
|
if s := got[ok.path]; !s.lastSuccess.Valid || s.failures != 0 {
|
||||||
|
t.Errorf("ok = %+v, want last_success_at set and no failures", s)
|
||||||
|
}
|
||||||
|
if s := got[broken.path]; s.lastSuccess.Valid || s.failures != 1 {
|
||||||
|
t.Errorf("broken = %+v, want one failure and no success", s)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSendServerErrorIsRetryable(t *testing.T) {
|
||||||
|
broken := newDevice(t, "/push/broken")
|
||||||
|
ps := newPushService(t, map[string]int{broken.path: http.StatusInternalServerError})
|
||||||
|
s, st, _ := newTestSender(t, ps, broken)
|
||||||
|
|
||||||
|
for want := 1; want <= 2; want++ {
|
||||||
|
err := s.Send(context.Background(), message())
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("Send with only a failing subscription succeeded, want an error so the outbox retries")
|
||||||
|
}
|
||||||
|
if !strings.Contains(err.Error(), "status 500") || strings.Contains(err.Error(), ps.srv.URL) {
|
||||||
|
t.Errorf("error = %q, want the status and no endpoint", err)
|
||||||
|
}
|
||||||
|
got := states(t, st, ps)[broken.path]
|
||||||
|
if got.failures != want || got.lastSuccess.Valid {
|
||||||
|
t.Errorf("after send %d: state = %+v, want failure_count %d and no success", want, got, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSendTransportErrorDoesNotQuoteEndpoint(t *testing.T) {
|
||||||
|
d := newDevice(t, "/push/secret-token")
|
||||||
|
ps := newPushService(t, map[string]int{})
|
||||||
|
s, st, _ := newTestSender(t, ps, d)
|
||||||
|
ps.srv.Close()
|
||||||
|
|
||||||
|
err := s.Send(context.Background(), message())
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("Send to a closed push service succeeded")
|
||||||
|
}
|
||||||
|
if strings.Contains(err.Error(), "secret-token") {
|
||||||
|
t.Errorf("error %q quotes the endpoint", err)
|
||||||
|
}
|
||||||
|
if got := states(t, st, ps)[d.path]; got.failures != 1 {
|
||||||
|
t.Errorf("state = %+v, want one failure", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSendWithNoSubscriptionsIsSent(t *testing.T) {
|
||||||
|
ps := newPushService(t, map[string]int{})
|
||||||
|
s, _, _ := newTestSender(t, ps)
|
||||||
|
|
||||||
|
if err := s.Send(context.Background(), message()); err != nil {
|
||||||
|
t.Fatalf("Send with no subscriptions = %v, want nil", err)
|
||||||
|
}
|
||||||
|
if n := len(ps.requests()); n != 0 {
|
||||||
|
t.Errorf("push service got %d requests, want none", n)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestOutboxDelivery runs the sender under the notify worker and checks the
|
||||||
|
// outbox row each outcome leaves.
|
||||||
|
func TestOutboxDelivery(t *testing.T) {
|
||||||
|
cases := []struct {
|
||||||
|
name string
|
||||||
|
status map[string]int // by device path; nil for no devices
|
||||||
|
wantStatus string
|
||||||
|
wantError bool
|
||||||
|
}{
|
||||||
|
{"delivered", map[string]int{"/push/ok": http.StatusCreated}, "sent", false},
|
||||||
|
{"server error", map[string]int{"/push/broken": http.StatusInternalServerError}, "pending", true},
|
||||||
|
{"no subscriptions", nil, "sent", false},
|
||||||
|
}
|
||||||
|
for _, tc := range cases {
|
||||||
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
|
ps := newPushService(t, tc.status)
|
||||||
|
var devices []device
|
||||||
|
for path := range tc.status {
|
||||||
|
devices = append(devices, newDevice(t, path))
|
||||||
|
}
|
||||||
|
s, st, _ := newTestSender(t, ps, devices...)
|
||||||
|
ctx, cancel := context.WithCancel(context.Background())
|
||||||
|
if err := household.SeedNotificationPrefs(ctx, st); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if _, err := st.Writer.Exec(`UPDATE notification_prefs SET push = 1, chat = 0 WHERE event = 'plan_ready'`); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
n, err := notify.NewService(st, slog.New(slog.NewTextHandler(io.Discard, nil)))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := n.Register(notify.ChannelPush, s); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := n.Start(ctx); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
defer func() { cancel(); n.Wait() }()
|
||||||
|
if err := n.Emit(ctx, notify.Event{
|
||||||
|
Type: notify.PlanReady, Key: "plan_ready:2026-10-12", Title: "This week's plan is ready", URL: "/plan",
|
||||||
|
}); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
var status string
|
||||||
|
var attempts int
|
||||||
|
var lastError sql.NullString
|
||||||
|
deadline := time.Now().Add(10 * time.Second)
|
||||||
|
for {
|
||||||
|
err := st.Reader.QueryRow(`SELECT status, attempts, last_error FROM notification_outbox
|
||||||
|
WHERE event_key = 'plan_ready:2026-10-12' AND channel = 'push'`).Scan(&status, &attempts, &lastError)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if attempts > 0 {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
if time.Now().After(deadline) {
|
||||||
|
t.Fatal("the worker did not deliver the notification")
|
||||||
|
}
|
||||||
|
time.Sleep(10 * time.Millisecond)
|
||||||
|
}
|
||||||
|
if status != tc.wantStatus || attempts != 1 || lastError.Valid != tc.wantError {
|
||||||
|
t.Errorf("outbox row: status %s, attempts %d, last_error %v; want %s, 1, error %v",
|
||||||
|
status, attempts, lastError, tc.wantStatus, tc.wantError)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestNewSenderRefusesBadConfig(t *testing.T) {
|
||||||
|
_, st := newTestService(t)
|
||||||
|
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
|
||||||
|
pair, err := vapid.Generate()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
other, err := vapid.Generate()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
cases := map[string]struct{ public, private, subject string }{
|
||||||
|
"bad public key": {"nope", pair.PrivateKey, subject},
|
||||||
|
"bad private key": {pair.PublicKey, "nope", subject},
|
||||||
|
"mismatched pair": {pair.PublicKey, other.PrivateKey, subject},
|
||||||
|
"bad subject": {pair.PublicKey, pair.PrivateKey, "kitchen@example.com"},
|
||||||
|
"empty mailto": {pair.PublicKey, pair.PrivateKey, "mailto:"},
|
||||||
|
}
|
||||||
|
for name, c := range cases {
|
||||||
|
t.Run(name, func(t *testing.T) {
|
||||||
|
_, err := NewSender(st, logger, c.public, c.private, c.subject)
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("NewSender succeeded")
|
||||||
|
}
|
||||||
|
if strings.Contains(err.Error(), pair.PrivateKey) || strings.Contains(err.Error(), other.PrivateKey) {
|
||||||
|
t.Errorf("error %q quotes the private key", err)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
if _, err := NewSender(st, logger, pair.PublicKey, pair.PrivateKey, "https://kitchen.example.com/contact"); err != nil {
|
||||||
|
t.Errorf("https subject: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -29,7 +29,7 @@ func newAIRouter(t *testing.T) http.Handler {
|
|||||||
t.Helper()
|
t.Helper()
|
||||||
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
|
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),
|
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 {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -92,7 +92,7 @@ func newDinnersEnv(t *testing.T) *dinnersEnv {
|
|||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
e.h, err = NewRouter(logger, "dev", nil, stubAuth{}, stubSessions{}, newTestSettings(t), newTestSearch(t), newTestAI(t),
|
e.h, err = NewRouter(logger, "dev", nil, stubAuth{}, stubSessions{}, newTestSettings(t), newTestSearch(t), newTestAI(t),
|
||||||
svc, newTestSuggest(t), newTestPlan(t), newTestShopping(t), newTestGoogle(t), runner, newTestPhotos(t), fstest.MapFS{"index.html": {Data: []byte(fixtureIndex)}})
|
svc, newTestSuggest(t), newTestPlan(t), newTestShopping(t), newTestGoogle(t), newTestPush(t), runner, newTestPhotos(t), fstest.MapFS{"index.html": {Data: []byte(fixtureIndex)}})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -69,7 +69,7 @@ func newGoogleEnv(t *testing.T) *googleEnv {
|
|||||||
}
|
}
|
||||||
logger := slog.New(slog.NewJSONHandler(e.logs, &slog.HandlerOptions{Level: slog.LevelDebug}))
|
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),
|
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)}})
|
fstest.MapFS{"index.html": {Data: []byte(fixtureIndex)}})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
|
|||||||
@@ -83,7 +83,7 @@ func newLoginRouter(t *testing.T, issuer string) http.Handler {
|
|||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
r, err := NewRouter(logger, "dev", trusted, flow, sessions, newTestSettings(t),
|
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 {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -51,7 +51,7 @@ func newMediaRouter(t *testing.T) (r http.Handler, id, orphan string) {
|
|||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
|
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)}})
|
photos, fstest.MapFS{"index.html": {Data: []byte(fixtureIndex)}})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
|
|||||||
@@ -45,7 +45,7 @@ func newOriginRouter(t *testing.T, logs io.Writer) http.Handler {
|
|||||||
}
|
}
|
||||||
logger := slog.New(forwarded.LogHandler(slog.NewJSONHandler(logs, nil)))
|
logger := slog.New(forwarded.LogHandler(slog.NewJSONHandler(logs, nil)))
|
||||||
r, err := NewRouter(logger, "dev", trusted, probeAuth{}, stubSessions{}, newTestSettings(t),
|
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 {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -94,7 +94,7 @@ func newPlansRouterTitled(t *testing.T, title shopping.Titler) plansEnv {
|
|||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
h, err := NewRouter(logger, "dev", nil, stubAuth{}, stubSessions{}, newTestSettings(t), newTestSearch(t), newTestAI(t),
|
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 {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,98 @@
|
|||||||
|
package server
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"log/slog"
|
||||||
|
"net/http"
|
||||||
|
|
||||||
|
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/push"
|
||||||
|
)
|
||||||
|
|
||||||
|
// maxPushBody bounds a POST or DELETE /push/subscriptions body: an endpoint
|
||||||
|
// of push.MaxEndpointBytes, the keys and a user agent of
|
||||||
|
// push.MaxUserAgentBytes, with room for JSON escaping.
|
||||||
|
const maxPushBody = 8 << 10
|
||||||
|
|
||||||
|
type vapidPublicKeyResponse struct {
|
||||||
|
PublicKey string `json:"public_key"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// pushSubscriptionBody is the POST /push/subscriptions body: the
|
||||||
|
// endpoint and keys of PushSubscription.toJSON(), without its
|
||||||
|
// expirationTime, and an optional user_agent.
|
||||||
|
type pushSubscriptionBody struct {
|
||||||
|
Endpoint string `json:"endpoint"`
|
||||||
|
Keys pushSubscriptionKeys `json:"keys"`
|
||||||
|
UserAgent *string `json:"user_agent"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type pushSubscriptionKeys struct {
|
||||||
|
P256dh string `json:"p256dh"`
|
||||||
|
Auth string `json:"auth"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// pushUnsubscribeBody is the DELETE /push/subscriptions body.
|
||||||
|
type pushUnsubscribeBody struct {
|
||||||
|
Endpoint string `json:"endpoint"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// getVAPIDPublicKey answers GET /push/vapid-public-key with the key
|
||||||
|
// browsers pass as applicationServerKey. The private key is never served.
|
||||||
|
func getVAPIDPublicKey(logger *slog.Logger, svc *push.Service) http.HandlerFunc {
|
||||||
|
return func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
writeJSON(w, r, logger, http.StatusOK, vapidPublicKeyResponse{PublicKey: svc.PublicKey()})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// postPushSubscription stores the browser's subscription by its endpoint
|
||||||
|
// and answers 200 with it, without its keys. Posting an endpoint already
|
||||||
|
// stored updates it.
|
||||||
|
func postPushSubscription(logger *slog.Logger, svc *push.Service) http.HandlerFunc {
|
||||||
|
return func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
body, err := decodePatch[pushSubscriptionBody](http.MaxBytesReader(w, r.Body, maxPushBody))
|
||||||
|
if err != nil {
|
||||||
|
writeError(w, r, logger, http.StatusBadRequest, "invalid_request", err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
sub, err := svc.Subscribe(r.Context(), push.SubscribeRequest{
|
||||||
|
Endpoint: body.Endpoint,
|
||||||
|
P256dh: body.Keys.P256dh,
|
||||||
|
Auth: body.Keys.Auth,
|
||||||
|
UserAgent: body.UserAgent,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
writePushError(w, r, logger, "store push subscription", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeJSON(w, r, logger, http.StatusOK, sub)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// deletePushSubscription removes the subscription for the body's endpoint
|
||||||
|
// and answers 204, whether or not it was stored.
|
||||||
|
func deletePushSubscription(logger *slog.Logger, svc *push.Service) http.HandlerFunc {
|
||||||
|
return func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
body, err := decodePatch[pushUnsubscribeBody](http.MaxBytesReader(w, r.Body, maxPushBody))
|
||||||
|
if err != nil {
|
||||||
|
writeError(w, r, logger, http.StatusBadRequest, "invalid_request", err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if _, err := svc.Unsubscribe(r.Context(), body.Endpoint); err != nil {
|
||||||
|
writePushError(w, r, logger, "delete push subscription", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
w.WriteHeader(http.StatusNoContent)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// writePushError maps a push.Service error to its API error: an invalid
|
||||||
|
// subscription is 400 invalid_subscription, anything else 500.
|
||||||
|
func writePushError(w http.ResponseWriter, r *http.Request, logger *slog.Logger, action string, err error) {
|
||||||
|
var invalid *push.ValidationError
|
||||||
|
if errors.As(err, &invalid) {
|
||||||
|
writeError(w, r, logger, http.StatusBadRequest, "invalid_subscription", invalid.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
logger.ErrorContext(r.Context(), action, "error", err)
|
||||||
|
writeError(w, r, logger, http.StatusInternalServerError, "internal", "internal error")
|
||||||
|
}
|
||||||
@@ -0,0 +1,284 @@
|
|||||||
|
package server
|
||||||
|
|
||||||
|
import (
|
||||||
|
"crypto/ecdh"
|
||||||
|
"crypto/rand"
|
||||||
|
"encoding/base64"
|
||||||
|
"encoding/hex"
|
||||||
|
"encoding/json"
|
||||||
|
"io"
|
||||||
|
"log/slog"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
"testing/fstest"
|
||||||
|
|
||||||
|
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/push"
|
||||||
|
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/store"
|
||||||
|
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/vapid"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
vapidPublicKeyPath = "/api/v1/push/vapid-public-key"
|
||||||
|
pushSubscriptionsPath = "/api/v1/push/subscriptions"
|
||||||
|
testPushEndpoint = "https://updates.push.services.mozilla.com/wpush/v2/gAAAAABk"
|
||||||
|
)
|
||||||
|
|
||||||
|
// newTestPush returns a push service over a migrated temp database with a
|
||||||
|
// new VAPID key pair.
|
||||||
|
func newTestPush(t *testing.T) *push.Service {
|
||||||
|
t.Helper()
|
||||||
|
svc, _, _ := newTestPushWith(t)
|
||||||
|
return svc
|
||||||
|
}
|
||||||
|
|
||||||
|
// newTestPushWith is newTestPush also returning its store and key pair.
|
||||||
|
func newTestPushWith(t *testing.T) (*push.Service, *store.Store, vapid.KeyPair) {
|
||||||
|
t.Helper()
|
||||||
|
pair, err := vapid.Generate()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
st := newTestStore(t)
|
||||||
|
svc, err := push.NewService(st, pair.PublicKey)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
return svc, st, pair
|
||||||
|
}
|
||||||
|
|
||||||
|
// newPushRouter returns a router serving svc.
|
||||||
|
func newPushRouter(t *testing.T, svc *push.Service) http.Handler {
|
||||||
|
t.Helper()
|
||||||
|
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
|
||||||
|
r, err := NewRouter(logger, "dev", nil, stubAuth{}, stubSessions{}, newTestSettings(t), newTestSearch(t), newTestAI(t), newTestDinners(t), newTestSuggest(t), newTestPlan(t), newTestShopping(t), newTestGoogle(t), svc, newTestRunner(t),
|
||||||
|
newTestPhotos(t), fstest.MapFS{"index.html": {Data: []byte(fixtureIndex)}})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
return r
|
||||||
|
}
|
||||||
|
|
||||||
|
// subscriptionJSON returns a POST /push/subscriptions body for endpoint
|
||||||
|
// with new browser keys.
|
||||||
|
func subscriptionJSON(t *testing.T, endpoint, userAgent string) string {
|
||||||
|
t.Helper()
|
||||||
|
k, err := ecdh.P256().GenerateKey(rand.Reader)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
auth := make([]byte, 16)
|
||||||
|
rand.Read(auth)
|
||||||
|
body := map[string]any{
|
||||||
|
"endpoint": endpoint,
|
||||||
|
"keys": map[string]string{
|
||||||
|
"p256dh": base64.RawURLEncoding.EncodeToString(k.PublicKey().Bytes()),
|
||||||
|
"auth": base64.RawURLEncoding.EncodeToString(auth),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
if userAgent != "" {
|
||||||
|
body["user_agent"] = userAgent
|
||||||
|
}
|
||||||
|
b, err := json.Marshal(body)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
return string(b)
|
||||||
|
}
|
||||||
|
|
||||||
|
func countPushSubscriptions(t *testing.T, st *store.Store) int {
|
||||||
|
t.Helper()
|
||||||
|
var n int
|
||||||
|
if err := st.Reader.QueryRow(`SELECT count(*) FROM push_subscriptions`).Scan(&n); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
return n
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestNewRouterRequiresPush(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
|
||||||
|
if _, err := NewRouter(logger, "dev", nil, stubAuth{}, stubSessions{}, newTestSettings(t), newTestSearch(t), newTestAI(t), newTestDinners(t), newTestSuggest(t), newTestPlan(t), newTestShopping(t), newTestGoogle(t), nil, newTestRunner(t), newTestPhotos(t), fstest.MapFS{"index.html": {}}); err == nil {
|
||||||
|
t.Fatal("NewRouter accepted a nil push service")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPushAPI(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
svc, st, pair := newTestPushWith(t)
|
||||||
|
router := newPushRouter(t, svc)
|
||||||
|
var bodies []string
|
||||||
|
call := func(method, target, body string) *httptest.ResponseRecorder {
|
||||||
|
t.Helper()
|
||||||
|
var r io.Reader
|
||||||
|
if body != "" {
|
||||||
|
r = strings.NewReader(body)
|
||||||
|
}
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
router.ServeHTTP(rec, signedIn(method, target, r))
|
||||||
|
bodies = append(bodies, rec.Body.String())
|
||||||
|
return rec
|
||||||
|
}
|
||||||
|
decode := func(rec *httptest.ResponseRecorder) map[string]any {
|
||||||
|
t.Helper()
|
||||||
|
var m map[string]any
|
||||||
|
if err := json.Unmarshal(rec.Body.Bytes(), &m); err != nil {
|
||||||
|
t.Fatalf("decode %q: %v", rec.Body.String(), err)
|
||||||
|
}
|
||||||
|
return m
|
||||||
|
}
|
||||||
|
|
||||||
|
rec := call(http.MethodGet, vapidPublicKeyPath, "")
|
||||||
|
if body := decode(rec); rec.Code != http.StatusOK || len(body) != 1 || body["public_key"] != pair.PublicKey {
|
||||||
|
t.Errorf("GET public key = %d %s, want only the public key", rec.Code, rec.Body.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
// Posting the same endpoint twice keeps one subscription, updated.
|
||||||
|
rec = call(http.MethodPost, pushSubscriptionsPath, subscriptionJSON(t, testPushEndpoint, "Firefox on the kitchen tablet"))
|
||||||
|
first := decode(rec)
|
||||||
|
if rec.Code != http.StatusOK || first["endpoint"] != testPushEndpoint || first["user_agent"] != "Firefox on the kitchen tablet" || first["id"] == "" {
|
||||||
|
t.Errorf("POST subscription = %d %s", rec.Code, rec.Body.String())
|
||||||
|
}
|
||||||
|
if _, ok := first["keys"]; ok {
|
||||||
|
t.Errorf("POST response holds the keys: %s", rec.Body.String())
|
||||||
|
}
|
||||||
|
rec = call(http.MethodPost, pushSubscriptionsPath, subscriptionJSON(t, testPushEndpoint, "Firefox 140"))
|
||||||
|
again := decode(rec)
|
||||||
|
if rec.Code != http.StatusOK || again["id"] != first["id"] || again["created_at"] != first["created_at"] || again["user_agent"] != "Firefox 140" {
|
||||||
|
t.Errorf("POST same endpoint = %d %s, want the first subscription updated", rec.Code, rec.Body.String())
|
||||||
|
}
|
||||||
|
if n := countPushSubscriptions(t, st); n != 1 {
|
||||||
|
t.Errorf("subscriptions after posting one endpoint twice = %d, want 1", n)
|
||||||
|
}
|
||||||
|
rec = call(http.MethodPost, pushSubscriptionsPath, subscriptionJSON(t, testPushEndpoint+"-phone", ""))
|
||||||
|
if body := decode(rec); rec.Code != http.StatusOK || body["user_agent"] != nil {
|
||||||
|
t.Errorf("POST second endpoint = %d %s", rec.Code, rec.Body.String())
|
||||||
|
}
|
||||||
|
if n := countPushSubscriptions(t, st); n != 2 {
|
||||||
|
t.Errorf("subscriptions after a second endpoint = %d, want 2", n)
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, bad := range []struct{ body, code string }{
|
||||||
|
{"", "invalid_request"},
|
||||||
|
{`{"endpoint":"` + testPushEndpoint + `","keys":{"p256dh":"x","auth":"y"},"expirationTime":null}`, "invalid_request"},
|
||||||
|
{`{"endpoint":"` + testPushEndpoint + `","keys":{"p256dh":"x","auth":"y"},"extra":1}`, "invalid_request"},
|
||||||
|
{`{"endpoint":"` + testPushEndpoint + `"}`, "invalid_subscription"},
|
||||||
|
{`{"endpoint":"` + testPushEndpoint + `","keys":{"p256dh":"x","auth":"y"}}`, "invalid_subscription"},
|
||||||
|
{subscriptionJSON(t, "http://push.example.com/x", ""), "invalid_subscription"},
|
||||||
|
{`{"endpoint":"https://push.example.com/` + strings.Repeat("x", maxPushBody) + `"}`, "invalid_request"},
|
||||||
|
} {
|
||||||
|
rec := call(http.MethodPost, pushSubscriptionsPath, bad.body)
|
||||||
|
if rec.Code != http.StatusBadRequest || !strings.Contains(rec.Body.String(), `"code":"`+bad.code+`"`) {
|
||||||
|
t.Errorf("POST %.80s = %d %s, want 400 %s", bad.body, rec.Code, rec.Body.String(), bad.code)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if n := countPushSubscriptions(t, st); n != 2 {
|
||||||
|
t.Errorf("subscriptions after invalid posts = %d, want 2", n)
|
||||||
|
}
|
||||||
|
|
||||||
|
// DELETE removes by endpoint and is 204 whether or not it was stored.
|
||||||
|
for i := range 2 {
|
||||||
|
rec = call(http.MethodDelete, pushSubscriptionsPath, `{"endpoint":"`+testPushEndpoint+`"}`)
|
||||||
|
if rec.Code != http.StatusNoContent || rec.Body.Len() != 0 {
|
||||||
|
t.Errorf("DELETE #%d = %d %s, want 204", i+1, rec.Code, rec.Body.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if n := countPushSubscriptions(t, st); n != 1 {
|
||||||
|
t.Errorf("subscriptions after DELETE = %d, want 1", n)
|
||||||
|
}
|
||||||
|
for _, bad := range []string{"", `{}`, `{"endpoint":"not a url"}`} {
|
||||||
|
rec := call(http.MethodDelete, pushSubscriptionsPath, bad)
|
||||||
|
if rec.Code != http.StatusBadRequest {
|
||||||
|
t.Errorf("DELETE %q = %d %s, want 400", bad, rec.Code, rec.Body.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if n := countPushSubscriptions(t, st); n != 1 {
|
||||||
|
t.Errorf("subscriptions after invalid deletes = %d, want 1", n)
|
||||||
|
}
|
||||||
|
|
||||||
|
// The private key never leaves the process, in any encoding.
|
||||||
|
priv, err := base64.RawURLEncoding.DecodeString(pair.PrivateKey)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
leaks := []string{
|
||||||
|
pair.PrivateKey,
|
||||||
|
pair.PrivateKey[:16],
|
||||||
|
base64.StdEncoding.EncodeToString(priv),
|
||||||
|
base64.RawStdEncoding.EncodeToString(priv)[:16],
|
||||||
|
hex.EncodeToString(priv),
|
||||||
|
}
|
||||||
|
for _, b := range bodies {
|
||||||
|
for _, leak := range leaks {
|
||||||
|
if strings.Contains(b, leak) {
|
||||||
|
t.Errorf("response holds the VAPID private key: %s", b)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPushSubscriptionsNeedCSRF(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
svc, st, _ := newTestPushWith(t)
|
||||||
|
router := newPushRouter(t, svc)
|
||||||
|
if _, err := svc.Subscribe(t.Context(), push.SubscribeRequest{Endpoint: testPushEndpoint + "-stored", P256dh: mustP256dh(t), Auth: "AAAAAAAAAAAAAAAAAAAAAA"}); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
for _, tc := range []struct {
|
||||||
|
method, body string
|
||||||
|
}{
|
||||||
|
{http.MethodPost, subscriptionJSON(t, testPushEndpoint, "")},
|
||||||
|
{http.MethodDelete, `{"endpoint":"` + testPushEndpoint + `-stored"}`},
|
||||||
|
} {
|
||||||
|
for _, header := range []*string{nil, ptr(""), ptr("wrong-token")} {
|
||||||
|
req := httptest.NewRequest(tc.method, pushSubscriptionsPath, strings.NewReader(tc.body))
|
||||||
|
req.AddCookie(validSessionCookie)
|
||||||
|
if header != nil {
|
||||||
|
req.Header.Set(CSRFHeader, *header)
|
||||||
|
}
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
router.ServeHTTP(rec, req)
|
||||||
|
if rec.Code != http.StatusForbidden || rec.Body.String() != csrfJSON {
|
||||||
|
t.Errorf("%s without a valid CSRF token (%v) = %d %s, want 403 csrf", tc.method, header, rec.Code, rec.Body.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if n := countPushSubscriptions(t, st); n != 1 {
|
||||||
|
t.Errorf("subscriptions after refused requests = %d, want the 1 stored before", n)
|
||||||
|
}
|
||||||
|
// GET needs no token.
|
||||||
|
req := httptest.NewRequest(http.MethodGet, vapidPublicKeyPath, nil)
|
||||||
|
req.AddCookie(validSessionCookie)
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
router.ServeHTTP(rec, req)
|
||||||
|
if rec.Code != http.StatusOK {
|
||||||
|
t.Errorf("GET public key without CSRF token = %d, want 200", rec.Code)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPushNeedsSession(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
router := newPushRouter(t, newTestPush(t))
|
||||||
|
for _, method := range []string{http.MethodGet, http.MethodPost, http.MethodDelete} {
|
||||||
|
target := pushSubscriptionsPath
|
||||||
|
if method == http.MethodGet {
|
||||||
|
target = vapidPublicKeyPath
|
||||||
|
}
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
router.ServeHTTP(rec, httptest.NewRequest(method, target, nil))
|
||||||
|
if rec.Code != http.StatusUnauthorized {
|
||||||
|
t.Errorf("%s %s without a session = %d, want 401", method, target, rec.Code)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func mustP256dh(t *testing.T) string {
|
||||||
|
t.Helper()
|
||||||
|
k, err := ecdh.P256().GenerateKey(rand.Reader)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
return base64.RawURLEncoding.EncodeToString(k.PublicKey().Bytes())
|
||||||
|
}
|
||||||
@@ -22,7 +22,7 @@ const (
|
|||||||
func newRecipeSearchRouter(t *testing.T, search *recipes.SearchSettingsService) http.Handler {
|
func newRecipeSearchRouter(t *testing.T, search *recipes.SearchSettingsService) http.Handler {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
|
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)}})
|
newTestPhotos(t), fstest.MapFS{"index.html": {Data: []byte(fixtureIndex)}})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ import (
|
|||||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/household"
|
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/household"
|
||||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/jobs"
|
"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/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/recipes"
|
||||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/shopping"
|
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/shopping"
|
||||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/suggest"
|
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/suggest"
|
||||||
@@ -55,12 +56,14 @@ type RouteMounter interface {
|
|||||||
// /api/v1/shopping/items/{id}; googleSvc serves GET
|
// /api/v1/shopping/items/{id}; googleSvc serves GET
|
||||||
// /api/v1/integrations/google and its /connect, /callback and /calendars,
|
// /api/v1/integrations/google and its /connect, /callback and /calendars,
|
||||||
// PUT /api/v1/integrations/google/calendar and DELETE
|
// 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
|
// POST /api/v1/jobs and GET /api/v1/jobs/{id}; photos serves
|
||||||
// GET /media/photos/{id}-{size}.jpg; assets is the web build
|
// GET /media/photos/{id}-{size}.jpg; assets is the web build
|
||||||
// served for every other GET. /health, /auth and the web build are public.
|
// 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.
|
// 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 {
|
if settings == nil {
|
||||||
return nil, errors.New("server: nil household settings service")
|
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 {
|
if googleSvc == nil {
|
||||||
return nil, errors.New("server: nil Google Calendar service")
|
return nil, errors.New("server: nil Google Calendar service")
|
||||||
}
|
}
|
||||||
|
if pushSvc == nil {
|
||||||
|
return nil, errors.New("server: nil push service")
|
||||||
|
}
|
||||||
if runner == nil {
|
if runner == nil {
|
||||||
return nil, errors.New("server: nil job runner")
|
return nil, errors.New("server: nil job runner")
|
||||||
}
|
}
|
||||||
@@ -158,6 +164,9 @@ func NewRouter(logger *slog.Logger, version string, trusted forwarded.Trusted, a
|
|||||||
apiV1.Delete("/integrations/google", deleteGoogle(logger, googleSvc))
|
apiV1.Delete("/integrations/google", deleteGoogle(logger, googleSvc))
|
||||||
apiV1.Get("/integrations/google/calendars", getGoogleCalendars(logger, googleSvc))
|
apiV1.Get("/integrations/google/calendars", getGoogleCalendars(logger, googleSvc))
|
||||||
apiV1.Put("/integrations/google/calendar", putGoogleCalendar(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.Post("/jobs", postJob(logger, runner))
|
||||||
apiV1.Get("/jobs/{id}", getJob(logger, runner))
|
apiV1.Get("/jobs/{id}", getJob(logger, runner))
|
||||||
media.Get("/photos/{name}", getPhoto(logger, photos))
|
media.Get("/photos/{name}", getPhoto(logger, photos))
|
||||||
|
|||||||
@@ -54,7 +54,7 @@ func newTestRouterWith(t *testing.T, version string, settings *household.Setting
|
|||||||
"index.html": {Data: []byte(fixtureIndex)},
|
"index.html": {Data: []byte(fixtureIndex)},
|
||||||
"assets/index-AbC123.js": {Data: []byte(fixtureAsset)},
|
"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 {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
@@ -287,7 +287,7 @@ func TestNewRouterRequiresAISettings(t *testing.T) {
|
|||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
|
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")
|
t.Fatal("NewRouter accepted a nil AI settings service")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -296,7 +296,7 @@ func TestNewRouterRequiresSearchSettings(t *testing.T) {
|
|||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
|
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")
|
t.Fatal("NewRouter accepted a nil recipe search settings service")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -305,7 +305,7 @@ func TestNewRouterRequiresPhotoFiles(t *testing.T) {
|
|||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
|
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")
|
t.Fatal("NewRouter accepted nil photo files")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -314,7 +314,7 @@ func TestNewRouterRequiresSettings(t *testing.T) {
|
|||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
|
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")
|
t.Fatal("NewRouter accepted a nil settings service")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -323,7 +323,7 @@ func TestNewRouterRequiresDinners(t *testing.T) {
|
|||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
|
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")
|
t.Fatal("NewRouter accepted a nil dinners service")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -332,7 +332,7 @@ func TestNewRouterRequiresSuggestions(t *testing.T) {
|
|||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
|
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")
|
t.Fatal("NewRouter accepted a nil suggestions service")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -341,7 +341,7 @@ func TestNewRouterRequiresPlan(t *testing.T) {
|
|||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
|
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")
|
t.Fatal("NewRouter accepted a nil plan service")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -350,7 +350,7 @@ func TestNewRouterRequiresShopping(t *testing.T) {
|
|||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
|
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")
|
t.Fatal("NewRouter accepted a nil shopping service")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -359,7 +359,7 @@ func TestNewRouterRequiresGoogle(t *testing.T) {
|
|||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
|
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")
|
t.Fatal("NewRouter accepted a nil Google Calendar service")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -368,7 +368,7 @@ func TestNewRouterRequiresJobRunner(t *testing.T) {
|
|||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
|
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")
|
t.Fatal("NewRouter accepted a nil job runner")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -277,7 +277,7 @@ func TestHouseholdSettingsWithRealSession(t *testing.T) {
|
|||||||
}
|
}
|
||||||
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
|
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
|
||||||
router, err := NewRouter(logger, "dev", nil, stubAuth{}, sessions, household.NewSettingsService(st),
|
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 {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -45,7 +45,7 @@ func newSuggestionsRouter(t *testing.T, names ...string) (http.Handler, *store.S
|
|||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
h, err := NewRouter(logger, "dev", nil, stubAuth{}, stubSessions{}, newTestSettings(t), newTestSearch(t), newTestAI(t),
|
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 {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,87 @@
|
|||||||
|
// Package vapid generates and validates the VAPID key pair Web Push signs
|
||||||
|
// with (RFC 8292): a P-256 (ES256) key pair in the encoding webpush-go
|
||||||
|
// reads. Both keys are unpadded base64url: the public key is the 65-byte
|
||||||
|
// uncompressed point and the private key the 32-byte scalar.
|
||||||
|
package vapid
|
||||||
|
|
||||||
|
import (
|
||||||
|
"crypto/ecdh"
|
||||||
|
"crypto/rand"
|
||||||
|
"encoding/base64"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
)
|
||||||
|
|
||||||
|
// encoding is unpadded base64url. Strict refuses non-zero trailing bits, so
|
||||||
|
// every key has exactly one accepted spelling.
|
||||||
|
var encoding = base64.RawURLEncoding.Strict()
|
||||||
|
|
||||||
|
const (
|
||||||
|
publicKeySize = 65 // 0x04 || X || Y
|
||||||
|
privateKeySize = 32
|
||||||
|
)
|
||||||
|
|
||||||
|
// KeyPair is a VAPID key pair in its stored encoding.
|
||||||
|
type KeyPair struct {
|
||||||
|
PublicKey string
|
||||||
|
PrivateKey string
|
||||||
|
}
|
||||||
|
|
||||||
|
// Generate returns a new random key pair.
|
||||||
|
func Generate() (KeyPair, error) {
|
||||||
|
k, err := ecdh.P256().GenerateKey(rand.Reader)
|
||||||
|
if err != nil {
|
||||||
|
return KeyPair{}, fmt.Errorf("vapid: generate key: %w", err)
|
||||||
|
}
|
||||||
|
return KeyPair{
|
||||||
|
PublicKey: encoding.EncodeToString(k.PublicKey().Bytes()),
|
||||||
|
PrivateKey: encoding.EncodeToString(k.Bytes()),
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ParsePublicKey parses an encoded public key, which must be a point on
|
||||||
|
// P-256.
|
||||||
|
func ParsePublicKey(s string) (*ecdh.PublicKey, error) {
|
||||||
|
b, err := encoding.DecodeString(s)
|
||||||
|
if err != nil {
|
||||||
|
return nil, errors.New("not unpadded base64url")
|
||||||
|
}
|
||||||
|
if len(b) != publicKeySize || b[0] != 0x04 {
|
||||||
|
return nil, fmt.Errorf("want the %d-byte uncompressed P-256 point, got %d bytes", publicKeySize, len(b))
|
||||||
|
}
|
||||||
|
k, err := ecdh.P256().NewPublicKey(b)
|
||||||
|
if err != nil {
|
||||||
|
return nil, errors.New("not a point on P-256")
|
||||||
|
}
|
||||||
|
return k, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ParsePrivateKey parses an encoded private key. Its errors never quote the
|
||||||
|
// key.
|
||||||
|
func ParsePrivateKey(s string) (*ecdh.PrivateKey, error) {
|
||||||
|
b, err := encoding.DecodeString(s)
|
||||||
|
if err != nil {
|
||||||
|
return nil, errors.New("not unpadded base64url")
|
||||||
|
}
|
||||||
|
if len(b) != privateKeySize {
|
||||||
|
return nil, fmt.Errorf("want a %d-byte P-256 scalar, got %d bytes", privateKeySize, len(b))
|
||||||
|
}
|
||||||
|
k, err := ecdh.P256().NewPrivateKey(b)
|
||||||
|
if err != nil {
|
||||||
|
return nil, errors.New("not a valid P-256 scalar")
|
||||||
|
}
|
||||||
|
return k, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ErrMismatch is returned by CheckPair when the private key's public key is
|
||||||
|
// not the given public key.
|
||||||
|
var ErrMismatch = errors.New("private key does not belong to the public key")
|
||||||
|
|
||||||
|
// CheckPair reports whether private is the private key of public, by
|
||||||
|
// deriving the public point from private and comparing.
|
||||||
|
func CheckPair(public *ecdh.PublicKey, private *ecdh.PrivateKey) error {
|
||||||
|
if !private.PublicKey().Equal(public) {
|
||||||
|
return ErrMismatch
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,92 @@
|
|||||||
|
package vapid
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/base64"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestGenerateRoundTrips(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
a, err := Generate()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
b, err := Generate()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if a == b {
|
||||||
|
t.Fatal("two generated key pairs are equal")
|
||||||
|
}
|
||||||
|
// 65 and 32 bytes in unpadded base64url.
|
||||||
|
if len(a.PublicKey) != 87 || len(a.PrivateKey) != 43 {
|
||||||
|
t.Errorf("key lengths = %d, %d; want 87, 43", len(a.PublicKey), len(a.PrivateKey))
|
||||||
|
}
|
||||||
|
pub, err := ParsePublicKey(a.PublicKey)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
priv, err := ParsePrivateKey(a.PrivateKey)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := CheckPair(pub, priv); err != nil {
|
||||||
|
t.Errorf("CheckPair of a generated pair: %v", err)
|
||||||
|
}
|
||||||
|
other, err := ParsePrivateKey(b.PrivateKey)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := CheckPair(pub, other); err != ErrMismatch {
|
||||||
|
t.Errorf("CheckPair of mismatched keys = %v, want ErrMismatch", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestParseRejectsMalformedKeys(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
pair, err := Generate()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
raw := func(b []byte) string { return base64.RawURLEncoding.EncodeToString(b) }
|
||||||
|
compressed, _ := base64.RawURLEncoding.DecodeString(pair.PublicKey)
|
||||||
|
compressed = append([]byte{0x02}, compressed[1:33]...)
|
||||||
|
offCurve := make([]byte, 65)
|
||||||
|
offCurve[0] = 0x04
|
||||||
|
offCurve[64] = 1
|
||||||
|
order := []byte{ // the P-256 group order n, not a valid scalar
|
||||||
|
0xff, 0xff, 0xff, 0xff, 0x00, 0x00, 0x00, 0x00, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff,
|
||||||
|
0xbc, 0xe6, 0xfa, 0xad, 0xa7, 0x17, 0x9e, 0x84, 0xf3, 0xb9, 0xca, 0xc2, 0xfc, 0x63, 0x25, 0x51,
|
||||||
|
}
|
||||||
|
|
||||||
|
for name, s := range map[string]string{
|
||||||
|
"empty": "",
|
||||||
|
"padded": pair.PublicKey + "=",
|
||||||
|
"standard alpha": strings.NewReplacer("-", "+", "_", "/").Replace(pair.PublicKey) + "+/",
|
||||||
|
"compressed": raw(compressed),
|
||||||
|
"off curve": raw(offCurve),
|
||||||
|
"private key": pair.PrivateKey,
|
||||||
|
} {
|
||||||
|
if _, err := ParsePublicKey(s); err == nil {
|
||||||
|
t.Errorf("ParsePublicKey(%s) succeeded", name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for name, s := range map[string]string{
|
||||||
|
"empty": "",
|
||||||
|
"padded": pair.PrivateKey + "=",
|
||||||
|
"PEM": "-----BEGIN EC PRIVATE KEY-----",
|
||||||
|
"zero": raw(make([]byte, 32)),
|
||||||
|
"group size": raw(order),
|
||||||
|
"public key": pair.PublicKey,
|
||||||
|
} {
|
||||||
|
_, err := ParsePrivateKey(s)
|
||||||
|
if err == nil {
|
||||||
|
t.Errorf("ParsePrivateKey(%s) succeeded", name)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if s != "" && strings.Contains(err.Error(), s) {
|
||||||
|
t.Errorf("ParsePrivateKey(%s) error quotes the key: %v", name, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,58 @@
|
|||||||
|
-- name: GetNotificationPref :one
|
||||||
|
SELECT * FROM notification_prefs
|
||||||
|
WHERE event = ?;
|
||||||
|
|
||||||
|
-- name: InsertNotification :execrows
|
||||||
|
-- Stores a pending notification for one channel, due at once. A row already
|
||||||
|
-- stored for the event key and channel, in any status, is left unchanged and
|
||||||
|
-- no row is inserted.
|
||||||
|
INSERT INTO notification_outbox (
|
||||||
|
id, event_key, channel, payload, status, attempts, next_attempt_at, created_at, updated_at
|
||||||
|
) VALUES (
|
||||||
|
sqlc.arg(id), sqlc.arg(event_key), sqlc.arg(channel), sqlc.arg(payload), 'pending', 0, sqlc.arg(next_attempt_at), sqlc.arg(now), sqlc.arg(now)
|
||||||
|
)
|
||||||
|
ON CONFLICT (event_key, channel) DO NOTHING;
|
||||||
|
|
||||||
|
-- name: ClaimDueNotification :one
|
||||||
|
-- Leases the pending notification due longest ago by moving its
|
||||||
|
-- next_attempt_at to lease_until, and returns it. A claimed row is not due
|
||||||
|
-- again until the lease ends, so no other claim takes it while it is being
|
||||||
|
-- delivered. Returns sql.ErrNoRows when none is due.
|
||||||
|
UPDATE notification_outbox
|
||||||
|
SET next_attempt_at = sqlc.arg(lease_until), updated_at = sqlc.arg(now)
|
||||||
|
WHERE id = (
|
||||||
|
SELECT id FROM notification_outbox
|
||||||
|
WHERE status = 'pending' AND next_attempt_at <= sqlc.arg(now)
|
||||||
|
ORDER BY next_attempt_at, id
|
||||||
|
LIMIT 1
|
||||||
|
)
|
||||||
|
RETURNING *;
|
||||||
|
|
||||||
|
-- name: MarkNotificationSent :execrows
|
||||||
|
-- Records a delivered notification; updated_at is when it was sent. The
|
||||||
|
-- lease must still be the claim's.
|
||||||
|
UPDATE notification_outbox
|
||||||
|
SET status = 'sent', attempts = attempts + 1, next_attempt_at = NULL, updated_at = sqlc.arg(now)
|
||||||
|
WHERE id = sqlc.arg(id) AND status = 'pending' AND next_attempt_at = sqlc.arg(lease_until);
|
||||||
|
|
||||||
|
-- name: RetryNotification :execrows
|
||||||
|
-- Records a failed delivery and when to try again. The lease must still be
|
||||||
|
-- the claim's.
|
||||||
|
UPDATE notification_outbox
|
||||||
|
SET attempts = attempts + 1, next_attempt_at = sqlc.arg(next_attempt_at), last_error = sqlc.arg(last_error), updated_at = sqlc.arg(now)
|
||||||
|
WHERE id = sqlc.arg(id) AND status = 'pending' AND next_attempt_at = sqlc.arg(lease_until);
|
||||||
|
|
||||||
|
-- name: FailNotification :execrows
|
||||||
|
-- Gives up on a notification. attempts counts the delivery attempt that
|
||||||
|
-- failed, if one was made. The lease must still be the claim's.
|
||||||
|
UPDATE notification_outbox
|
||||||
|
SET status = 'failed', attempts = attempts + sqlc.arg(attempted), next_attempt_at = NULL, last_error = sqlc.arg(last_error), updated_at = sqlc.arg(now)
|
||||||
|
WHERE id = sqlc.arg(id) AND status = 'pending' AND next_attempt_at = sqlc.arg(lease_until);
|
||||||
|
|
||||||
|
-- name: NextNotificationDue :one
|
||||||
|
-- Returns when the next pending notification is due. Returns sql.ErrNoRows
|
||||||
|
-- when none is pending.
|
||||||
|
SELECT next_attempt_at FROM notification_outbox
|
||||||
|
WHERE status = 'pending'
|
||||||
|
ORDER BY next_attempt_at
|
||||||
|
LIMIT 1;
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
-- 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 = ?;
|
||||||
|
|
||||||
|
-- name: ListPushSubscriptions :many
|
||||||
|
-- Every stored subscription with its keys, for the push sender, oldest
|
||||||
|
-- first.
|
||||||
|
SELECT id, endpoint, p256dh, auth
|
||||||
|
FROM push_subscriptions
|
||||||
|
ORDER BY created_at, id;
|
||||||
|
|
||||||
|
-- name: RecordPushSuccess :exec
|
||||||
|
-- Records that the push service accepted a notification for a subscription.
|
||||||
|
UPDATE push_subscriptions
|
||||||
|
SET last_success_at = ?
|
||||||
|
WHERE id = ?;
|
||||||
|
|
||||||
|
-- name: RecordPushFailure :exec
|
||||||
|
-- Counts a delivery to a subscription that the push service did not accept.
|
||||||
|
UPDATE push_subscriptions
|
||||||
|
SET failure_count = failure_count + 1
|
||||||
|
WHERE id = ?;
|
||||||
|
|
||||||
|
-- name: DeletePushSubscriptionByID :exec
|
||||||
|
-- Removes a subscription the push service reports gone (404 or 410).
|
||||||
|
DELETE FROM push_subscriptions
|
||||||
|
WHERE id = ?;
|
||||||
@@ -0,0 +1,231 @@
|
|||||||
|
import { describe, expect, it, vi } from 'vitest'
|
||||||
|
import {
|
||||||
|
APP_BADGE,
|
||||||
|
APP_ICON,
|
||||||
|
GENERIC_NOTICE,
|
||||||
|
appPath,
|
||||||
|
clickPath,
|
||||||
|
openNotification,
|
||||||
|
parsePushPayload,
|
||||||
|
planClick,
|
||||||
|
showPush,
|
||||||
|
type ClickWindow,
|
||||||
|
} from './notification.ts'
|
||||||
|
|
||||||
|
const origin = 'https://mealquest.example'
|
||||||
|
|
||||||
|
describe('appPath', () => {
|
||||||
|
it('keeps a same-origin path with its query and fragment', () => {
|
||||||
|
expect(appPath('/plan/shopping?week=2026-10-05#tuesday', origin)).toBe('/plan/shopping?week=2026-10-05#tuesday')
|
||||||
|
})
|
||||||
|
|
||||||
|
it.each([
|
||||||
|
'https://evil.example/plan',
|
||||||
|
'//evil.example/plan',
|
||||||
|
'/\\evil.example/plan',
|
||||||
|
'javascript:alert(1)',
|
||||||
|
'plan',
|
||||||
|
'',
|
||||||
|
])('refuses %s', (raw) => {
|
||||||
|
expect(appPath(raw, origin)).toBeNull()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('resolves dot segments and keeps the result on origin', () => {
|
||||||
|
expect(appPath('/plan/../discover', origin)).toBe('/discover')
|
||||||
|
})
|
||||||
|
|
||||||
|
// Each of these starts with a single / but resolves to a path starting with
|
||||||
|
// //, which a browser reads as another host.
|
||||||
|
it.each([
|
||||||
|
'/..//evil.example/path',
|
||||||
|
'/plan/..//evil.example/path',
|
||||||
|
'/..\\\\evil.example/path',
|
||||||
|
'/./\\evil.example',
|
||||||
|
'/.\\/evil.example',
|
||||||
|
'/%2e%2e//evil.example/path',
|
||||||
|
'/.\t.//evil.example/path',
|
||||||
|
'/..\n//evil.example',
|
||||||
|
])('refuses %j, which resolves to a protocol-relative path', (raw) => {
|
||||||
|
expect(appPath(raw, origin)).toBeNull()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('parsePushPayload', () => {
|
||||||
|
it('reads title, body, tag and url', () => {
|
||||||
|
const text = JSON.stringify({ title: 'Plan ready', body: 'Your Draft board is ready.', tag: 'plan_ready', url: '/plan' })
|
||||||
|
expect(parsePushPayload(text, origin)).toEqual({
|
||||||
|
notice: { title: 'Plan ready', body: 'Your Draft board is ready.', tag: 'plan_ready', url: '/plan' },
|
||||||
|
problem: null,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it('opens home with an empty body and no tag when only a title is sent', () => {
|
||||||
|
expect(parsePushPayload(JSON.stringify({ title: 'Hi' }), origin)).toEqual({
|
||||||
|
notice: { title: 'Hi', body: '', url: '/' },
|
||||||
|
problem: null,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it('ignores fields it does not know', () => {
|
||||||
|
const parsed = parsePushPayload(JSON.stringify({ title: 'Hi', event: 'plan_ready' }), origin)
|
||||||
|
expect(parsed.problem).toBeNull()
|
||||||
|
expect(parsed.notice).toEqual({ title: 'Hi', body: '', url: '/' })
|
||||||
|
})
|
||||||
|
|
||||||
|
it.each([
|
||||||
|
[null, 'push message has no payload'],
|
||||||
|
['', 'push message has no payload'],
|
||||||
|
['\u0000ÿ not json', 'push payload is not JSON'],
|
||||||
|
['[1,2]', 'push payload is not a JSON object'],
|
||||||
|
['null', 'push payload is not a JSON object'],
|
||||||
|
['{"body":"no title"}', 'push payload has no title'],
|
||||||
|
['{"title":" "}', 'push payload has no title'],
|
||||||
|
['{"title":42}', 'push payload has no title'],
|
||||||
|
['{"title":"Hi","body":7}', 'push payload body is not a string'],
|
||||||
|
['{"title":"Hi","tag":""}', 'push payload tag is not a non-empty string'],
|
||||||
|
['{"title":"Hi","url":"https://evil.example/"}', 'push payload url is not a same-origin path'],
|
||||||
|
['{"title":"Hi","url":5}', 'push payload url is not a same-origin path'],
|
||||||
|
])('shows the generic notice for %j', (text, problem) => {
|
||||||
|
expect(parsePushPayload(text, origin)).toEqual({ notice: GENERIC_NOTICE, problem })
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('showPush', () => {
|
||||||
|
it('shows the payload with the app icon and badge, its tag and its url as data', async () => {
|
||||||
|
const registration = { showNotification: vi.fn(async () => {}) }
|
||||||
|
const warn = vi.fn()
|
||||||
|
await showPush(
|
||||||
|
JSON.stringify({ title: 'Supply run ready', body: 'Time to shop.', tag: 'list_ready', url: '/plan/shopping' }),
|
||||||
|
registration,
|
||||||
|
origin,
|
||||||
|
warn,
|
||||||
|
)
|
||||||
|
expect(registration.showNotification).toHaveBeenCalledWith('Supply run ready', {
|
||||||
|
body: 'Time to shop.',
|
||||||
|
tag: 'list_ready',
|
||||||
|
icon: APP_ICON,
|
||||||
|
badge: APP_BADGE,
|
||||||
|
data: { url: '/plan/shopping' },
|
||||||
|
})
|
||||||
|
expect(warn).not.toHaveBeenCalled()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('shows the generic notification for a malformed payload and logs why, without throwing', async () => {
|
||||||
|
const registration = { showNotification: vi.fn(async () => {}) }
|
||||||
|
const warn = vi.fn()
|
||||||
|
await expect(showPush('{not json', registration, origin, warn)).resolves.toBeUndefined()
|
||||||
|
expect(registration.showNotification).toHaveBeenCalledWith('MealQuest', {
|
||||||
|
body: GENERIC_NOTICE.body,
|
||||||
|
icon: APP_ICON,
|
||||||
|
badge: APP_BADGE,
|
||||||
|
data: { url: '/' },
|
||||||
|
})
|
||||||
|
expect(warn).toHaveBeenCalledWith('MealQuest: push payload is not JSON; showing the generic notification')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('clickPath', () => {
|
||||||
|
it('opens the url the notification carries', () => {
|
||||||
|
expect(clickPath({ url: '/discover' }, origin)).toBe('/discover')
|
||||||
|
})
|
||||||
|
|
||||||
|
it.each([undefined, null, 'x', {}, { url: 'https://evil.example/' }])('opens home for data %j', (data) => {
|
||||||
|
expect(clickPath(data, origin)).toBe('/')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('planClick', () => {
|
||||||
|
it('refuses a path that resolves off origin', () => {
|
||||||
|
expect(() => planClick([], '//evil.example/path', origin)).toThrow(/resolves off origin/)
|
||||||
|
})
|
||||||
|
|
||||||
|
const at = (path: string, focused = false) => ({ url: origin + path, focused })
|
||||||
|
|
||||||
|
it('opens a new window when no MealQuest window is open', () => {
|
||||||
|
expect(planClick([], '/plan', origin)).toEqual({ action: 'open', url: `${origin}/plan` })
|
||||||
|
})
|
||||||
|
|
||||||
|
it('ignores windows on another origin', () => {
|
||||||
|
const other = { url: 'https://other.example/plan', focused: true }
|
||||||
|
expect(planClick([other], '/plan', origin)).toEqual({ action: 'open', url: `${origin}/plan` })
|
||||||
|
})
|
||||||
|
|
||||||
|
it('focuses the focused MealQuest window and navigates it', () => {
|
||||||
|
const first = at('/')
|
||||||
|
const focused = at('/discover', true)
|
||||||
|
expect(planClick([first, focused], '/plan', origin)).toEqual({
|
||||||
|
action: 'focus',
|
||||||
|
window: focused,
|
||||||
|
url: `${origin}/plan`,
|
||||||
|
navigate: true,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it('focuses the first MealQuest window when none is focused', () => {
|
||||||
|
const first = at('/')
|
||||||
|
expect(planClick([first, at('/discover')], '/plan', origin)).toMatchObject({ action: 'focus', window: first })
|
||||||
|
})
|
||||||
|
|
||||||
|
it('does not reload a window already at the url', () => {
|
||||||
|
expect(planClick([at('/plan')], '/plan', origin)).toMatchObject({ action: 'focus', navigate: false })
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('openNotification', () => {
|
||||||
|
function window(path: string, focused = false) {
|
||||||
|
return {
|
||||||
|
url: origin + path,
|
||||||
|
focused,
|
||||||
|
focus: vi.fn(async () => {}),
|
||||||
|
navigate: vi.fn(async () => {}),
|
||||||
|
} satisfies ClickWindow
|
||||||
|
}
|
||||||
|
|
||||||
|
function clients(windows: ClickWindow[]) {
|
||||||
|
return { matchAll: vi.fn(async () => windows), openWindow: vi.fn(async () => null) }
|
||||||
|
}
|
||||||
|
|
||||||
|
it('focuses an open window, then navigates it to the url', async () => {
|
||||||
|
const win = window('/', true)
|
||||||
|
const all = clients([win])
|
||||||
|
await openNotification({ url: '/plan/shopping' }, all, origin)
|
||||||
|
expect(all.matchAll).toHaveBeenCalledWith({ type: 'window' })
|
||||||
|
expect(win.focus).toHaveBeenCalled()
|
||||||
|
expect(win.navigate).toHaveBeenCalledWith(`${origin}/plan/shopping`)
|
||||||
|
expect(win.focus.mock.invocationCallOrder[0]).toBeLessThan(win.navigate.mock.invocationCallOrder[0]!)
|
||||||
|
expect(all.openWindow).not.toHaveBeenCalled()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('opens a new window at the url when none is open', async () => {
|
||||||
|
const all = clients([])
|
||||||
|
await openNotification({ url: '/discover' }, all, origin)
|
||||||
|
expect(all.openWindow).toHaveBeenCalledWith(`${origin}/discover`)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('never opens a url off origin, even from tampered data', async () => {
|
||||||
|
const all = clients([])
|
||||||
|
await openNotification({ url: 'https://evil.example/' }, all, origin)
|
||||||
|
expect(all.openWindow).toHaveBeenCalledWith(`${origin}/`)
|
||||||
|
})
|
||||||
|
|
||||||
|
it.each(['/..//evil.example/path', '/..\\\\evil.example/path', '/%2e%2e//evil.example/path', '/.\t.//evil.example/path'])(
|
||||||
|
'never leaves origin for %j, which resolves to a protocol-relative path',
|
||||||
|
async (url) => {
|
||||||
|
const win = window('/plan', true)
|
||||||
|
const all = clients([win])
|
||||||
|
await openNotification({ url }, all, origin)
|
||||||
|
expect(win.navigate).toHaveBeenCalledWith(`${origin}/`)
|
||||||
|
expect(all.openWindow).not.toHaveBeenCalled()
|
||||||
|
|
||||||
|
const none = clients([])
|
||||||
|
await openNotification({ url }, none, origin)
|
||||||
|
expect(none.openWindow).toHaveBeenCalledWith(`${origin}/`)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
it('still opens an internal path that uses dot segments', async () => {
|
||||||
|
const all = clients([])
|
||||||
|
await openNotification({ url: '/plan/../discover' }, all, origin)
|
||||||
|
expect(all.openWindow).toHaveBeenCalledWith(`${origin}/discover`)
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,174 @@
|
|||||||
|
// Web Push notifications as the service worker shows them, and where a tap on
|
||||||
|
// one takes the household. The worker's push and notificationclick listeners
|
||||||
|
// (src/sw.ts) only adapt these to the worker's globals, so every decision runs
|
||||||
|
// under test without a real ServiceWorkerGlobalScope. This file is checked
|
||||||
|
// with both the DOM and the WebWorker libraries, so it names only the narrow
|
||||||
|
// shapes it uses.
|
||||||
|
|
||||||
|
// APP_ICON and APP_BADGE are the app icon from the web manifest.
|
||||||
|
export const APP_ICON = '/icons/icon-192.png'
|
||||||
|
export const APP_BADGE = '/icons/icon-192.png'
|
||||||
|
|
||||||
|
// PushNotice is a notification to show: its title, body and tag, and the
|
||||||
|
// same-origin path a tap opens.
|
||||||
|
export type PushNotice = { title: string; body: string; tag?: string; url: string }
|
||||||
|
|
||||||
|
// GENERIC_NOTICE is shown for a push whose payload is not a notice: the
|
||||||
|
// household still learns that MealQuest has something for them.
|
||||||
|
export const GENERIC_NOTICE: PushNotice = {
|
||||||
|
title: 'MealQuest',
|
||||||
|
body: "There's something new in MealQuest.",
|
||||||
|
url: '/',
|
||||||
|
}
|
||||||
|
|
||||||
|
// ParsedPush is the notice to show for a push, and why its payload was not
|
||||||
|
// used, when it was not.
|
||||||
|
export type ParsedPush = { notice: PushNotice; problem: string | null }
|
||||||
|
|
||||||
|
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||||
|
return typeof value === 'object' && value !== null && !Array.isArray(value)
|
||||||
|
}
|
||||||
|
|
||||||
|
// appPath returns raw as a path on origin (path, query and fragment), or null
|
||||||
|
// when raw is not a path-absolute URL that stays on origin. A payload never
|
||||||
|
// sends the household to another site. The path is checked after the parser
|
||||||
|
// has resolved it: dot segments (also as %2e), backslashes and stripped tabs
|
||||||
|
// and newlines can all turn an innocent-looking path into one starting with
|
||||||
|
// //, which a browser would read as another host.
|
||||||
|
export function appPath(raw: string, origin: string): string | null {
|
||||||
|
if (!raw.startsWith('/') || raw.startsWith('//')) return null
|
||||||
|
let url: URL
|
||||||
|
try {
|
||||||
|
url = new URL(raw, origin)
|
||||||
|
} catch {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
if (url.origin !== origin || url.pathname.startsWith('//')) return null
|
||||||
|
return url.pathname + url.search + url.hash
|
||||||
|
}
|
||||||
|
|
||||||
|
// parsePushPayload reads a push message's payload, which may be any bytes:
|
||||||
|
// a JSON object with a non-empty string title, and optionally a string body,
|
||||||
|
// a non-empty string tag and a url that is a same-origin path. Other fields
|
||||||
|
// are ignored. Anything else — no payload, not JSON, a missing title, a
|
||||||
|
// field of the wrong type, a url off origin — is shown as GENERIC_NOTICE,
|
||||||
|
// with the problem for the worker to log. It never throws.
|
||||||
|
export function parsePushPayload(text: string | null, origin: string): ParsedPush {
|
||||||
|
const generic = (problem: string): ParsedPush => ({ notice: GENERIC_NOTICE, problem })
|
||||||
|
if (text === null || text === '') return generic('push message has no payload')
|
||||||
|
let payload: unknown
|
||||||
|
try {
|
||||||
|
payload = JSON.parse(text)
|
||||||
|
} catch {
|
||||||
|
return generic('push payload is not JSON')
|
||||||
|
}
|
||||||
|
if (!isRecord(payload)) return generic('push payload is not a JSON object')
|
||||||
|
const { title, body, tag, url } = payload
|
||||||
|
if (typeof title !== 'string' || title.trim() === '') return generic('push payload has no title')
|
||||||
|
if (body !== undefined && typeof body !== 'string') return generic('push payload body is not a string')
|
||||||
|
if (tag !== undefined && (typeof tag !== 'string' || tag === '')) {
|
||||||
|
return generic('push payload tag is not a non-empty string')
|
||||||
|
}
|
||||||
|
let path = '/'
|
||||||
|
if (url !== undefined) {
|
||||||
|
const safe = typeof url === 'string' ? appPath(url, origin) : null
|
||||||
|
if (safe === null) return generic('push payload url is not a same-origin path')
|
||||||
|
path = safe
|
||||||
|
}
|
||||||
|
const notice: PushNotice = { title, body: body ?? '', url: path }
|
||||||
|
if (tag !== undefined) notice.tag = tag
|
||||||
|
return { notice, problem: null }
|
||||||
|
}
|
||||||
|
|
||||||
|
// NoticeData is the data a shown notification carries for its click.
|
||||||
|
export type NoticeData = { url: string }
|
||||||
|
|
||||||
|
// notificationOptions is what showNotification is given for notice: its body
|
||||||
|
// and tag, the app icon and badge, and the path to open as its data.
|
||||||
|
export function notificationOptions(notice: PushNotice): NotificationOptions & { data: NoticeData } {
|
||||||
|
const options: NotificationOptions & { data: NoticeData } = {
|
||||||
|
body: notice.body,
|
||||||
|
icon: APP_ICON,
|
||||||
|
badge: APP_BADGE,
|
||||||
|
data: { url: notice.url },
|
||||||
|
}
|
||||||
|
if (notice.tag !== undefined) options.tag = notice.tag
|
||||||
|
return options
|
||||||
|
}
|
||||||
|
|
||||||
|
export type NotificationShower = {
|
||||||
|
showNotification(title: string, options: NotificationOptions): Promise<void>
|
||||||
|
}
|
||||||
|
|
||||||
|
// showPush shows the notification for a push message's payload text,
|
||||||
|
// reporting through warn why a payload was not used.
|
||||||
|
export function showPush(
|
||||||
|
text: string | null,
|
||||||
|
registration: NotificationShower,
|
||||||
|
origin: string,
|
||||||
|
warn: (message: string) => void,
|
||||||
|
): Promise<void> {
|
||||||
|
const { notice, problem } = parsePushPayload(text, origin)
|
||||||
|
if (problem !== null) warn(`MealQuest: ${problem}; showing the generic notification`)
|
||||||
|
return registration.showNotification(notice.title, notificationOptions(notice))
|
||||||
|
}
|
||||||
|
|
||||||
|
// clickPath is the same-origin path a tapped notification opens: the url in
|
||||||
|
// its data, which showPush put there, or the app's home when the data is not
|
||||||
|
// ours (a notification shown by an older worker).
|
||||||
|
export function clickPath(data: unknown, origin: string): string {
|
||||||
|
if (isRecord(data) && typeof data.url === 'string') {
|
||||||
|
const path = appPath(data.url, origin)
|
||||||
|
if (path !== null) return path
|
||||||
|
}
|
||||||
|
return '/'
|
||||||
|
}
|
||||||
|
|
||||||
|
// AppWindow is the part of a window client a click reads.
|
||||||
|
export type AppWindow = { url: string; focused: boolean }
|
||||||
|
|
||||||
|
// ClickPlan is what a notification tap does: focus an open MealQuest window
|
||||||
|
// and navigate it to url, or open a new window at url. url is absolute.
|
||||||
|
export type ClickPlan<W> = { action: 'focus'; window: W; url: string; navigate: boolean } | { action: 'open'; url: string }
|
||||||
|
|
||||||
|
// planClick decides a tap on a notification that opens path: the focused
|
||||||
|
// MealQuest window if there is one, else the first open one, navigated to
|
||||||
|
// path unless it is already there; with none open, a new window. A path that
|
||||||
|
// resolves off origin is an error: a tap never leaves MealQuest.
|
||||||
|
export function planClick<W extends AppWindow>(windows: readonly W[], path: string, origin: string): ClickPlan<W> {
|
||||||
|
const resolved = new URL(path, origin)
|
||||||
|
if (resolved.origin !== origin) {
|
||||||
|
throw new Error(`MealQuest: notification path ${JSON.stringify(path)} resolves off origin to ${resolved.origin}`)
|
||||||
|
}
|
||||||
|
const url = resolved.href
|
||||||
|
const ours = windows.filter((w) => URL.canParse(w.url) && new URL(w.url).origin === origin)
|
||||||
|
const target = ours.find((w) => w.focused) ?? ours[0]
|
||||||
|
if (target === undefined) return { action: 'open', url }
|
||||||
|
return { action: 'focus', window: target, url, navigate: target.url !== url }
|
||||||
|
}
|
||||||
|
|
||||||
|
export type ClickWindow = AppWindow & {
|
||||||
|
focus(): Promise<unknown>
|
||||||
|
navigate(url: string): Promise<unknown>
|
||||||
|
}
|
||||||
|
|
||||||
|
export type ClickClients<W extends ClickWindow> = {
|
||||||
|
matchAll(options: { type: 'window' }): Promise<readonly W[]>
|
||||||
|
openWindow(url: string): Promise<unknown>
|
||||||
|
}
|
||||||
|
|
||||||
|
// openNotification carries out a tap on a notification with data: focus
|
||||||
|
// first, while the tap still allows it, then navigate.
|
||||||
|
export async function openNotification<W extends ClickWindow>(
|
||||||
|
data: unknown,
|
||||||
|
clients: ClickClients<W>,
|
||||||
|
origin: string,
|
||||||
|
): Promise<void> {
|
||||||
|
const plan = planClick(await clients.matchAll({ type: 'window' }), clickPath(data, origin), origin)
|
||||||
|
if (plan.action === 'open') {
|
||||||
|
await clients.openWindow(plan.url)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
await plan.window.focus()
|
||||||
|
if (plan.navigate) await plan.window.navigate(plan.url)
|
||||||
|
}
|
||||||
@@ -0,0 +1,308 @@
|
|||||||
|
import { describe, expect, it, vi } from 'vitest'
|
||||||
|
import { ApiError, type ApiRequest } from '../api/index.ts'
|
||||||
|
import {
|
||||||
|
SUBSCRIPTIONS_PATH,
|
||||||
|
VAPID_PUBLIC_KEY_PATH,
|
||||||
|
decodeVapidKey,
|
||||||
|
encodeBase64url,
|
||||||
|
PermissionGestureError,
|
||||||
|
subscribePush,
|
||||||
|
subscriptionBody,
|
||||||
|
unsubscribePush,
|
||||||
|
type BrowserPushSubscription,
|
||||||
|
type PushEnvironment,
|
||||||
|
} from './subscribe.ts'
|
||||||
|
|
||||||
|
function bytes(length: number, first: number): Uint8Array<ArrayBuffer> {
|
||||||
|
const out = new Uint8Array(new ArrayBuffer(length))
|
||||||
|
for (let i = 0; i < length; i++) out[i] = (first + i) % 256
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
const vapidKey = bytes(65, 4)
|
||||||
|
const vapidKeyText = encodeBase64url(vapidKey.buffer)
|
||||||
|
const p256dh = bytes(65, 4).buffer
|
||||||
|
const auth = bytes(16, 200).buffer
|
||||||
|
const endpoint = 'https://push.example/send/abc'
|
||||||
|
|
||||||
|
function subscription(key: ArrayBuffer | null = vapidKey.buffer, at = endpoint) {
|
||||||
|
return {
|
||||||
|
endpoint: at,
|
||||||
|
options: { applicationServerKey: key },
|
||||||
|
getKey: (name: 'p256dh' | 'auth') => (name === 'p256dh' ? p256dh : auth),
|
||||||
|
unsubscribe: vi.fn(async () => true),
|
||||||
|
} satisfies BrowserPushSubscription
|
||||||
|
}
|
||||||
|
|
||||||
|
type Setup = {
|
||||||
|
permission?: NotificationPermission
|
||||||
|
answer?: NotificationPermission
|
||||||
|
active?: boolean
|
||||||
|
current?: BrowserPushSubscription | null
|
||||||
|
registered?: boolean
|
||||||
|
subscribeError?: unknown
|
||||||
|
}
|
||||||
|
|
||||||
|
function setup({
|
||||||
|
permission = 'default',
|
||||||
|
answer = 'granted',
|
||||||
|
active = true,
|
||||||
|
current = null,
|
||||||
|
registered = true,
|
||||||
|
subscribeError,
|
||||||
|
}: Setup = {}) {
|
||||||
|
const created = subscription()
|
||||||
|
const pushManager = {
|
||||||
|
getSubscription: vi.fn(async () => current),
|
||||||
|
subscribe: vi.fn(async () => {
|
||||||
|
if (subscribeError !== undefined) throw subscribeError
|
||||||
|
return created
|
||||||
|
}),
|
||||||
|
}
|
||||||
|
const notification = { permission, requestPermission: vi.fn(async () => answer) }
|
||||||
|
const env: PushEnvironment = {
|
||||||
|
serviceWorker: { getRegistration: vi.fn(async () => (registered ? { pushManager } : undefined)) },
|
||||||
|
pushManager: true,
|
||||||
|
notification,
|
||||||
|
userActivation: { isActive: active },
|
||||||
|
userAgent: 'Mozilla/5.0 Test',
|
||||||
|
}
|
||||||
|
const calls: { path: string; request?: ApiRequest }[] = []
|
||||||
|
const api = {
|
||||||
|
apiFetch: vi.fn(async (path: string, request?: ApiRequest) => {
|
||||||
|
calls.push({ path, request })
|
||||||
|
if (path === VAPID_PUBLIC_KEY_PATH) return { public_key: vapidKeyText }
|
||||||
|
return undefined
|
||||||
|
}) as <T>(path: string, request?: ApiRequest) => Promise<T>,
|
||||||
|
}
|
||||||
|
return { env, api, calls, pushManager, notification, created }
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('decodeVapidKey', () => {
|
||||||
|
it('decodes the unpadded base64url point', () => {
|
||||||
|
expect(decodeVapidKey(vapidKeyText)).toEqual(vapidKey)
|
||||||
|
})
|
||||||
|
|
||||||
|
it.each(['', 'abc=', 'not base64!', encodeBase64url(bytes(64, 4).buffer), encodeBase64url(bytes(65, 2).buffer)])(
|
||||||
|
'refuses %j',
|
||||||
|
(key) => {
|
||||||
|
expect(() => decodeVapidKey(key)).toThrow(/65-byte uncompressed P-256 point/)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('subscriptionBody', () => {
|
||||||
|
it('sends exactly the endpoint, the keys in unpadded base64url and the user agent', () => {
|
||||||
|
const body = subscriptionBody(subscription(), 'Mozilla/5.0 Test')
|
||||||
|
expect(body).toEqual({
|
||||||
|
endpoint,
|
||||||
|
keys: { p256dh: encodeBase64url(p256dh), auth: encodeBase64url(auth) },
|
||||||
|
user_agent: 'Mozilla/5.0 Test',
|
||||||
|
})
|
||||||
|
expect(body.keys.auth).toMatch(/^[A-Za-z0-9_-]{22}$/)
|
||||||
|
expect(JSON.stringify(body)).not.toContain('expirationTime')
|
||||||
|
})
|
||||||
|
|
||||||
|
it.each(['', ' ', 'x'.repeat(513)])('leaves out a blank or too-long user agent', (userAgent) => {
|
||||||
|
expect(subscriptionBody(subscription(), userAgent)).not.toHaveProperty('user_agent')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('refuses a subscription without keys', () => {
|
||||||
|
const keyless = { ...subscription(), getKey: () => null }
|
||||||
|
expect(() => subscriptionBody(keyless, 'ua')).toThrow(/without its p256dh and auth keys/)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('subscribePush', () => {
|
||||||
|
it.each([
|
||||||
|
['serviceWorker', { serviceWorker: undefined }],
|
||||||
|
['PushManager', { pushManager: false }],
|
||||||
|
['Notification', { notification: undefined }],
|
||||||
|
])('answers unsupported without %s, asking nothing', async (_, missing) => {
|
||||||
|
const { env, api, notification } = setup()
|
||||||
|
expect(await subscribePush(api, { ...env, ...missing })).toBe('unsupported')
|
||||||
|
expect(notification.requestPermission).not.toHaveBeenCalled()
|
||||||
|
expect(api.apiFetch).not.toHaveBeenCalled()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('asks for permission during a tap, subscribes with the server key and stores the subscription', async () => {
|
||||||
|
const { env, api, calls, pushManager, notification } = setup()
|
||||||
|
expect(await subscribePush(api, env)).toBe('subscribed')
|
||||||
|
expect(notification.requestPermission).toHaveBeenCalledTimes(1)
|
||||||
|
expect(pushManager.subscribe).toHaveBeenCalledWith({ userVisibleOnly: true, applicationServerKey: vapidKey })
|
||||||
|
expect(calls).toEqual([
|
||||||
|
{ path: VAPID_PUBLIC_KEY_PATH, request: undefined },
|
||||||
|
{
|
||||||
|
path: SUBSCRIPTIONS_PATH,
|
||||||
|
request: {
|
||||||
|
method: 'POST',
|
||||||
|
body: {
|
||||||
|
endpoint,
|
||||||
|
keys: { p256dh: encodeBase64url(p256dh), auth: encodeBase64url(auth) },
|
||||||
|
user_agent: 'Mozilla/5.0 Test',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('asks for permission before awaiting anything, so the tap still counts', async () => {
|
||||||
|
const { env, api, notification } = setup()
|
||||||
|
const pending = subscribePush(api, env)
|
||||||
|
expect(notification.requestPermission).toHaveBeenCalledTimes(1)
|
||||||
|
expect(api.apiFetch).not.toHaveBeenCalled()
|
||||||
|
await pending
|
||||||
|
})
|
||||||
|
|
||||||
|
it('refuses to ask for permission outside a tap', async () => {
|
||||||
|
const { env, api, notification } = setup({ active: false })
|
||||||
|
const error = await subscribePush(api, env).catch((e: unknown) => e)
|
||||||
|
expect(error).toBeInstanceOf(PermissionGestureError)
|
||||||
|
expect(error).toMatchObject({ reason: 'inactive', message: expect.stringMatching(/only be asked for from a tap/) })
|
||||||
|
expect(notification.requestPermission).not.toHaveBeenCalled()
|
||||||
|
expect(api.apiFetch).not.toHaveBeenCalled()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('refuses to ask for permission where the browser cannot say whether a tap is active', async () => {
|
||||||
|
const { env, api, notification } = setup()
|
||||||
|
const error = await subscribePush(api, { ...env, userActivation: undefined }).catch((e: unknown) => e)
|
||||||
|
expect(error).toBeInstanceOf(PermissionGestureError)
|
||||||
|
expect(error).toMatchObject({ reason: 'unobservable', message: expect.stringMatching(/no navigator.userActivation/) })
|
||||||
|
expect(notification.requestPermission).not.toHaveBeenCalled()
|
||||||
|
expect(api.apiFetch).not.toHaveBeenCalled()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('does not need to observe a tap once permission is granted', async () => {
|
||||||
|
const { env, api, notification } = setup({ permission: 'granted' })
|
||||||
|
expect(await subscribePush(api, { ...env, userActivation: undefined })).toBe('subscribed')
|
||||||
|
expect(notification.requestPermission).not.toHaveBeenCalled()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('does not ask again once permission is granted, even outside a tap', async () => {
|
||||||
|
const { env, api, notification } = setup({ permission: 'granted', active: false })
|
||||||
|
expect(await subscribePush(api, env)).toBe('subscribed')
|
||||||
|
expect(notification.requestPermission).not.toHaveBeenCalled()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('reports a permission denied earlier without asking or subscribing', async () => {
|
||||||
|
const { env, api, notification, pushManager } = setup({ permission: 'denied' })
|
||||||
|
expect(await subscribePush(api, env)).toBe('denied')
|
||||||
|
expect(notification.requestPermission).not.toHaveBeenCalled()
|
||||||
|
expect(pushManager.subscribe).not.toHaveBeenCalled()
|
||||||
|
expect(api.apiFetch).not.toHaveBeenCalled()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('reports a permission denied when asked, without subscribing', async () => {
|
||||||
|
const { env, api, pushManager } = setup({ answer: 'denied' })
|
||||||
|
expect(await subscribePush(api, env)).toBe('denied')
|
||||||
|
expect(pushManager.subscribe).not.toHaveBeenCalled()
|
||||||
|
expect(api.apiFetch).not.toHaveBeenCalled()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('reports a dismissed prompt', async () => {
|
||||||
|
const { env, api, pushManager } = setup({ answer: 'default' })
|
||||||
|
expect(await subscribePush(api, env)).toBe('dismissed')
|
||||||
|
expect(pushManager.subscribe).not.toHaveBeenCalled()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('reports denied when the browser refuses the subscription for permission', async () => {
|
||||||
|
const { env, api, calls } = setup({ subscribeError: new DOMException('Registration failed - permission denied', 'NotAllowedError') })
|
||||||
|
expect(await subscribePush(api, env)).toBe('denied')
|
||||||
|
expect(calls.map((c) => c.path)).toEqual([VAPID_PUBLIC_KEY_PATH])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('rejects with an Error, not a raw DOMException, when the browser cannot subscribe', async () => {
|
||||||
|
const cause = new DOMException('push service not available', 'AbortError')
|
||||||
|
const { env, api } = setup({ subscribeError: cause })
|
||||||
|
const error = await subscribePush(api, env).catch((e: unknown) => e)
|
||||||
|
expect(error).not.toBeInstanceOf(DOMException)
|
||||||
|
expect(error).toBeInstanceOf(Error)
|
||||||
|
expect((error as Error).message).toBe('Web Push: the browser could not subscribe: push service not available')
|
||||||
|
expect((error as Error).cause).toBe(cause)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('rejects when no service worker is registered', async () => {
|
||||||
|
const { env, api } = setup({ registered: false })
|
||||||
|
await expect(subscribePush(api, env)).rejects.toThrow(/no service worker is registered/)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('stores an existing subscription made with the same key again, without resubscribing', async () => {
|
||||||
|
const current = subscription()
|
||||||
|
const { env, api, calls, pushManager } = setup({ permission: 'granted', current })
|
||||||
|
expect(await subscribePush(api, env)).toBe('subscribed')
|
||||||
|
expect(pushManager.subscribe).not.toHaveBeenCalled()
|
||||||
|
expect(current.unsubscribe).not.toHaveBeenCalled()
|
||||||
|
expect(calls.map((c) => c.request?.method ?? 'GET')).toEqual(['GET', 'POST'])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('replaces a subscription made with an older key', async () => {
|
||||||
|
const old = subscription(bytes(65, 9).buffer, 'https://push.example/send/old')
|
||||||
|
const { env, api, calls, pushManager } = setup({ permission: 'granted', current: old })
|
||||||
|
expect(await subscribePush(api, env)).toBe('subscribed')
|
||||||
|
expect(old.unsubscribe).toHaveBeenCalled()
|
||||||
|
expect(pushManager.subscribe).toHaveBeenCalled()
|
||||||
|
expect(calls.map((c) => [c.request?.method ?? 'GET', c.request?.body])).toEqual([
|
||||||
|
['GET', undefined],
|
||||||
|
['DELETE', { endpoint: 'https://push.example/send/old' }],
|
||||||
|
['POST', expect.objectContaining({ endpoint })],
|
||||||
|
])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('passes on an API failure storing the subscription', async () => {
|
||||||
|
const { env, api } = setup()
|
||||||
|
const refused = new ApiError(400, 'invalid_subscription', 'endpoint: want an absolute https:// URL', false)
|
||||||
|
vi.mocked(api.apiFetch).mockImplementation(async (path: string) => {
|
||||||
|
if (path === VAPID_PUBLIC_KEY_PATH) return { public_key: vapidKeyText } as never
|
||||||
|
throw refused
|
||||||
|
})
|
||||||
|
await expect(subscribePush(api, env)).rejects.toBe(refused)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('rejects a VAPID key response without a public_key', async () => {
|
||||||
|
const { env, api } = setup()
|
||||||
|
vi.mocked(api.apiFetch).mockResolvedValue({} as never)
|
||||||
|
await expect(subscribePush(api, env)).rejects.toThrow(`GET ${VAPID_PUBLIC_KEY_PATH}: response has no public_key`)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('unsubscribePush', () => {
|
||||||
|
it('deletes the subscription on the server, then in the browser', async () => {
|
||||||
|
const current = subscription()
|
||||||
|
const { env, api, calls } = setup({ current })
|
||||||
|
expect(await unsubscribePush(api, env)).toBe('unsubscribed')
|
||||||
|
expect(calls).toEqual([{ path: SUBSCRIPTIONS_PATH, request: { method: 'DELETE', body: { endpoint } } }])
|
||||||
|
expect(current.unsubscribe).toHaveBeenCalled()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('answers not-subscribed without calling the server when there is no subscription', async () => {
|
||||||
|
const { env, api } = setup({ current: null })
|
||||||
|
expect(await unsubscribePush(api, env)).toBe('not-subscribed')
|
||||||
|
expect(api.apiFetch).not.toHaveBeenCalled()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('answers not-subscribed when no service worker is registered', async () => {
|
||||||
|
const { env, api } = setup({ registered: false })
|
||||||
|
expect(await unsubscribePush(api, env)).toBe('not-subscribed')
|
||||||
|
expect(api.apiFetch).not.toHaveBeenCalled()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('answers unsupported without Web Push', async () => {
|
||||||
|
const { env, api } = setup()
|
||||||
|
expect(await unsubscribePush(api, { ...env, pushManager: false })).toBe('unsupported')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('keeps the browser subscribed when the server delete fails, so trying again works', async () => {
|
||||||
|
const current = subscription()
|
||||||
|
const { env, api } = setup({ current })
|
||||||
|
vi.mocked(api.apiFetch).mockRejectedValue(new TypeError('Failed to fetch'))
|
||||||
|
await expect(unsubscribePush(api, env)).rejects.toThrow('Failed to fetch')
|
||||||
|
expect(current.unsubscribe).not.toHaveBeenCalled()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('rejects when the browser does not remove the subscription', async () => {
|
||||||
|
const current = subscription()
|
||||||
|
current.unsubscribe.mockResolvedValue(false)
|
||||||
|
const { env, api } = setup({ current })
|
||||||
|
await expect(unsubscribePush(api, env)).rejects.toThrow(/did not remove its push subscription/)
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,209 @@
|
|||||||
|
import type { ApiClient } from '../api/client.ts'
|
||||||
|
import { isRecord } from '../api/errors.ts'
|
||||||
|
|
||||||
|
export const VAPID_PUBLIC_KEY_PATH = '/api/v1/push/vapid-public-key'
|
||||||
|
export const SUBSCRIPTIONS_PATH = '/api/v1/push/subscriptions'
|
||||||
|
|
||||||
|
// MAX_USER_AGENT_BYTES is the server's limit on a subscription's user_agent
|
||||||
|
// (internal/push MaxUserAgentBytes).
|
||||||
|
export const MAX_USER_AGENT_BYTES = 512
|
||||||
|
|
||||||
|
// SubscribePushResult is how turning on Web Push on this device ended:
|
||||||
|
// subscribed, the browser's permission denied (now or earlier), the
|
||||||
|
// permission prompt dismissed without an answer, or a browser without Web
|
||||||
|
// Push. Any other failure rejects with an Error (an ApiError from the API).
|
||||||
|
export type SubscribePushResult = 'subscribed' | 'denied' | 'dismissed' | 'unsupported'
|
||||||
|
|
||||||
|
// UnsubscribePushResult is how turning off Web Push on this device ended:
|
||||||
|
// its subscription removed from the browser and the server, no subscription
|
||||||
|
// to remove, or a browser without Web Push. Any other failure rejects.
|
||||||
|
export type UnsubscribePushResult = 'unsubscribed' | 'not-subscribed' | 'unsupported'
|
||||||
|
|
||||||
|
// The parts of the browser's Push API this module uses.
|
||||||
|
export type BrowserPushSubscription = {
|
||||||
|
readonly endpoint: string
|
||||||
|
readonly options: { readonly applicationServerKey: ArrayBuffer | null }
|
||||||
|
getKey(name: 'p256dh' | 'auth'): ArrayBuffer | null
|
||||||
|
unsubscribe(): Promise<boolean>
|
||||||
|
}
|
||||||
|
|
||||||
|
export type BrowserPushManager = {
|
||||||
|
getSubscription(): Promise<BrowserPushSubscription | null>
|
||||||
|
subscribe(options: { userVisibleOnly: boolean; applicationServerKey: Uint8Array<ArrayBuffer> }): Promise<BrowserPushSubscription>
|
||||||
|
}
|
||||||
|
|
||||||
|
// PushEnvironment is what the browser offers for Web Push. A field is
|
||||||
|
// undefined (or pushManager false) where the browser lacks it.
|
||||||
|
export type PushEnvironment = {
|
||||||
|
serviceWorker: { getRegistration(): Promise<{ readonly pushManager: BrowserPushManager } | undefined> } | undefined
|
||||||
|
pushManager: boolean
|
||||||
|
notification:
|
||||||
|
| { readonly permission: NotificationPermission; requestPermission(): Promise<NotificationPermission> }
|
||||||
|
| undefined
|
||||||
|
// Whether the page is handling a user gesture right now.
|
||||||
|
userActivation: { readonly isActive: boolean } | undefined
|
||||||
|
userAgent: string
|
||||||
|
}
|
||||||
|
|
||||||
|
// browserPushEnvironment reads the PushEnvironment from this window.
|
||||||
|
export function browserPushEnvironment(): PushEnvironment {
|
||||||
|
return {
|
||||||
|
serviceWorker: 'serviceWorker' in navigator ? navigator.serviceWorker : undefined,
|
||||||
|
pushManager: 'PushManager' in window,
|
||||||
|
notification: 'Notification' in window ? Notification : undefined,
|
||||||
|
userActivation: 'userActivation' in navigator ? navigator.userActivation : undefined,
|
||||||
|
userAgent: navigator.userAgent,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type PushApi = Pick<ApiClient, 'apiFetch'>
|
||||||
|
|
||||||
|
// PermissionGestureError is why subscribePush refused to ask for notification
|
||||||
|
// permission: no tap is active right now ('inactive'), or the browser cannot
|
||||||
|
// say whether one is ('unobservable', no navigator.userActivation).
|
||||||
|
export class PermissionGestureError extends Error {
|
||||||
|
readonly reason: 'inactive' | 'unobservable'
|
||||||
|
|
||||||
|
constructor(reason: 'inactive' | 'unobservable') {
|
||||||
|
super(
|
||||||
|
reason === 'inactive'
|
||||||
|
? 'subscribePush: notification permission can only be asked for from a tap on a button'
|
||||||
|
: 'subscribePush: this browser cannot report whether a tap is active (no navigator.userActivation), so notification permission is not asked for',
|
||||||
|
)
|
||||||
|
this.name = 'PermissionGestureError'
|
||||||
|
this.reason = reason
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// encodeBase64url encodes bytes as unpadded base64url, as the server wants
|
||||||
|
// subscription keys.
|
||||||
|
export function encodeBase64url(bytes: ArrayBuffer): string {
|
||||||
|
let binary = ''
|
||||||
|
for (const byte of new Uint8Array(bytes)) binary += String.fromCharCode(byte)
|
||||||
|
return btoa(binary).replaceAll('+', '-').replaceAll('/', '_').replace(/=+$/, '')
|
||||||
|
}
|
||||||
|
|
||||||
|
// decodeVapidKey decodes the server's VAPID public key, unpadded base64url
|
||||||
|
// of the 65-byte uncompressed P-256 point, into an applicationServerKey.
|
||||||
|
export function decodeVapidKey(key: string): Uint8Array<ArrayBuffer> {
|
||||||
|
const problem = `GET ${VAPID_PUBLIC_KEY_PATH}: public_key is not the unpadded base64url of a 65-byte uncompressed P-256 point`
|
||||||
|
if (!/^[A-Za-z0-9_-]+$/.test(key) || key.length % 4 === 1) throw new Error(problem)
|
||||||
|
const binary = atob(key.replaceAll('-', '+').replaceAll('_', '/'))
|
||||||
|
const bytes = new Uint8Array(new ArrayBuffer(binary.length))
|
||||||
|
for (let i = 0; i < binary.length; i++) bytes[i] = binary.charCodeAt(i)
|
||||||
|
if (bytes.length !== 65 || bytes[0] !== 0x04) throw new Error(problem)
|
||||||
|
return bytes
|
||||||
|
}
|
||||||
|
|
||||||
|
function sameKey(current: ArrayBuffer | null, key: Uint8Array): boolean {
|
||||||
|
if (current === null || current.byteLength !== key.length) return false
|
||||||
|
const bytes = new Uint8Array(current)
|
||||||
|
return bytes.every((byte, i) => byte === key[i])
|
||||||
|
}
|
||||||
|
|
||||||
|
// SubscriptionBody is the POST /api/v1/push/subscriptions body. The server
|
||||||
|
// refuses unknown fields and nulls, so it is built field by field rather
|
||||||
|
// than from PushSubscription.toJSON(), whose expirationTime may be null.
|
||||||
|
export type SubscriptionBody = { endpoint: string; keys: { p256dh: string; auth: string }; user_agent?: string }
|
||||||
|
|
||||||
|
// subscriptionBody is the body that stores subscription. The user agent only
|
||||||
|
// labels the device, so one that is blank or over the server's limit is left
|
||||||
|
// out rather than sent to be refused.
|
||||||
|
export function subscriptionBody(subscription: BrowserPushSubscription, userAgent: string): SubscriptionBody {
|
||||||
|
const p256dh = subscription.getKey('p256dh')
|
||||||
|
const auth = subscription.getKey('auth')
|
||||||
|
if (p256dh === null || auth === null) {
|
||||||
|
throw new Error('Web Push: the browser gave a subscription without its p256dh and auth keys')
|
||||||
|
}
|
||||||
|
const body: SubscriptionBody = {
|
||||||
|
endpoint: subscription.endpoint,
|
||||||
|
keys: { p256dh: encodeBase64url(p256dh), auth: encodeBase64url(auth) },
|
||||||
|
}
|
||||||
|
if (userAgent.trim() !== '' && new TextEncoder().encode(userAgent).length <= MAX_USER_AGENT_BYTES) {
|
||||||
|
body.user_agent = userAgent
|
||||||
|
}
|
||||||
|
return body
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fetchVapidKey(api: PushApi): Promise<Uint8Array<ArrayBuffer>> {
|
||||||
|
const body = await api.apiFetch<unknown>(VAPID_PUBLIC_KEY_PATH)
|
||||||
|
if (!isRecord(body) || typeof body.public_key !== 'string') {
|
||||||
|
throw new Error(`GET ${VAPID_PUBLIC_KEY_PATH}: response has no public_key`)
|
||||||
|
}
|
||||||
|
return decodeVapidKey(body.public_key)
|
||||||
|
}
|
||||||
|
|
||||||
|
// removeSubscription deletes subscription on the server, then in the
|
||||||
|
// browser. Deleting on the server first means a failure leaves both still
|
||||||
|
// subscribed, so trying again finds the subscription and finishes the job.
|
||||||
|
async function removeSubscription(api: PushApi, subscription: BrowserPushSubscription): Promise<void> {
|
||||||
|
await api.apiFetch<void>(SUBSCRIPTIONS_PATH, { method: 'DELETE', body: { endpoint: subscription.endpoint } })
|
||||||
|
if (!(await subscription.unsubscribe())) {
|
||||||
|
throw new Error('Web Push: the browser did not remove its push subscription')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function errorName(error: unknown): string | undefined {
|
||||||
|
return isRecord(error) && typeof error.name === 'string' ? error.name : undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
// subscribePush turns on Web Push on this device. Call it only from the
|
||||||
|
// household's tap on a button: when the browser has not yet been asked, it
|
||||||
|
// asks for notification permission, which browsers allow only during a user
|
||||||
|
// gesture, and it refuses to ask outside one — or where the browser cannot
|
||||||
|
// say whether a gesture is active — with a PermissionGestureError. Permission
|
||||||
|
// is asked before
|
||||||
|
// anything is awaited so the gesture still counts. With permission, it
|
||||||
|
// subscribes the browser with the server's VAPID public key — replacing a
|
||||||
|
// subscription made with an older key — and stores the subscription on the
|
||||||
|
// server. Subscribing again when already subscribed stores it again.
|
||||||
|
export async function subscribePush(api: PushApi, env: PushEnvironment): Promise<SubscribePushResult> {
|
||||||
|
const { serviceWorker, notification } = env
|
||||||
|
if (serviceWorker === undefined || !env.pushManager || notification === undefined) return 'unsupported'
|
||||||
|
|
||||||
|
let permission = notification.permission
|
||||||
|
if (permission === 'default') {
|
||||||
|
if (env.userActivation === undefined) throw new PermissionGestureError('unobservable')
|
||||||
|
if (!env.userActivation.isActive) throw new PermissionGestureError('inactive')
|
||||||
|
permission = await notification.requestPermission()
|
||||||
|
}
|
||||||
|
if (permission === 'denied') return 'denied'
|
||||||
|
if (permission === 'default') return 'dismissed'
|
||||||
|
|
||||||
|
const found = await serviceWorker.getRegistration()
|
||||||
|
if (found === undefined) {
|
||||||
|
throw new Error('Web Push: no service worker is registered for this page, so it cannot receive notifications')
|
||||||
|
}
|
||||||
|
const { pushManager } = found
|
||||||
|
const key = await fetchVapidKey(api)
|
||||||
|
let subscription = await pushManager.getSubscription()
|
||||||
|
if (subscription !== null && !sameKey(subscription.options.applicationServerKey, key)) {
|
||||||
|
await removeSubscription(api, subscription)
|
||||||
|
subscription = null
|
||||||
|
}
|
||||||
|
if (subscription === null) {
|
||||||
|
try {
|
||||||
|
subscription = await pushManager.subscribe({ userVisibleOnly: true, applicationServerKey: key })
|
||||||
|
} catch (error) {
|
||||||
|
if (errorName(error) === 'NotAllowedError') return 'denied'
|
||||||
|
throw new Error(`Web Push: the browser could not subscribe: ${(error as Error).message}`, { cause: error })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
await api.apiFetch(SUBSCRIPTIONS_PATH, { method: 'POST', body: subscriptionBody(subscription, env.userAgent) })
|
||||||
|
return 'subscribed'
|
||||||
|
}
|
||||||
|
|
||||||
|
// unsubscribePush turns off Web Push on this device: it removes the
|
||||||
|
// browser's subscription on the server and in the browser. With no
|
||||||
|
// subscription there is nothing to remove, and it answers 'not-subscribed'
|
||||||
|
// without calling the server.
|
||||||
|
export async function unsubscribePush(api: PushApi, env: PushEnvironment): Promise<UnsubscribePushResult> {
|
||||||
|
const { serviceWorker } = env
|
||||||
|
if (serviceWorker === undefined || !env.pushManager) return 'unsupported'
|
||||||
|
const found = await serviceWorker.getRegistration()
|
||||||
|
if (found === undefined) return 'not-subscribed'
|
||||||
|
const subscription = await found.pushManager.getSubscription()
|
||||||
|
if (subscription === null) return 'not-subscribed'
|
||||||
|
await removeSubscription(api, subscription)
|
||||||
|
return 'unsubscribed'
|
||||||
|
}
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
import { useMemo } from 'react'
|
||||||
|
import { useApi } from '../api/ApiContext.tsx'
|
||||||
|
import {
|
||||||
|
browserPushEnvironment,
|
||||||
|
subscribePush,
|
||||||
|
unsubscribePush,
|
||||||
|
type SubscribePushResult,
|
||||||
|
type UnsubscribePushResult,
|
||||||
|
} from './subscribe.ts'
|
||||||
|
|
||||||
|
export type Push = {
|
||||||
|
// subscribePush turns on Web Push on this device; call it only from a tap.
|
||||||
|
subscribePush(): Promise<SubscribePushResult>
|
||||||
|
// unsubscribePush turns off Web Push on this device.
|
||||||
|
unsubscribePush(): Promise<UnsubscribePushResult>
|
||||||
|
}
|
||||||
|
|
||||||
|
// usePush binds subscribePush and unsubscribePush to the app's API client
|
||||||
|
// and this browser.
|
||||||
|
export function usePush(): Push {
|
||||||
|
const api = useApi()
|
||||||
|
return useMemo(
|
||||||
|
() => ({
|
||||||
|
subscribePush: () => subscribePush(api, browserPushEnvironment()),
|
||||||
|
unsubscribePush: () => unsubscribePush(api, browserPushEnvironment()),
|
||||||
|
}),
|
||||||
|
[api],
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -50,3 +50,57 @@ describe('service worker activation', () => {
|
|||||||
expect(cacheStorage.delete).toHaveBeenCalledTimes(2)
|
expect(cacheStorage.delete).toHaveBeenCalledTimes(2)
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// The push listeners only hand src/push/notification.ts the worker's globals;
|
||||||
|
// jsdom has no ServiceWorkerGlobalScope, so registration and clients are
|
||||||
|
// stubbed and the events are plain Events with the worker event's fields.
|
||||||
|
describe('service worker push listeners', () => {
|
||||||
|
afterEach(() => {
|
||||||
|
vi.unstubAllGlobals()
|
||||||
|
})
|
||||||
|
|
||||||
|
async function dispatch(type: string, fields: object): Promise<void> {
|
||||||
|
vi.stubGlobal('caches', { keys: vi.fn(async () => []), delete: vi.fn() })
|
||||||
|
vi.stubGlobal('__WB_MANIFEST', [])
|
||||||
|
await import('./sw')
|
||||||
|
const pending: Promise<unknown>[] = []
|
||||||
|
self.dispatchEvent(
|
||||||
|
Object.assign(new Event(type), { waitUntil: (promise: Promise<unknown>) => pending.push(promise) }, fields),
|
||||||
|
)
|
||||||
|
expect(pending).toHaveLength(1)
|
||||||
|
await Promise.all(pending)
|
||||||
|
}
|
||||||
|
|
||||||
|
it('shows a push message as a notification', async () => {
|
||||||
|
const registration = { showNotification: vi.fn(async () => {}) }
|
||||||
|
vi.stubGlobal('registration', registration)
|
||||||
|
const payload = JSON.stringify({ title: 'Plan ready', body: 'Adjust the Draft board.', tag: 'plan_ready', url: '/plan' })
|
||||||
|
await dispatch('push', { data: { text: () => payload } })
|
||||||
|
expect(registration.showNotification).toHaveBeenCalledWith('Plan ready', {
|
||||||
|
body: 'Adjust the Draft board.',
|
||||||
|
tag: 'plan_ready',
|
||||||
|
icon: '/icons/icon-192.png',
|
||||||
|
badge: '/icons/icon-192.png',
|
||||||
|
data: { url: '/plan' },
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it('shows the generic notification for a push without a payload', async () => {
|
||||||
|
const registration = { showNotification: vi.fn(async () => {}) }
|
||||||
|
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {})
|
||||||
|
vi.stubGlobal('registration', registration)
|
||||||
|
await dispatch('push', { data: null })
|
||||||
|
expect(registration.showNotification).toHaveBeenCalledWith('MealQuest', expect.objectContaining({ data: { url: '/' } }))
|
||||||
|
expect(warn).toHaveBeenCalledWith('MealQuest: push message has no payload; showing the generic notification')
|
||||||
|
warn.mockRestore()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('closes a tapped notification and opens its url', async () => {
|
||||||
|
const clients = { matchAll: vi.fn(async () => []), openWindow: vi.fn(async () => null) }
|
||||||
|
vi.stubGlobal('clients', clients)
|
||||||
|
const notification = { data: { url: '/discover' }, close: vi.fn() }
|
||||||
|
await dispatch('notificationclick', { notification })
|
||||||
|
expect(notification.close).toHaveBeenCalled()
|
||||||
|
expect(clients.openWindow).toHaveBeenCalledWith(`${self.location.origin}/discover`)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ import { ExpirationPlugin } from 'workbox-expiration'
|
|||||||
import { PrecacheFallbackPlugin, matchPrecache, precacheAndRoute } from 'workbox-precaching'
|
import { PrecacheFallbackPlugin, matchPrecache, precacheAndRoute } from 'workbox-precaching'
|
||||||
import { registerRoute } from 'workbox-routing'
|
import { registerRoute } from 'workbox-routing'
|
||||||
import { CacheFirst, NetworkOnly, type Strategy } from 'workbox-strategies'
|
import { CacheFirst, NetworkOnly, type Strategy } from 'workbox-strategies'
|
||||||
|
import { openNotification, showPush } from './push/notification'
|
||||||
import { createApiStrategy } from './sw/apiStrategy'
|
import { createApiStrategy } from './sw/apiStrategy'
|
||||||
import {
|
import {
|
||||||
cacheNames,
|
cacheNames,
|
||||||
@@ -113,3 +114,18 @@ self.addEventListener('activate', (event) => {
|
|||||||
self.addEventListener('message', (event) => {
|
self.addEventListener('message', (event) => {
|
||||||
if (event.data?.type === 'SKIP_WAITING') void self.skipWaiting()
|
if (event.data?.type === 'SKIP_WAITING') void self.skipWaiting()
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// Web Push: src/push/notification.ts decides what to show and where a tap
|
||||||
|
// goes; these listeners only hand it the worker's globals. A payload that is
|
||||||
|
// not a MealQuest notice still shows a generic notification, and the reason
|
||||||
|
// is logged.
|
||||||
|
self.addEventListener('push', (event) => {
|
||||||
|
event.waitUntil(
|
||||||
|
showPush(event.data?.text() ?? null, self.registration, self.location.origin, (message) => console.warn(message)),
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
self.addEventListener('notificationclick', (event) => {
|
||||||
|
event.notification.close()
|
||||||
|
event.waitUntil(openNotification(event.notification.data, self.clients, self.location.origin))
|
||||||
|
})
|
||||||
|
|||||||
@@ -44,3 +44,13 @@ describe('service worker source', () => {
|
|||||||
)
|
)
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
describe('service worker push listeners source', () => {
|
||||||
|
it('delegates push and notificationclick to src/push/notification.ts', () => {
|
||||||
|
expect(source).toContain("import { openNotification, showPush } from './push/notification'")
|
||||||
|
expect(source).toMatch(/self\.addEventListener\('push', \(event\) => \{\s*event\.waitUntil\(\s*showPush\(event\.data\?\.text\(\) \?\? null, self\.registration, self\.location\.origin,/)
|
||||||
|
expect(source).toMatch(
|
||||||
|
/self\.addEventListener\('notificationclick', \(event\) => \{\s*event\.notification\.close\(\)\s*event\.waitUntil\(openNotification\(event\.notification\.data, self\.clients, self\.location\.origin\)\)/,
|
||||||
|
)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|||||||
Reference in New Issue
Block a user