Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
101498185e | ||
|
|
615969cff2 |
+1
-16
@@ -17,7 +17,6 @@ import (
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/ai/harness"
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/ai/skills"
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/auth"
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/chat"
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/config"
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/dinners"
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/forwarded"
|
||||
@@ -150,20 +149,6 @@ func run(cfg config.Config, logger *slog.Logger) error {
|
||||
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
|
||||
}
|
||||
chatSvc, err := chat.NewService(db, box, cfg.PublicURL, http.DefaultTransport)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := notifier.Register(notify.ChannelChat, chatSvc); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
sessions, err := auth.NewSessionManager(db, cfg.SessionIdle, cfg.SessionMax)
|
||||
if err != nil {
|
||||
@@ -181,7 +166,7 @@ func run(cfg config.Config, logger *slog.Logger) error {
|
||||
return err
|
||||
}
|
||||
|
||||
router, err := server.NewRouter(logger, version, cfg.TrustedProxy, authFlow, sessions, settings, search, ai, chatSvc, dinnerSvc, suggestSvc, planSvc, shopSvc, googleSvc, pushSvc, runner, photos, frontend.Assets())
|
||||
router, err := server.NewRouter(logger, version, cfg.TrustedProxy, authFlow, sessions, settings, search, ai, dinnerSvc, suggestSvc, planSvc, shopSvc, googleSvc, pushSvc, runner, photos, frontend.Assets())
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -684,49 +684,6 @@ Runs are recorded in `schedule_runs` and executed through `jobs`.
|
||||
`{"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).
|
||||
- `internal/chat` keeps the Google Chat incoming webhook in `chat_settings`
|
||||
and is the outbox's sender for the `chat` channel. The webhook URL holds
|
||||
its own key and token, so it is sealed with `secretbox` in
|
||||
`webhook_url_ct` and never appears in a response, log line or error.
|
||||
Under the signed-in `/api/v1`:
|
||||
- `PUT /api/v1/settings/chat-webhook` (CSRF-checked) takes
|
||||
`{"url", "space_label"}`, both required. The URL must be exactly
|
||||
`https://chat.googleapis.com/v1/spaces/{space}/messages?key=…&token=…`:
|
||||
that scheme and host spelled so, no port or user info, the space id
|
||||
letters, digits, `-` or `_`, and the raw query exactly
|
||||
`key=…&token=…` (in that order, no empty segment, no second `?`, no
|
||||
other parameter), each decoding to at least 16 characters of letters,
|
||||
digits, `-`, `_`, `.`, `~` or `=`. The space label is 1–100
|
||||
characters. A refused body is 400 `invalid_request` (not a JSON object
|
||||
of the two fields) or `invalid_webhook`. Saving replaces any stored
|
||||
webhook and clears its test outcome.
|
||||
- `GET /api/v1/settings/chat-webhook` answers `{space_label, url_masked,
|
||||
last_test_at, last_test_status}`, so `url_masked` is `null` exactly when
|
||||
no webhook is stored; `url_masked`
|
||||
shows the space id and only the last four characters of the decoded
|
||||
token (`…/spaces/AAQA…/messages?key=…&token=…ZxQ=`). Absent values are
|
||||
`null`.
|
||||
- `POST /api/v1/settings/chat-webhook/test` (CSRF-checked) posts
|
||||
"MealQuest · test message", unthreaded, and records `last_test_at` and
|
||||
`last_test_status` (`ok` for 2xx, else `failed`). Whatever status Google
|
||||
Chat answers, it answers 200 with that status as `http_status` and the
|
||||
settings as GET gives them. No webhook stored is 409 `not_configured`;
|
||||
a webhook replaced during the test is 409 `webhook_changed`; no answer
|
||||
from Google Chat is recorded as failed and answered 502
|
||||
`chat_unreachable`.
|
||||
- A notification is posted as `{"text"}`, plus `cardsV2` with an "Open"
|
||||
button to the payload's path under `MEALQUEST_PUBLIC_URL` for
|
||||
`plan_ready` and `list_ready`, with `threadKey=mealquest-week-YYYY-Www`
|
||||
(the ISO week of the payload's week) and
|
||||
`messageReplyOption=REPLY_MESSAGE_FALLBACK_TO_NEW_THREAD`; a payload
|
||||
with no week is posted unthreaded. 2xx is sent; 429, 5xx or no answer
|
||||
is retried by the outbox; any other status, or no webhook stored, fails
|
||||
the row at once (`notify.ErrUndeliverable`). Redirects are not
|
||||
followed.
|
||||
- The builders in `internal/chat/messages.go` make each event's
|
||||
`notify.Event` (title and body shared with Web Push) and `chat.Text`
|
||||
its Chat message, such as "This week's plan is ready: Tacos, Bolognese,
|
||||
Tikka masala + 4 more. Open the plan →".
|
||||
|
||||
## Google Calendar
|
||||
|
||||
|
||||
@@ -3,7 +3,6 @@ module code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest
|
||||
go 1.25.0
|
||||
|
||||
require (
|
||||
github.com/SherClockHolmes/webpush-go v1.4.0
|
||||
github.com/coreos/go-oidc/v3 v3.21.0
|
||||
github.com/go-chi/chi/v5 v5.3.1
|
||||
github.com/oklog/ulid/v2 v2.1.2
|
||||
@@ -15,12 +14,10 @@ require (
|
||||
require (
|
||||
github.com/dustin/go-humanize v1.0.1 // 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/mattn/go-isatty v0.0.24 // indirect
|
||||
github.com/ncruces/go-strftime v1.0.0 // 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
|
||||
modernc.org/libc v1.75.7 // indirect
|
||||
modernc.org/mathutil v1.7.1 // indirect
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
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/go.mod h1:DYCf24+ncYi+XkIH97GY1+dqoRlbaSI26KVTCI9SrY4=
|
||||
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
|
||||
@@ -8,9 +6,6 @@ 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-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/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/go.mod h1:jl5iWTm0/hd5PjEYEOuwAJ57L/CibdZfrqZ5XA5GrCk=
|
||||
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
||||
@@ -26,82 +21,18 @@ 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/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
|
||||
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/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/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/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/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/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/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/go.mod h1:OnovgIhbbMXMu1aISnJ0wvVD1KnW+cAUJkIrAWh+kVI=
|
||||
modernc.org/ccgo/v4 v4.35.0 h1:F+TUsmw09QxLzmi3aeYYGxjAXarmZaKgj3mKQHNaA8w=
|
||||
|
||||
@@ -1,455 +0,0 @@
|
||||
// Package chat sends notifications to the household's Google Chat space
|
||||
// through its incoming webhook, and keeps that webhook in chat_settings.
|
||||
//
|
||||
// The webhook URL carries its own key and token, so it is a secret: it is
|
||||
// sealed with secretbox in webhook_url_ct and is never returned, logged or
|
||||
// put in an error. Only its space label, a masked tail and the outcome of
|
||||
// the last test message are shown.
|
||||
package chat
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
"time"
|
||||
"unicode/utf8"
|
||||
|
||||
"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/jobs"
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/notify"
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/secretbox"
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/store"
|
||||
)
|
||||
|
||||
const (
|
||||
// MaxSpaceLabelChars bounds a space label.
|
||||
MaxSpaceLabelChars = 100
|
||||
// requestTimeout bounds one post to Google Chat.
|
||||
requestTimeout = 15 * time.Second
|
||||
// maxDrainBytes bounds how much of a Google Chat response is read.
|
||||
maxDrainBytes = 4096
|
||||
// replyOption threads a message under its threadKey, starting the
|
||||
// thread when there is none yet.
|
||||
replyOption = "REPLY_MESSAGE_FALLBACK_TO_NEW_THREAD"
|
||||
)
|
||||
|
||||
// Test outcomes, as chat_settings.last_test_status stores them.
|
||||
const (
|
||||
TestOK = "ok"
|
||||
TestFailed = "failed"
|
||||
)
|
||||
|
||||
var (
|
||||
// ErrNotConfigured is returned when no webhook is stored.
|
||||
ErrNotConfigured = errors.New("chat: no Google Chat webhook is configured")
|
||||
// ErrUnreachable is wrapped by the error of a post Google Chat did not
|
||||
// answer.
|
||||
ErrUnreachable = errors.New("chat: Google Chat did not answer")
|
||||
// ErrWebhookChanged is returned by Test when the webhook was replaced
|
||||
// while the test message was being sent; nothing is recorded.
|
||||
ErrWebhookChanged = errors.New("chat: the Google Chat webhook changed during the test")
|
||||
)
|
||||
|
||||
// Settings is the stored webhook as the API shows it: never the URL, only
|
||||
// its masked form. URLMasked is nil when no webhook is stored.
|
||||
type Settings struct {
|
||||
SpaceLabel *string `json:"space_label"`
|
||||
// URLMasked is the webhook URL without its key and with only the last
|
||||
// few characters of its token.
|
||||
URLMasked *string `json:"url_masked"`
|
||||
// LastTestAt and LastTestStatus ("ok" or "failed") are nil until a
|
||||
// test message is sent to this webhook.
|
||||
LastTestAt *string `json:"last_test_at"`
|
||||
LastTestStatus *string `json:"last_test_status"`
|
||||
}
|
||||
|
||||
// TestResult is the outcome of a test message.
|
||||
type TestResult struct {
|
||||
// HTTPStatus is the status Google Chat answered.
|
||||
HTTPStatus int `json:"http_status"`
|
||||
Settings
|
||||
}
|
||||
|
||||
// Problem is one invalid field of a webhook update, named by its JSON key.
|
||||
type Problem struct {
|
||||
Field string
|
||||
Reason string
|
||||
}
|
||||
|
||||
// ValidationError lists every problem with a webhook update. It never
|
||||
// quotes the URL.
|
||||
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 Google Chat webhook: " + strings.Join(parts, "; ")
|
||||
}
|
||||
|
||||
// Service keeps the webhook and posts to it. It is the notify.Sender of the
|
||||
// chat channel.
|
||||
type Service struct {
|
||||
st *store.Store
|
||||
box *secretbox.Box
|
||||
// publicURL is MEALQUEST_PUBLIC_URL, which a card's "Open" button
|
||||
// links under.
|
||||
publicURL *url.URL
|
||||
client *http.Client
|
||||
// now is the clock settings are saved and tests recorded on; tests
|
||||
// replace it.
|
||||
now func() time.Time
|
||||
}
|
||||
|
||||
// NewService returns the service over st, sealing the webhook with box and
|
||||
// linking cards under publicURL. Requests go through transport
|
||||
// (http.DefaultTransport in production) and never follow a redirect.
|
||||
func NewService(st *store.Store, box *secretbox.Box, publicURL *url.URL, transport http.RoundTripper) (*Service, error) {
|
||||
switch {
|
||||
case st == nil:
|
||||
return nil, errors.New("chat: nil store")
|
||||
case box == nil:
|
||||
return nil, errors.New("chat: nil secret box")
|
||||
case publicURL == nil:
|
||||
return nil, errors.New("chat: nil public URL")
|
||||
case transport == nil:
|
||||
return nil, errors.New("chat: nil transport")
|
||||
}
|
||||
return &Service{
|
||||
st: st,
|
||||
box: box,
|
||||
publicURL: publicURL,
|
||||
client: &http.Client{
|
||||
Transport: transport,
|
||||
Timeout: requestTimeout,
|
||||
CheckRedirect: func(*http.Request, []*http.Request) error {
|
||||
return http.ErrUseLastResponse
|
||||
},
|
||||
},
|
||||
now: time.Now,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// Get returns the stored webhook's settings; every field is nil before one
|
||||
// is saved.
|
||||
func (s *Service) Get(ctx context.Context) (Settings, error) {
|
||||
row, err := db.New(s.st.Reader).GetChatSettings(ctx)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return Settings{}, nil
|
||||
}
|
||||
if err != nil {
|
||||
return Settings{}, fmt.Errorf("chat: read chat settings: %w", err)
|
||||
}
|
||||
hook, err := s.open(row)
|
||||
if err != nil {
|
||||
return Settings{}, err
|
||||
}
|
||||
return settingsFrom(row, hook), nil
|
||||
}
|
||||
|
||||
// Put validates rawURL and spaceLabel and stores them, replacing any
|
||||
// webhook stored before and clearing its test outcome. Every problem is
|
||||
// reported in a *ValidationError.
|
||||
func (s *Service) Put(ctx context.Context, rawURL, spaceLabel string) (Settings, error) {
|
||||
var problems []Problem
|
||||
hook, err := ParseWebhook(rawURL)
|
||||
if err != nil {
|
||||
problems = append(problems, Problem{"url", err.Error()})
|
||||
}
|
||||
label := strings.TrimSpace(spaceLabel)
|
||||
switch {
|
||||
case label == "":
|
||||
problems = append(problems, Problem{"space_label", "is required"})
|
||||
case utf8.RuneCountInString(label) > MaxSpaceLabelChars:
|
||||
problems = append(problems, Problem{"space_label", fmt.Sprintf("must be at most %d characters", MaxSpaceLabelChars)})
|
||||
case strings.ContainsFunc(label, func(r rune) bool { return r < ' ' || r == 0x7f }):
|
||||
problems = append(problems, Problem{"space_label", "must not contain control characters"})
|
||||
}
|
||||
if len(problems) > 0 {
|
||||
return Settings{}, &ValidationError{Problems: problems}
|
||||
}
|
||||
ct, err := s.box.Encrypt([]byte(hook.String()))
|
||||
if err != nil {
|
||||
return Settings{}, fmt.Errorf("chat: seal webhook: %w", err)
|
||||
}
|
||||
|
||||
var saved db.ChatSetting
|
||||
err = s.st.WithTx(ctx, func(tx *sql.Tx) error {
|
||||
q := db.New(tx)
|
||||
row, err := q.GetChatSettings(ctx)
|
||||
if err != nil && !errors.Is(err, sql.ErrNoRows) {
|
||||
return fmt.Errorf("chat: read chat settings: %w", err)
|
||||
}
|
||||
now := s.timestamp()
|
||||
created := now
|
||||
if err == nil {
|
||||
created = row.CreatedAt
|
||||
}
|
||||
saved, err = q.UpsertChatSettings(ctx, db.UpsertChatSettingsParams{
|
||||
WebhookUrlCt: ct,
|
||||
SpaceLabel: sql.NullString{String: label, Valid: true},
|
||||
CreatedAt: created,
|
||||
UpdatedAt: now,
|
||||
})
|
||||
if err != nil {
|
||||
return fmt.Errorf("chat: save chat settings: %w", err)
|
||||
}
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
return Settings{}, err
|
||||
}
|
||||
return settingsFrom(saved, hook), nil
|
||||
}
|
||||
|
||||
// Test posts TestText to the stored webhook, unthreaded, and records when
|
||||
// and whether Google Chat accepted it. A status Google Chat answers is a
|
||||
// TestResult, whatever it is; a request that gets no answer is recorded as
|
||||
// failed and returned as an error. A test cut short by ctx records nothing.
|
||||
func (s *Service) Test(ctx context.Context) (TestResult, error) {
|
||||
row, err := db.New(s.st.Reader).GetChatSettings(ctx)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return TestResult{}, ErrNotConfigured
|
||||
}
|
||||
if err != nil {
|
||||
return TestResult{}, fmt.Errorf("chat: read chat settings: %w", err)
|
||||
}
|
||||
hook, err := s.open(row)
|
||||
if err != nil {
|
||||
return TestResult{}, err
|
||||
}
|
||||
status, postErr := s.post(ctx, hook, message{Text: TestText}, "")
|
||||
if err := ctx.Err(); err != nil {
|
||||
return TestResult{}, err
|
||||
}
|
||||
outcome := TestFailed
|
||||
if postErr == nil && status >= 200 && status < 300 {
|
||||
outcome = TestOK
|
||||
}
|
||||
at := s.timestamp()
|
||||
write, cancel := jobs.FinalWriteContext(ctx)
|
||||
defer cancel()
|
||||
n, err := db.New(s.st.Writer).RecordChatTest(write, db.RecordChatTestParams{
|
||||
LastTestAt: sql.NullString{String: at, Valid: true},
|
||||
LastTestStatus: sql.NullString{String: outcome, Valid: true},
|
||||
UpdatedAt: at,
|
||||
WebhookUrlCt: row.WebhookUrlCt,
|
||||
})
|
||||
if err != nil {
|
||||
return TestResult{}, fmt.Errorf("chat: record test: %w", err)
|
||||
}
|
||||
if n != 1 {
|
||||
return TestResult{}, ErrWebhookChanged
|
||||
}
|
||||
if postErr != nil {
|
||||
return TestResult{}, postErr
|
||||
}
|
||||
row.LastTestAt = sql.NullString{String: at, Valid: true}
|
||||
row.LastTestStatus = sql.NullString{String: outcome, Valid: true}
|
||||
row.UpdatedAt = at
|
||||
return TestResult{HTTPStatus: status, Settings: settingsFrom(row, hook)}, nil
|
||||
}
|
||||
|
||||
// Send posts m to the stored webhook, threaded by the week of its payload,
|
||||
// and is the chat channel's notify.Sender. Google Chat answering 2xx is
|
||||
// success; 429 or 5xx, or no answer at all, is an error the outbox
|
||||
// retries; any other status, or no webhook stored, wraps
|
||||
// notify.ErrUndeliverable so the row fails at once.
|
||||
func (s *Service) Send(ctx context.Context, m notify.Message) error {
|
||||
row, err := db.New(s.st.Reader).GetChatSettings(ctx)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return fmt.Errorf("%w: %w", notify.ErrUndeliverable, ErrNotConfigured)
|
||||
}
|
||||
if err != nil {
|
||||
return fmt.Errorf("chat: read chat settings: %w", err)
|
||||
}
|
||||
hook, err := s.open(row)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
msg, err := s.messageOf(m.Payload)
|
||||
if err != nil {
|
||||
return fmt.Errorf("%w: chat: %w", notify.ErrUndeliverable, err)
|
||||
}
|
||||
var thread string
|
||||
if m.Payload.Week != "" {
|
||||
week, err := store.ParseLocalDate(m.Payload.Week)
|
||||
if err != nil {
|
||||
return fmt.Errorf("%w: chat: payload week: %w", notify.ErrUndeliverable, err)
|
||||
}
|
||||
thread = ThreadKey(week)
|
||||
}
|
||||
status, err := s.post(ctx, hook, msg, thread)
|
||||
switch {
|
||||
case err != nil:
|
||||
return err
|
||||
case status >= 200 && status < 300:
|
||||
return nil
|
||||
case status == http.StatusTooManyRequests || status >= 500:
|
||||
return fmt.Errorf("chat: Google Chat answered status %d", status)
|
||||
default:
|
||||
return fmt.Errorf("%w: chat: Google Chat refused the message with status %d", notify.ErrUndeliverable, status)
|
||||
}
|
||||
}
|
||||
|
||||
// ThreadKey is the Chat thread of the week starting on week, such as
|
||||
// "mealquest-week-2026-W42": every message about a week goes in one thread.
|
||||
func ThreadKey(week store.LocalDate) string {
|
||||
return "mealquest-week-" + household.PeriodKey(week)
|
||||
}
|
||||
|
||||
// message is the body of a Google Chat webhook post.
|
||||
type message struct {
|
||||
Text string `json:"text"`
|
||||
CardsV2 []cardV2 `json:"cardsV2,omitempty"`
|
||||
}
|
||||
|
||||
type cardV2 struct {
|
||||
CardID string `json:"cardId"`
|
||||
Card card `json:"card"`
|
||||
}
|
||||
|
||||
type card struct {
|
||||
Header cardHeader `json:"header"`
|
||||
Sections []cardSection `json:"sections"`
|
||||
}
|
||||
|
||||
type cardHeader struct {
|
||||
Title string `json:"title"`
|
||||
Subtitle string `json:"subtitle,omitempty"`
|
||||
}
|
||||
|
||||
type cardSection struct {
|
||||
Widgets []cardWidget `json:"widgets"`
|
||||
}
|
||||
|
||||
type cardWidget struct {
|
||||
ButtonList buttonList `json:"buttonList"`
|
||||
}
|
||||
|
||||
type buttonList struct {
|
||||
Buttons []button `json:"buttons"`
|
||||
}
|
||||
|
||||
type button struct {
|
||||
Text string `json:"text"`
|
||||
OnClick onClick `json:"onClick"`
|
||||
}
|
||||
|
||||
type onClick struct {
|
||||
OpenLink openLink `json:"openLink"`
|
||||
}
|
||||
|
||||
type openLink struct {
|
||||
URL string `json:"url"`
|
||||
}
|
||||
|
||||
// messageOf is the Chat message of p: its Text and, for a plan or Supply
|
||||
// run, a card whose "Open" button links to p.URL under the public URL.
|
||||
func (s *Service) messageOf(p notify.Payload) (message, error) {
|
||||
msg := message{Text: Text(p)}
|
||||
if !carded[p.Event] {
|
||||
return msg, nil
|
||||
}
|
||||
ref, err := url.Parse(p.URL)
|
||||
if err != nil || ref.Scheme != "" || ref.Host != "" || !strings.HasPrefix(p.URL, "/") {
|
||||
return message{}, fmt.Errorf("payload url %q is not an app path", p.URL)
|
||||
}
|
||||
link := s.publicURL.ResolveReference(ref)
|
||||
msg.CardsV2 = []cardV2{{
|
||||
CardID: string(p.Event),
|
||||
Card: card{
|
||||
Header: cardHeader{Title: p.Title, Subtitle: p.Body},
|
||||
Sections: []cardSection{{Widgets: []cardWidget{{ButtonList: buttonList{Buttons: []button{{
|
||||
Text: "Open",
|
||||
OnClick: onClick{OpenLink: openLink{URL: link.String()}},
|
||||
}}}}}}},
|
||||
},
|
||||
}}
|
||||
return msg, nil
|
||||
}
|
||||
|
||||
// post sends msg to hook, in thread when it is not empty, and returns the
|
||||
// status Google Chat answered. Its error never quotes the webhook URL.
|
||||
func (s *Service) post(ctx context.Context, hook Webhook, msg message, thread string) (int, error) {
|
||||
body, err := json.Marshal(msg)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("chat: encode message: %w", err)
|
||||
}
|
||||
target, err := url.Parse(hook.String())
|
||||
if err != nil {
|
||||
return 0, errors.New("chat: stored webhook URL does not parse")
|
||||
}
|
||||
if thread != "" {
|
||||
q := target.Query()
|
||||
q.Set("threadKey", thread)
|
||||
q.Set("messageReplyOption", replyOption)
|
||||
target.RawQuery = q.Encode()
|
||||
}
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodPost, target.String(), bytes.NewReader(body))
|
||||
if err != nil {
|
||||
return 0, errors.New("chat: build request to the webhook")
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/json; charset=UTF-8")
|
||||
resp, err := s.client.Do(req)
|
||||
if err != nil {
|
||||
// A *url.Error quotes the request URL, the webhook; keep only its
|
||||
// cause.
|
||||
var ue *url.Error
|
||||
if errors.As(err, &ue) {
|
||||
return 0, fmt.Errorf("%w: %w", ErrUnreachable, ue.Err)
|
||||
}
|
||||
return 0, ErrUnreachable
|
||||
}
|
||||
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
|
||||
}
|
||||
|
||||
// open decrypts and validates the stored webhook. Its error never quotes
|
||||
// the URL.
|
||||
func (s *Service) open(row db.ChatSetting) (Webhook, error) {
|
||||
raw, err := s.box.Decrypt(row.WebhookUrlCt)
|
||||
if err != nil {
|
||||
return Webhook{}, fmt.Errorf("chat: open stored webhook: %w", err)
|
||||
}
|
||||
hook, err := ParseWebhook(string(raw))
|
||||
if err != nil {
|
||||
return Webhook{}, fmt.Errorf("chat: stored webhook URL is invalid: %w", err)
|
||||
}
|
||||
return hook, nil
|
||||
}
|
||||
|
||||
func (s *Service) timestamp() string {
|
||||
return store.Timestamp{Time: s.now().UTC().Truncate(time.Millisecond)}.String()
|
||||
}
|
||||
|
||||
func settingsFrom(row db.ChatSetting, hook Webhook) Settings {
|
||||
masked := hook.Masked()
|
||||
return Settings{
|
||||
SpaceLabel: nullString(row.SpaceLabel),
|
||||
URLMasked: &masked,
|
||||
LastTestAt: nullString(row.LastTestAt),
|
||||
LastTestStatus: nullString(row.LastTestStatus),
|
||||
}
|
||||
}
|
||||
|
||||
func nullString(s sql.NullString) *string {
|
||||
if !s.Valid {
|
||||
return nil
|
||||
}
|
||||
return &s.String
|
||||
}
|
||||
@@ -1,530 +0,0 @@
|
||||
package chat
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"database/sql"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"io"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/url"
|
||||
"strings"
|
||||
"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/notify"
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/secretbox"
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/store"
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/migrations"
|
||||
)
|
||||
|
||||
// posted is one request the fake Chat endpoint received.
|
||||
type posted struct {
|
||||
Method, Host, Path string
|
||||
Query url.Values
|
||||
ContentType string
|
||||
Body map[string]any
|
||||
}
|
||||
|
||||
// fakeChat is an httptest Google Chat endpoint answering status.
|
||||
type fakeChat struct {
|
||||
srv *httptest.Server
|
||||
mu sync.Mutex
|
||||
// status is what it answers.
|
||||
status int
|
||||
got []posted
|
||||
}
|
||||
|
||||
func newFakeChat(t *testing.T) *fakeChat {
|
||||
t.Helper()
|
||||
f := &fakeChat{status: http.StatusOK}
|
||||
f.srv = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
data, _ := io.ReadAll(r.Body)
|
||||
var body map[string]any
|
||||
if err := json.Unmarshal(data, &body); err != nil {
|
||||
t.Errorf("Chat endpoint body %q: %v", data, err)
|
||||
}
|
||||
f.mu.Lock()
|
||||
f.got = append(f.got, posted{r.Method, r.Host, r.URL.Path, r.URL.Query(), r.Header.Get("Content-Type"), body})
|
||||
status := f.status
|
||||
f.mu.Unlock()
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(status)
|
||||
w.Write([]byte(`{}`))
|
||||
}))
|
||||
t.Cleanup(f.srv.Close)
|
||||
return f
|
||||
}
|
||||
|
||||
func (f *fakeChat) setStatus(status int) {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
f.status = status
|
||||
}
|
||||
|
||||
func (f *fakeChat) requests() []posted {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
return append([]posted(nil), f.got...)
|
||||
}
|
||||
|
||||
// redirectTo sends every request to the server at target, keeping its path
|
||||
// and query, as if it were chat.googleapis.com.
|
||||
type redirectTo struct {
|
||||
target *url.URL
|
||||
}
|
||||
|
||||
func (r redirectTo) RoundTrip(req *http.Request) (*http.Response, error) {
|
||||
out := req.Clone(req.Context())
|
||||
out.URL.Scheme, out.URL.Host = r.target.Scheme, r.target.Host
|
||||
out.Host = req.URL.Host
|
||||
return http.DefaultTransport.RoundTrip(out)
|
||||
}
|
||||
|
||||
func newTestStore(t *testing.T) *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)
|
||||
}
|
||||
return st
|
||||
}
|
||||
|
||||
func newTestBox(t *testing.T) *secretbox.Box {
|
||||
t.Helper()
|
||||
key := make([]byte, secretbox.KeySize)
|
||||
rand.Read(key)
|
||||
box, err := secretbox.New(base64.StdEncoding.EncodeToString(key))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return box
|
||||
}
|
||||
|
||||
// newTestService returns a service over a migrated temp database whose
|
||||
// requests go to f, with cards linking under https://mealquest.example.
|
||||
func newTestService(t *testing.T, f *fakeChat) (*Service, *store.Store) {
|
||||
t.Helper()
|
||||
st := newTestStore(t)
|
||||
target, err := url.Parse(f.srv.URL)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
pub, err := url.Parse("https://mealquest.example")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
svc, err := NewService(st, newTestBox(t), pub, redirectTo{target})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return svc, st
|
||||
}
|
||||
|
||||
// assertNoSecret fails when s holds the webhook URL, its key or its token.
|
||||
func assertNoSecret(t *testing.T, what, s string) {
|
||||
t.Helper()
|
||||
tokenDecoded, _ := url.QueryUnescape(testToken)
|
||||
for _, secret := range []string{testHook, testKey, testToken, tokenDecoded, testKey[:12], testToken[:12]} {
|
||||
if strings.Contains(s, secret) {
|
||||
t.Errorf("%s holds the webhook URL or a credential: %s", what, s)
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewServiceRequiresDependencies(t *testing.T) {
|
||||
t.Parallel()
|
||||
st, box := newTestStore(t), newTestBox(t)
|
||||
pub, _ := url.Parse("https://mealquest.example")
|
||||
for name, build := range map[string]func() (*Service, error){
|
||||
"store": func() (*Service, error) { return NewService(nil, box, pub, http.DefaultTransport) },
|
||||
"box": func() (*Service, error) { return NewService(st, nil, pub, http.DefaultTransport) },
|
||||
"public URL": func() (*Service, error) { return NewService(st, box, nil, http.DefaultTransport) },
|
||||
"transport": func() (*Service, error) { return NewService(st, box, pub, nil) },
|
||||
} {
|
||||
if _, err := build(); err == nil {
|
||||
t.Errorf("NewService without %s succeeded", name)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestPutGetStoresSealedWebhook(t *testing.T) {
|
||||
t.Parallel()
|
||||
ctx := context.Background()
|
||||
svc, st := newTestService(t, newFakeChat(t))
|
||||
|
||||
got, err := svc.Get(ctx)
|
||||
if err != nil || got.SpaceLabel != nil || got.URLMasked != nil || got.LastTestAt != nil {
|
||||
t.Fatalf("Get unconfigured = %+v, %v", got, err)
|
||||
}
|
||||
|
||||
put, err := svc.Put(ctx, testHook, " Family dinner ")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if *put.SpaceLabel != "Family dinner" || *put.URLMasked != "https://chat.googleapis.com/v1/spaces/"+testSpace+"/messages?key=…&token=…ZxQ=" || put.LastTestAt != nil || put.LastTestStatus != nil {
|
||||
t.Errorf("Put = %+v", put)
|
||||
}
|
||||
got, err = svc.Get(ctx)
|
||||
if err != nil || *got.URLMasked != *put.URLMasked || *got.SpaceLabel != "Family dinner" {
|
||||
t.Errorf("Get = %+v, %v", got, err)
|
||||
}
|
||||
|
||||
row, err := db.New(st.Reader).GetChatSettings(ctx)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if bytes.Contains(row.WebhookUrlCt, []byte(testKey)) || bytes.Contains(row.WebhookUrlCt, []byte("chat.googleapis.com")) {
|
||||
t.Errorf("webhook_url_ct holds the URL in plain text")
|
||||
}
|
||||
plain, err := svc.box.Decrypt(row.WebhookUrlCt)
|
||||
if err != nil || string(plain) != testHook {
|
||||
t.Errorf("webhook_url_ct decrypts to %q, %v", plain, err)
|
||||
}
|
||||
encoded, _ := json.Marshal(got)
|
||||
assertNoSecret(t, "GET settings", string(encoded))
|
||||
}
|
||||
|
||||
func TestPutRefusesInvalid(t *testing.T) {
|
||||
t.Parallel()
|
||||
ctx := context.Background()
|
||||
svc, st := newTestService(t, newFakeChat(t))
|
||||
for _, tc := range []struct {
|
||||
url, label string
|
||||
fields []string
|
||||
}{
|
||||
{"https://chat.googleapis.com.evil.example/v1/spaces/X/messages?key=" + testKey + "&token=" + testToken, "Family", []string{"url"}},
|
||||
{testHook, " ", []string{"space_label"}},
|
||||
{testHook, strings.Repeat("x", MaxSpaceLabelChars+1), []string{"space_label"}},
|
||||
{testHook, "bad\nlabel", []string{"space_label"}},
|
||||
{"http://chat.googleapis.com/v1/spaces/X/messages?key=" + testKey + "&token=" + testToken, "", []string{"url", "space_label"}},
|
||||
} {
|
||||
_, err := svc.Put(ctx, tc.url, tc.label)
|
||||
var invalid *ValidationError
|
||||
if !errors.As(err, &invalid) || len(invalid.Problems) != len(tc.fields) {
|
||||
t.Errorf("Put(%q, %q) = %v, want problems with %v", tc.url, tc.label, err, tc.fields)
|
||||
continue
|
||||
}
|
||||
for i, p := range invalid.Problems {
|
||||
if p.Field != tc.fields[i] {
|
||||
t.Errorf("Put(%q, %q) problem %d = %s, want %s", tc.url, tc.label, i, p.Field, tc.fields[i])
|
||||
}
|
||||
}
|
||||
assertNoSecret(t, "validation error", err.Error())
|
||||
}
|
||||
if _, err := db.New(st.Reader).GetChatSettings(ctx); !errors.Is(err, sql.ErrNoRows) {
|
||||
t.Errorf("a refused Put stored a row: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTestRecordsOutcome(t *testing.T) {
|
||||
t.Parallel()
|
||||
ctx := context.Background()
|
||||
f := newFakeChat(t)
|
||||
svc, _ := newTestService(t, f)
|
||||
svc.now = func() time.Time { return time.Date(2026, 10, 9, 15, 0, 0, 0, time.UTC) }
|
||||
|
||||
if _, err := svc.Test(ctx); !errors.Is(err, ErrNotConfigured) {
|
||||
t.Fatalf("Test unconfigured = %v, want ErrNotConfigured", err)
|
||||
}
|
||||
if _, err := svc.Put(ctx, testHook, "Family"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
res, err := svc.Test(ctx)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if res.HTTPStatus != 200 || *res.LastTestStatus != TestOK || *res.LastTestAt != "2026-10-09T15:00:00.000Z" {
|
||||
t.Errorf("Test = %+v, want 200 ok", res)
|
||||
}
|
||||
reqs := f.requests()
|
||||
if len(reqs) != 1 {
|
||||
t.Fatalf("Chat got %d requests, want 1", len(reqs))
|
||||
}
|
||||
r := reqs[0]
|
||||
if r.Method != http.MethodPost || r.Host != "chat.googleapis.com" || r.Path != "/v1/spaces/"+testSpace+"/messages" ||
|
||||
r.Query.Get("key") != testKey || r.Query.Get("threadKey") != "" || r.Body["text"] != TestText || r.Body["cardsV2"] != nil {
|
||||
t.Errorf("test request = %+v", r)
|
||||
}
|
||||
|
||||
f.setStatus(http.StatusNotFound)
|
||||
res, err = svc.Test(ctx)
|
||||
if err != nil || res.HTTPStatus != 404 || *res.LastTestStatus != TestFailed {
|
||||
t.Errorf("Test answered 404 = %+v, %v; want 404 failed", res, err)
|
||||
}
|
||||
got, _ := svc.Get(ctx)
|
||||
if *got.LastTestStatus != TestFailed || *got.LastTestAt != "2026-10-09T15:00:00.000Z" {
|
||||
t.Errorf("Get after a failed test = %+v", got)
|
||||
}
|
||||
|
||||
// A new webhook has no test outcome.
|
||||
got, err = svc.Put(ctx, testHook, "Family")
|
||||
if err != nil || got.LastTestAt != nil || got.LastTestStatus != nil {
|
||||
t.Errorf("Put after a test = %+v, %v; want the outcome cleared", got, err)
|
||||
}
|
||||
|
||||
f.srv.Close()
|
||||
_, err = svc.Test(ctx)
|
||||
if !errors.Is(err, ErrUnreachable) {
|
||||
t.Errorf("Test against a stopped endpoint = %v, want ErrUnreachable", err)
|
||||
} else {
|
||||
assertNoSecret(t, "unreachable error", err.Error())
|
||||
}
|
||||
if got, _ := svc.Get(ctx); got.LastTestStatus == nil || *got.LastTestStatus != TestFailed {
|
||||
t.Errorf("Get after an unanswered test = %+v, want failed", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSendThreadsByWeek(t *testing.T) {
|
||||
t.Parallel()
|
||||
ctx := context.Background()
|
||||
f := newFakeChat(t)
|
||||
svc, _ := newTestService(t, f)
|
||||
if _, err := svc.Put(ctx, testHook, "Family"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
week := mustDate(t, "2026-10-12")
|
||||
events := []notify.Event{
|
||||
PlanReady(week, []string{"Tacos", "Bolognese", "Tikka masala", "A", "B", "C", "D"}),
|
||||
ListReady(week, 19, 7),
|
||||
DayOfReminder(mustDate(t, "2026-10-18"), "01J9DINNER", "Tacos"),
|
||||
NewSuggestions("01J9BATCH", week, []string{"Pho"}),
|
||||
RotationReview("01J9DINNER", "Pho", week),
|
||||
}
|
||||
for _, ev := range events {
|
||||
if err := svc.Send(ctx, notify.Message{ID: "n1", EventKey: ev.Key, Channel: notify.ChannelChat, Payload: payloadOf(ev), Attempt: 1}); err != nil {
|
||||
t.Fatalf("Send %s: %v", ev.Type, err)
|
||||
}
|
||||
}
|
||||
reqs := f.requests()
|
||||
if len(reqs) != len(events) {
|
||||
t.Fatalf("Chat got %d requests, want %d", len(reqs), len(events))
|
||||
}
|
||||
for i, r := range reqs {
|
||||
ev := events[i]
|
||||
if r.Method != http.MethodPost || r.Path != "/v1/spaces/"+testSpace+"/messages" || !strings.HasPrefix(r.ContentType, "application/json") {
|
||||
t.Errorf("%s: request = %+v", ev.Type, r)
|
||||
}
|
||||
if r.Query.Get("key") != testKey || r.Query.Get("token") != strings.ReplaceAll(testToken, "%3D", "=") {
|
||||
t.Errorf("%s: key and token not kept: %v", ev.Type, r.Query)
|
||||
}
|
||||
if r.Query.Get("threadKey") != "mealquest-week-2026-W42" || r.Query.Get("messageReplyOption") != "REPLY_MESSAGE_FALLBACK_TO_NEW_THREAD" {
|
||||
t.Errorf("%s: threading = %v", ev.Type, r.Query)
|
||||
}
|
||||
if want := Text(payloadOf(ev)); r.Body["text"] != want {
|
||||
t.Errorf("%s: text = %q, want %q", ev.Type, r.Body["text"], want)
|
||||
}
|
||||
_, hasCards := r.Body["cardsV2"]
|
||||
if hasCards != (ev.Type == notify.PlanReady || ev.Type == notify.ListReady) {
|
||||
t.Errorf("%s: cardsV2 present = %v", ev.Type, hasCards)
|
||||
}
|
||||
}
|
||||
if reqs[0].Body["text"] != "This week's plan is ready: Tacos, Bolognese, Tikka masala + 4 more. Open the plan →" {
|
||||
t.Errorf("plan text = %q", reqs[0].Body["text"])
|
||||
}
|
||||
|
||||
// The cards' "Open" buttons link under the public URL.
|
||||
for i, wantLink := range map[int]string{0: "https://mealquest.example/plan", 1: "https://mealquest.example/plan/shopping"} {
|
||||
data, _ := json.Marshal(reqs[i].Body["cardsV2"])
|
||||
var cards []cardV2
|
||||
if err := json.Unmarshal(data, &cards); err != nil || len(cards) != 1 {
|
||||
t.Fatalf("cardsV2 = %s, %v", data, err)
|
||||
}
|
||||
c := cards[0]
|
||||
buttons := c.Card.Sections[0].Widgets[0].ButtonList.Buttons
|
||||
if c.CardID != string(events[i].Type) || c.Card.Header.Title != events[i].Title || c.Card.Header.Subtitle != events[i].Body ||
|
||||
len(buttons) != 1 || buttons[0].Text != "Open" || buttons[0].OnClick.OpenLink.URL != wantLink {
|
||||
t.Errorf("%s card = %s, want an Open button to %s", events[i].Type, data, wantLink)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestSendWithoutWeekIsUnthreaded(t *testing.T) {
|
||||
t.Parallel()
|
||||
ctx := context.Background()
|
||||
f := newFakeChat(t)
|
||||
svc, _ := newTestService(t, f)
|
||||
if _, err := svc.Put(ctx, testHook, "Family"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
p := notify.Payload{Event: notify.RotationReview, Title: "Keep it in rotation?", URL: "/", Tag: "rotation_review:x"}
|
||||
if err := svc.Send(ctx, notify.Message{Payload: p}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if q := f.requests()[0].Query; q.Has("threadKey") || q.Has("messageReplyOption") {
|
||||
t.Errorf("query = %v, want no threading", q)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSendClassifiesStatus(t *testing.T) {
|
||||
t.Parallel()
|
||||
ctx := context.Background()
|
||||
f := newFakeChat(t)
|
||||
svc, _ := newTestService(t, f)
|
||||
if _, err := svc.Put(ctx, testHook, "Family"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
m := notify.Message{Payload: payloadOf(PlanReady(mustDate(t, "2026-10-12"), []string{"Tacos"}))}
|
||||
for _, tc := range []struct {
|
||||
status int
|
||||
ok, permanent bool
|
||||
}{
|
||||
{200, true, false},
|
||||
{204, true, false},
|
||||
{301, false, true},
|
||||
{400, false, true},
|
||||
{401, false, true},
|
||||
{403, false, true},
|
||||
{404, false, true},
|
||||
{413, false, true},
|
||||
{429, false, false},
|
||||
{500, false, false},
|
||||
{502, false, false},
|
||||
{503, false, false},
|
||||
} {
|
||||
f.setStatus(tc.status)
|
||||
err := svc.Send(ctx, m)
|
||||
if (err == nil) != tc.ok || errors.Is(err, notify.ErrUndeliverable) != tc.permanent {
|
||||
t.Errorf("status %d: Send = %v, want ok %v, undeliverable %v", tc.status, err, tc.ok, tc.permanent)
|
||||
}
|
||||
if err != nil {
|
||||
assertNoSecret(t, "Send error", err.Error())
|
||||
}
|
||||
}
|
||||
|
||||
f.srv.Close()
|
||||
err := svc.Send(ctx, m)
|
||||
if err == nil || errors.Is(err, notify.ErrUndeliverable) {
|
||||
t.Errorf("Send to a stopped endpoint = %v, want a retryable error", err)
|
||||
} else {
|
||||
assertNoSecret(t, "Send error", err.Error())
|
||||
}
|
||||
}
|
||||
|
||||
func TestSendWithoutWebhookIsUndeliverable(t *testing.T) {
|
||||
t.Parallel()
|
||||
svc, _ := newTestService(t, newFakeChat(t))
|
||||
err := svc.Send(context.Background(), notify.Message{Payload: payloadOf(ListReady(mustDate(t, "2026-10-12"), 3, 2))})
|
||||
if !errors.Is(err, notify.ErrUndeliverable) || !errors.Is(err, ErrNotConfigured) {
|
||||
t.Errorf("Send = %v, want undeliverable, not configured", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestOutboxRowsFollowClassification delivers through the notify worker: a
|
||||
// 400 fails the row at once, a 503 leaves it pending for a retry. Neither
|
||||
// the stored error nor the log holds the webhook URL.
|
||||
func TestOutboxRowsFollowClassification(t *testing.T) {
|
||||
t.Parallel()
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
f := newFakeChat(t)
|
||||
svc, st := newTestService(t, f)
|
||||
if _, err := svc.Put(ctx, testHook, "Family"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := household.SeedNotificationPrefs(ctx, st); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := st.Writer.Exec(`UPDATE notification_prefs SET push = 0, chat = 1`); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var logs syncBuffer
|
||||
notifier, err := notify.NewService(st, slog.New(slog.NewTextHandler(&logs, nil)))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := notifier.Register(notify.ChannelChat, svc); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := notifier.Start(ctx); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() {
|
||||
cancel()
|
||||
notifier.Wait()
|
||||
})
|
||||
|
||||
week := mustDate(t, "2026-10-12")
|
||||
f.setStatus(http.StatusBadRequest)
|
||||
planned := PlanReady(week, []string{"Tacos"})
|
||||
if err := notifier.Emit(ctx, planned); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
status, lastErr := waitForAttempt(t, st, planned.Key)
|
||||
if status != "failed" || !strings.Contains(lastErr, "status 400") {
|
||||
t.Errorf("row after a 400 = %s %q, want failed", status, lastErr)
|
||||
}
|
||||
|
||||
f.setStatus(http.StatusServiceUnavailable)
|
||||
listed := ListReady(week, 3, 1)
|
||||
if err := notifier.Emit(ctx, listed); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
status, lastErr = waitForAttempt(t, st, listed.Key)
|
||||
if status != "pending" || !strings.Contains(lastErr, "status 503") {
|
||||
t.Errorf("row after a 503 = %s %q, want pending for a retry", status, lastErr)
|
||||
}
|
||||
cancel()
|
||||
notifier.Wait()
|
||||
assertNoSecret(t, "log", logs.String())
|
||||
assertNoSecret(t, "last_error", lastErr)
|
||||
}
|
||||
|
||||
// waitForAttempt waits for the chat row of key to have been attempted and
|
||||
// returns its status and last error.
|
||||
func waitForAttempt(t *testing.T, st *store.Store, key string) (string, string) {
|
||||
t.Helper()
|
||||
deadline := time.Now().Add(5 * time.Second)
|
||||
for time.Now().Before(deadline) {
|
||||
var status string
|
||||
var attempts int
|
||||
var lastErr sql.NullString
|
||||
err := st.Reader.QueryRow(`SELECT status, attempts, last_error FROM notification_outbox WHERE event_key = ? AND channel = 'chat'`, key).
|
||||
Scan(&status, &attempts, &lastErr)
|
||||
if err != nil && !errors.Is(err, sql.ErrNoRows) {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if attempts > 0 {
|
||||
return status, lastErr.String
|
||||
}
|
||||
time.Sleep(10 * time.Millisecond)
|
||||
}
|
||||
t.Fatalf("chat row of %s was never attempted", key)
|
||||
return "", ""
|
||||
}
|
||||
|
||||
// syncBuffer is a bytes.Buffer safe for the worker's logger.
|
||||
type syncBuffer struct {
|
||||
mu sync.Mutex
|
||||
buf bytes.Buffer
|
||||
}
|
||||
|
||||
func (b *syncBuffer) Write(p []byte) (int, error) {
|
||||
b.mu.Lock()
|
||||
defer b.mu.Unlock()
|
||||
return b.buf.Write(p)
|
||||
}
|
||||
|
||||
func (b *syncBuffer) String() string {
|
||||
b.mu.Lock()
|
||||
defer b.mu.Unlock()
|
||||
return b.buf.String()
|
||||
}
|
||||
@@ -1,144 +0,0 @@
|
||||
package chat
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"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"
|
||||
)
|
||||
|
||||
// This file is the one place the notification texts are written. The
|
||||
// builders make the notify.Event of each event type, whose title and body
|
||||
// the push channel shows as they are; Text turns its payload into the Google
|
||||
// Chat message, such as "This week's plan is ready: Tacos, Bolognese,
|
||||
// Tikka masala + 4 more. Open the plan →".
|
||||
|
||||
// TestText is the message POST /settings/chat-webhook/test sends.
|
||||
const TestText = "MealQuest · test message"
|
||||
|
||||
// listedNames is how many names a message lists before "+ N more".
|
||||
const listedNames = 3
|
||||
|
||||
// actions is the closing call to action of each event's Chat message.
|
||||
var actions = map[notify.EventType]string{
|
||||
notify.PlanReady: "Open the plan",
|
||||
notify.ListReady: "Open the Supply run",
|
||||
notify.DayOfReminder: "Open the recipe",
|
||||
notify.NewSuggestions: "Open Discover",
|
||||
notify.RotationReview: "Open MealQuest",
|
||||
}
|
||||
|
||||
// carded are the events whose Chat message also has a card with an "Open"
|
||||
// button.
|
||||
var carded = map[notify.EventType]bool{notify.PlanReady: true, notify.ListReady: true}
|
||||
|
||||
// PlanReady is the plan_ready event of the week starting on week, naming
|
||||
// its dinners in plan order.
|
||||
func PlanReady(week store.LocalDate, dinners []string) notify.Event {
|
||||
return notify.Event{
|
||||
Type: notify.PlanReady,
|
||||
Key: string(notify.PlanReady) + ":" + week.String(),
|
||||
Title: "This week's plan is ready",
|
||||
Body: nameList(dinners),
|
||||
URL: "/plan",
|
||||
Week: week,
|
||||
}
|
||||
}
|
||||
|
||||
// ListReady is the list_ready event of the Supply run of the week starting
|
||||
// on week: needed items to buy for its dinners.
|
||||
func ListReady(week store.LocalDate, needed, dinners int) notify.Event {
|
||||
return notify.Event{
|
||||
Type: notify.ListReady,
|
||||
Key: string(notify.ListReady) + ":" + week.String(),
|
||||
Title: "This week's Supply run is ready",
|
||||
Body: fmt.Sprintf("%s to buy for %s", count(needed, "item", "items"), count(dinners, "dinner", "dinners")),
|
||||
URL: "/plan/shopping",
|
||||
Week: week,
|
||||
}
|
||||
}
|
||||
|
||||
// DayOfReminder is the day_of_reminder event of the dinner dinnerID, named
|
||||
// name, planned on date.
|
||||
func DayOfReminder(date store.LocalDate, dinnerID, name string) notify.Event {
|
||||
return notify.Event{
|
||||
Type: notify.DayOfReminder,
|
||||
Key: string(notify.DayOfReminder) + ":" + date.String(),
|
||||
Title: "Tonight's dinner",
|
||||
Body: name,
|
||||
URL: "/dinner/" + dinnerID,
|
||||
Week: household.WeekStart(date),
|
||||
}
|
||||
}
|
||||
|
||||
// NewSuggestions is the new_suggestions event of suggestion batch batchID,
|
||||
// made in the week starting on week, naming its Side quests.
|
||||
func NewSuggestions(batchID string, week store.LocalDate, names []string) notify.Event {
|
||||
return notify.Event{
|
||||
Type: notify.NewSuggestions,
|
||||
Key: string(notify.NewSuggestions) + ":" + batchID,
|
||||
Title: "New Side quests are in",
|
||||
Body: nameList(names),
|
||||
URL: "/discover",
|
||||
Week: week,
|
||||
}
|
||||
}
|
||||
|
||||
// RotationReview is the rotation_review event asking whether the dinner
|
||||
// dinnerID, named name and tried in the week starting on week, becomes a
|
||||
// Regular.
|
||||
func RotationReview(dinnerID, name string, week store.LocalDate) notify.Event {
|
||||
return notify.Event{
|
||||
Type: notify.RotationReview,
|
||||
Key: string(notify.RotationReview) + ":" + dinnerID,
|
||||
Title: "Keep it in rotation?",
|
||||
Body: "You tried " + name + ". Make it a Regular?",
|
||||
URL: "/",
|
||||
Week: week,
|
||||
}
|
||||
}
|
||||
|
||||
// Text is the Google Chat message of p: its title, its body after a colon
|
||||
// and the event's call to action with an arrow.
|
||||
func Text(p notify.Payload) string {
|
||||
text := p.Title
|
||||
if p.Body != "" {
|
||||
if endsSentence(text) {
|
||||
text += " "
|
||||
} else {
|
||||
text += ": "
|
||||
}
|
||||
text += p.Body
|
||||
}
|
||||
if !endsSentence(text) {
|
||||
text += "."
|
||||
}
|
||||
if action, ok := actions[p.Event]; ok {
|
||||
text += " " + action + " →"
|
||||
}
|
||||
return text
|
||||
}
|
||||
|
||||
// endsSentence reports whether s ends with sentence punctuation.
|
||||
func endsSentence(s string) bool {
|
||||
return strings.HasSuffix(s, ".") || strings.HasSuffix(s, "?") || strings.HasSuffix(s, "!")
|
||||
}
|
||||
|
||||
// nameList lists the first listedNames of names, then how many more there
|
||||
// are: "Tacos, Bolognese, Tikka masala + 4 more".
|
||||
func nameList(names []string) string {
|
||||
if len(names) <= listedNames {
|
||||
return strings.Join(names, ", ")
|
||||
}
|
||||
return fmt.Sprintf("%s + %d more", strings.Join(names[:listedNames], ", "), len(names)-listedNames)
|
||||
}
|
||||
|
||||
// count is n with the singular or plural noun.
|
||||
func count(n int, one, many string) string {
|
||||
if n == 1 {
|
||||
return "1 " + one
|
||||
}
|
||||
return fmt.Sprintf("%d %s", n, many)
|
||||
}
|
||||
@@ -1,137 +0,0 @@
|
||||
package chat
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/notify"
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/store"
|
||||
)
|
||||
|
||||
func mustDate(t *testing.T, s string) store.LocalDate {
|
||||
t.Helper()
|
||||
d, err := store.ParseLocalDate(s)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return d
|
||||
}
|
||||
|
||||
// payloadOf is the payload notify stores for ev.
|
||||
func payloadOf(ev notify.Event) notify.Payload {
|
||||
p := notify.Payload{Event: ev.Type, Title: ev.Title, Body: ev.Body, URL: ev.URL, Tag: ev.Key}
|
||||
if !ev.Week.IsZero() {
|
||||
p.Week = ev.Week.String()
|
||||
}
|
||||
return p
|
||||
}
|
||||
|
||||
func TestMessages(t *testing.T) {
|
||||
t.Parallel()
|
||||
week := mustDate(t, "2026-10-12")
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
ev notify.Event
|
||||
key, url, week string
|
||||
title, body, chatT string
|
||||
}{
|
||||
{
|
||||
name: "plan ready",
|
||||
ev: PlanReady(week, []string{"Tacos", "Bolognese", "Tikka masala", "Lemon orzo", "Bibimbap", "Shakshuka", "Chili"}),
|
||||
key: "plan_ready:2026-10-12",
|
||||
url: "/plan",
|
||||
week: "2026-10-12",
|
||||
title: "This week's plan is ready",
|
||||
body: "Tacos, Bolognese, Tikka masala + 4 more",
|
||||
chatT: "This week's plan is ready: Tacos, Bolognese, Tikka masala + 4 more. Open the plan →",
|
||||
},
|
||||
{
|
||||
name: "plan ready with three dinners",
|
||||
ev: PlanReady(week, []string{"Tacos", "Bolognese", "Tikka masala"}),
|
||||
key: "plan_ready:2026-10-12",
|
||||
url: "/plan",
|
||||
week: "2026-10-12",
|
||||
title: "This week's plan is ready",
|
||||
body: "Tacos, Bolognese, Tikka masala",
|
||||
chatT: "This week's plan is ready: Tacos, Bolognese, Tikka masala. Open the plan →",
|
||||
},
|
||||
{
|
||||
name: "list ready",
|
||||
ev: ListReady(week, 19, 7),
|
||||
key: "list_ready:2026-10-12",
|
||||
url: "/plan/shopping",
|
||||
week: "2026-10-12",
|
||||
title: "This week's Supply run is ready",
|
||||
body: "19 items to buy for 7 dinners",
|
||||
chatT: "This week's Supply run is ready: 19 items to buy for 7 dinners. Open the Supply run →",
|
||||
},
|
||||
{
|
||||
name: "list ready singular",
|
||||
ev: ListReady(week, 1, 1),
|
||||
key: "list_ready:2026-10-12",
|
||||
url: "/plan/shopping",
|
||||
week: "2026-10-12",
|
||||
title: "This week's Supply run is ready",
|
||||
body: "1 item to buy for 1 dinner",
|
||||
chatT: "This week's Supply run is ready: 1 item to buy for 1 dinner. Open the Supply run →",
|
||||
},
|
||||
{
|
||||
name: "day-of reminder",
|
||||
ev: DayOfReminder(mustDate(t, "2026-10-15"), "01J9Z8Y7X6W5V4T3S2R1Q0P9N8", "Tacos"),
|
||||
key: "day_of_reminder:2026-10-15",
|
||||
url: "/dinner/01J9Z8Y7X6W5V4T3S2R1Q0P9N8",
|
||||
week: "2026-10-12",
|
||||
title: "Tonight's dinner",
|
||||
body: "Tacos",
|
||||
chatT: "Tonight's dinner: Tacos. Open the recipe →",
|
||||
},
|
||||
{
|
||||
name: "new suggestions",
|
||||
ev: NewSuggestions("01J9BATCH", week, []string{"Lemon orzo", "Bibimbap", "Shakshuka", "Pho", "Paella"}),
|
||||
key: "new_suggestions:01J9BATCH",
|
||||
url: "/discover",
|
||||
week: "2026-10-12",
|
||||
title: "New Side quests are in",
|
||||
body: "Lemon orzo, Bibimbap, Shakshuka + 2 more",
|
||||
chatT: "New Side quests are in: Lemon orzo, Bibimbap, Shakshuka + 2 more. Open Discover →",
|
||||
},
|
||||
{
|
||||
name: "rotation review",
|
||||
ev: RotationReview("01J9DINNER", "Lemon orzo", week),
|
||||
key: "rotation_review:01J9DINNER",
|
||||
url: "/",
|
||||
week: "2026-10-12",
|
||||
title: "Keep it in rotation?",
|
||||
body: "You tried Lemon orzo. Make it a Regular?",
|
||||
chatT: "Keep it in rotation? You tried Lemon orzo. Make it a Regular? Open MealQuest →",
|
||||
},
|
||||
} {
|
||||
ev := tc.ev
|
||||
if ev.Key != tc.key || ev.URL != tc.url || ev.Week.String() != tc.week || ev.Title != tc.title || ev.Body != tc.body {
|
||||
t.Errorf("%s: event = %+v, want key %q url %q week %s title %q body %q", tc.name, ev, tc.key, tc.url, tc.week, tc.title, tc.body)
|
||||
}
|
||||
if got := Text(payloadOf(ev)); got != tc.chatT {
|
||||
t.Errorf("%s: Text = %q, want %q", tc.name, got, tc.chatT)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestTextWithoutBody(t *testing.T) {
|
||||
t.Parallel()
|
||||
got := Text(notify.Payload{Event: notify.PlanReady, Title: "This week's plan is ready", URL: "/plan"})
|
||||
if want := "This week's plan is ready. Open the plan →"; got != want {
|
||||
t.Errorf("Text = %q, want %q", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestThreadKey(t *testing.T) {
|
||||
t.Parallel()
|
||||
for date, want := range map[string]string{
|
||||
"2026-10-12": "mealquest-week-2026-W42",
|
||||
"2026-12-28": "mealquest-week-2026-W53",
|
||||
"2027-01-04": "mealquest-week-2027-W01",
|
||||
} {
|
||||
if got := ThreadKey(mustDate(t, date)); got != want {
|
||||
t.Errorf("ThreadKey(%s) = %q, want %q", date, got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,115 +0,0 @@
|
||||
package chat
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/url"
|
||||
"regexp"
|
||||
"strings"
|
||||
)
|
||||
|
||||
const (
|
||||
// webhookPrefix is how every accepted webhook URL starts, compared
|
||||
// exactly: no other scheme, host spelling, port or user info.
|
||||
webhookPrefix = "https://chat.googleapis.com/v1/spaces/"
|
||||
// webhookHost is the only host a webhook URL may name.
|
||||
webhookHost = "chat.googleapis.com"
|
||||
// MaxWebhookURLLength bounds an accepted webhook URL.
|
||||
MaxWebhookURLLength = 2048
|
||||
// MinCredentialLength is the shortest key or token accepted, so the
|
||||
// masked URL's token tail never gives away most of it.
|
||||
MinCredentialLength = 16
|
||||
// maskedTail is how many trailing characters of the token the masked
|
||||
// URL shows.
|
||||
maskedTail = 4
|
||||
)
|
||||
|
||||
// webhookPath is the path of a space's incoming webhook; the group is the
|
||||
// space id.
|
||||
var webhookPath = regexp.MustCompile(`^/v1/spaces/([A-Za-z0-9_-]{1,128})/messages$`)
|
||||
|
||||
// credentialChars is the alphabet of a decoded key or token: URL-safe
|
||||
// ASCII, so its length in bytes is its length in characters.
|
||||
var credentialChars = regexp.MustCompile(`^[A-Za-z0-9_.~=-]*$`)
|
||||
|
||||
// Webhook is a validated Google Chat incoming webhook URL. Its String is
|
||||
// the full URL, a secret: it is only ever sealed for storage or sent to
|
||||
// Google Chat, never logged, shown or put in an error.
|
||||
type Webhook struct {
|
||||
raw string
|
||||
space string
|
||||
token string
|
||||
}
|
||||
|
||||
// ParseWebhook validates raw as
|
||||
// https://chat.googleapis.com/v1/spaces/{space}/messages?key=…&token=…,
|
||||
// whose raw query is exactly key=…&token=…, in that order, each decoding to
|
||||
// at least MinCredentialLength characters of credentialChars. Its error
|
||||
// never quotes raw.
|
||||
func ParseWebhook(raw string) (Webhook, error) {
|
||||
switch {
|
||||
case raw == "":
|
||||
return Webhook{}, errors.New("is required")
|
||||
case len(raw) > MaxWebhookURLLength:
|
||||
return Webhook{}, fmt.Errorf("must be at most %d characters", MaxWebhookURLLength)
|
||||
case strings.ContainsFunc(raw, func(r rune) bool { return r <= ' ' || r == 0x7f }):
|
||||
return Webhook{}, errors.New("must not contain spaces or control characters")
|
||||
case !strings.HasPrefix(raw, webhookPrefix):
|
||||
return Webhook{}, errors.New("must be a Google Chat incoming webhook URL starting with " + webhookPrefix)
|
||||
case strings.Contains(raw, "#"):
|
||||
return Webhook{}, errors.New("must not have a fragment")
|
||||
}
|
||||
u, err := url.Parse(raw)
|
||||
if err != nil {
|
||||
return Webhook{}, errors.New("is not a valid URL")
|
||||
}
|
||||
if u.Scheme != "https" || u.Host != webhookHost || u.User != nil || u.Opaque != "" || u.RawPath != "" {
|
||||
return Webhook{}, errors.New("must be a Google Chat incoming webhook URL starting with " + webhookPrefix)
|
||||
}
|
||||
m := webhookPath.FindStringSubmatch(u.Path)
|
||||
if m == nil {
|
||||
return Webhook{}, errors.New("path must be /v1/spaces/{space}/messages")
|
||||
}
|
||||
// The raw query is exactly key=…&token=…: no empty segment, no second
|
||||
// "?", no other or repeated parameter.
|
||||
if strings.Contains(u.RawQuery, "?") {
|
||||
return Webhook{}, errors.New("query must not contain ?")
|
||||
}
|
||||
segments := strings.Split(u.RawQuery, "&")
|
||||
if len(segments) != 2 {
|
||||
return Webhook{}, errors.New("query must be exactly key=…&token=…")
|
||||
}
|
||||
creds := make([]string, 2)
|
||||
for i, name := range []string{"key", "token"} {
|
||||
escaped, ok := strings.CutPrefix(segments[i], name+"=")
|
||||
if !ok {
|
||||
return Webhook{}, errors.New("query must be exactly key=…&token=…")
|
||||
}
|
||||
v, err := url.QueryUnescape(escaped)
|
||||
if err != nil {
|
||||
return Webhook{}, errors.New("query is not valid")
|
||||
}
|
||||
if !credentialChars.MatchString(v) {
|
||||
return Webhook{}, fmt.Errorf("%s must be letters, digits, -, _, ., ~ or =", name)
|
||||
}
|
||||
if len(v) < MinCredentialLength {
|
||||
return Webhook{}, fmt.Errorf("%s must be at least %d characters", name, MinCredentialLength)
|
||||
}
|
||||
creds[i] = v
|
||||
}
|
||||
return Webhook{raw: raw, space: m[1], token: creds[1]}, nil
|
||||
}
|
||||
|
||||
// String returns the full webhook URL, a secret.
|
||||
func (w Webhook) String() string {
|
||||
return w.raw
|
||||
}
|
||||
|
||||
// Masked returns the URL with its key hidden and only the last few
|
||||
// characters of its token, such as
|
||||
// "https://chat.googleapis.com/v1/spaces/AAQA1234/messages?key=…&token=…x3Dk".
|
||||
func (w Webhook) Masked() string {
|
||||
// The token is ASCII of at least MinCredentialLength bytes, so the
|
||||
// tail is maskedTail of its many characters.
|
||||
return webhookPrefix + w.space + "/messages?key=…&token=…" + w.token[len(w.token)-maskedTail:]
|
||||
}
|
||||
@@ -1,101 +0,0 @@
|
||||
package chat
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
const (
|
||||
testKey = "AIzaSyDdI0hCZtE6vySjMm-WEfRq3CPzqKqqsHI"
|
||||
testToken = "q0Lz3kV9Yw8pXcRtN2bHsJdFgA7uEmKiOlP1nZxQ%3D"
|
||||
testSpace = "AAQA1b2C3d4"
|
||||
testHook = "https://chat.googleapis.com/v1/spaces/" + testSpace + "/messages?key=" + testKey + "&token=" + testToken
|
||||
)
|
||||
|
||||
func TestParseWebhookAccepts(t *testing.T) {
|
||||
t.Parallel()
|
||||
for _, raw := range []string{
|
||||
testHook,
|
||||
"https://chat.googleapis.com/v1/spaces/AAAA_x-y/messages?key=" + testKey + "&token=" + testToken,
|
||||
} {
|
||||
hook, err := ParseWebhook(raw)
|
||||
if err != nil {
|
||||
t.Errorf("ParseWebhook(%q) = %v, want accepted", raw, err)
|
||||
continue
|
||||
}
|
||||
if hook.String() != raw {
|
||||
t.Errorf("String() = %q, want %q", hook.String(), raw)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseWebhookRejects(t *testing.T) {
|
||||
t.Parallel()
|
||||
q := "?key=" + testKey + "&token=" + testToken
|
||||
for name, raw := range map[string]string{
|
||||
"empty": "",
|
||||
"http": "http://chat.googleapis.com/v1/spaces/" + testSpace + "/messages" + q,
|
||||
"upper-case scheme": "HTTPS://chat.googleapis.com/v1/spaces/" + testSpace + "/messages" + q,
|
||||
"lookalike suffix": "https://chat.googleapis.com.evil.example/v1/spaces/" + testSpace + "/messages" + q,
|
||||
"lookalike prefix": "https://evilchat.googleapis.com/v1/spaces/" + testSpace + "/messages" + q,
|
||||
"other google host": "https://www.googleapis.com/v1/spaces/" + testSpace + "/messages" + q,
|
||||
"upper-case host": "https://CHAT.googleapis.com/v1/spaces/" + testSpace + "/messages" + q,
|
||||
"port": "https://chat.googleapis.com:443/v1/spaces/" + testSpace + "/messages" + q,
|
||||
"user info": "https://user@chat.googleapis.com/v1/spaces/" + testSpace + "/messages" + q,
|
||||
"trailing dot host": "https://chat.googleapis.com./v1/spaces/" + testSpace + "/messages" + q,
|
||||
"no space": "https://chat.googleapis.com/v1/spaces//messages" + q,
|
||||
"extra path": "https://chat.googleapis.com/v1/spaces/" + testSpace + "/messages/x" + q,
|
||||
"other version": "https://chat.googleapis.com/v2/spaces/" + testSpace + "/messages" + q,
|
||||
"members path": "https://chat.googleapis.com/v1/spaces/" + testSpace + "/members" + q,
|
||||
"escaped path": "https://chat.googleapis.com/v1/spaces/AA%2FQA/messages" + q,
|
||||
"dot segment": "https://chat.googleapis.com/v1/spaces/../messages" + q,
|
||||
"no query": "https://chat.googleapis.com/v1/spaces/" + testSpace + "/messages",
|
||||
"no key": "https://chat.googleapis.com/v1/spaces/" + testSpace + "/messages?token=" + testToken,
|
||||
"no token": "https://chat.googleapis.com/v1/spaces/" + testSpace + "/messages?key=" + testKey,
|
||||
"empty token": "https://chat.googleapis.com/v1/spaces/" + testSpace + "/messages?key=" + testKey + "&token=",
|
||||
"short key": "https://chat.googleapis.com/v1/spaces/" + testSpace + "/messages?key=abc&token=" + testToken,
|
||||
"token first": "https://chat.googleapis.com/v1/spaces/" + testSpace + "/messages?token=" + testToken + "&key=" + testKey,
|
||||
"empty segment": "https://chat.googleapis.com/v1/spaces/" + testSpace + "/messages?key=" + testKey + "&&token=" + testToken,
|
||||
"leading ampersand": "https://chat.googleapis.com/v1/spaces/" + testSpace + "/messages?&" + q[1:],
|
||||
"trailing ampersand": "https://chat.googleapis.com/v1/spaces/" + testSpace + "/messages" + q + "&",
|
||||
"second question": "https://chat.googleapis.com/v1/spaces/" + testSpace + "/messages" + q + "?x",
|
||||
"multi-byte token": "https://chat.googleapis.com/v1/spaces/" + testSpace + "/messages?key=" + testKey + "&token=" + strings.Repeat("%F0%9F%98%80", 4),
|
||||
"multi-byte key": "https://chat.googleapis.com/v1/spaces/" + testSpace + "/messages?key=" + strings.Repeat("%F0%9F%98%80", 4) + "&token=" + testToken,
|
||||
"plus in token": "https://chat.googleapis.com/v1/spaces/" + testSpace + "/messages?key=" + testKey + "&token=" + testToken + "+",
|
||||
"repeated key": "https://chat.googleapis.com/v1/spaces/" + testSpace + "/messages" + q + "&key=" + testKey,
|
||||
"extra parameter": "https://chat.googleapis.com/v1/spaces/" + testSpace + "/messages" + q + "&threadKey=x",
|
||||
"bad escape in query": "https://chat.googleapis.com/v1/spaces/" + testSpace + "/messages" + q + "%zz",
|
||||
"fragment": testHook + "#frag",
|
||||
"space": testHook + " ",
|
||||
"newline": testHook + "\n",
|
||||
"too long": testHook + strings.Repeat("A", MaxWebhookURLLength),
|
||||
"not a URL": "chat webhook please",
|
||||
} {
|
||||
_, err := ParseWebhook(raw)
|
||||
if err == nil {
|
||||
t.Errorf("%s: ParseWebhook(%q) accepted", name, raw)
|
||||
continue
|
||||
}
|
||||
for _, secret := range []string{testKey, testToken, testSpace} {
|
||||
if strings.Contains(err.Error(), secret) {
|
||||
t.Errorf("%s: error %q quotes the URL", name, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestWebhookMasked(t *testing.T) {
|
||||
t.Parallel()
|
||||
hook, err := ParseWebhook(testHook)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// The tail is of the token as it decodes: "%3D" is "=".
|
||||
want := "https://chat.googleapis.com/v1/spaces/" + testSpace + "/messages?key=…&token=…ZxQ="
|
||||
if got := hook.Masked(); got != want {
|
||||
t.Errorf("Masked() = %q, want %q", got, want)
|
||||
}
|
||||
if m := hook.Masked(); strings.Contains(m, testKey) || strings.Contains(m, testToken) || strings.Contains(m, testToken[:8]) {
|
||||
t.Errorf("Masked() = %q holds the key or token", m)
|
||||
}
|
||||
}
|
||||
@@ -23,89 +23,6 @@ func (q *Queries) DeletePushSubscription(ctx context.Context, endpoint string) (
|
||||
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
|
||||
|
||||
@@ -68,8 +68,6 @@ type Querier interface {
|
||||
DeleteAIRunsBefore(ctx context.Context, cutoff string) (int64, 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
|
||||
DeleteRecipeSteps(ctx context.Context, recipeID string) error
|
||||
DeleteShoppingGroup(ctx context.Context, id string) error
|
||||
@@ -190,9 +188,6 @@ type Querier interface {
|
||||
// The planned dinners dated from from_date up to but not including
|
||||
// to_date, oldest first. Days with no dinner are left out.
|
||||
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).
|
||||
// The most recent names the household turned down, newest first, as
|
||||
// ListPassedNames has them.
|
||||
@@ -260,14 +255,6 @@ type Querier interface {
|
||||
// event id while its dinner stays the same; a new dinner clears it, since
|
||||
// Calendar no longer shows the day as planned.
|
||||
PutPlanDay(ctx context.Context, arg PutPlanDayParams) error
|
||||
// Records the outcome of a test message sent to the webhook sealed in
|
||||
// webhook_url_ct; affects no row when the webhook has been replaced or
|
||||
// removed since the test began.
|
||||
RecordChatTest(ctx context.Context, arg RecordChatTestParams) (int64, 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
|
||||
// revision.
|
||||
RedraftWeekPlan(ctx context.Context, arg RedraftWeekPlanParams) error
|
||||
|
||||
@@ -161,35 +161,6 @@ func (q *Queries) GetRecipeSearchSettings(ctx context.Context) (RecipeSearchSett
|
||||
return i, err
|
||||
}
|
||||
|
||||
const recordChatTest = `-- name: RecordChatTest :execrows
|
||||
UPDATE chat_settings
|
||||
SET last_test_at = ?, last_test_status = ?, updated_at = ?
|
||||
WHERE id = 1 AND webhook_url_ct = ?
|
||||
`
|
||||
|
||||
type RecordChatTestParams struct {
|
||||
LastTestAt sql.NullString
|
||||
LastTestStatus sql.NullString
|
||||
UpdatedAt string
|
||||
WebhookUrlCt []byte
|
||||
}
|
||||
|
||||
// Records the outcome of a test message sent to the webhook sealed in
|
||||
// webhook_url_ct; affects no row when the webhook has been replaced or
|
||||
// removed since the test began.
|
||||
func (q *Queries) RecordChatTest(ctx context.Context, arg RecordChatTestParams) (int64, error) {
|
||||
result, err := q.db.ExecContext(ctx, recordChatTest,
|
||||
arg.LastTestAt,
|
||||
arg.LastTestStatus,
|
||||
arg.UpdatedAt,
|
||||
arg.WebhookUrlCt,
|
||||
)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return result.RowsAffected()
|
||||
}
|
||||
|
||||
const setIntegrationsGoogleCalendar = `-- name: SetIntegrationsGoogleCalendar :execrows
|
||||
UPDATE integrations_google
|
||||
SET calendar_id = ?, calendar_name = ?, updated_at = ?
|
||||
|
||||
@@ -12,8 +12,7 @@
|
||||
// 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, or with an error wrapping ErrUndeliverable, marks it
|
||||
// failed, with the error in last_error.
|
||||
// after that point marks it failed, with the error in last_error.
|
||||
package notify
|
||||
|
||||
import (
|
||||
|
||||
@@ -422,31 +422,6 @@ func TestRetryThenSent(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestUndeliverableFailsAtOnce(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
svc, st, _ := newTestService(t)
|
||||
setPref(t, st, PlanReady, false, true)
|
||||
sender := &fakeSender{fail: fmt.Errorf("%w: chat refused the message (status 400)", ErrUndeliverable)}
|
||||
if err := svc.Register(ChannelChat, 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)
|
||||
}
|
||||
wait, err := svc.deliverDue(ctx)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
r := onlyRow(t, st, key, ChannelChat)
|
||||
if r.Status != "failed" || r.Attempts != 1 || r.LastError.String != sender.fail.Error() {
|
||||
t.Fatalf("row = %+v, want failed after one attempt with the sender's error", r)
|
||||
}
|
||||
if wait != -1 || len(sender.messages()) != 1 {
|
||||
t.Fatalf("wait = %v after %d deliveries, want nothing pending after one", wait, len(sender.messages()))
|
||||
}
|
||||
}
|
||||
|
||||
func TestGiveUpAfter24Hours(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
svc, st, c := newTestService(t)
|
||||
|
||||
@@ -48,14 +48,8 @@ type Message struct {
|
||||
Attempt int64
|
||||
}
|
||||
|
||||
// ErrUndeliverable is wrapped by a Sender's error when retrying cannot
|
||||
// help, such as a channel refusing the message outright: the worker marks
|
||||
// the row failed at once instead of retrying it.
|
||||
var ErrUndeliverable = errors.New("notify: undeliverable")
|
||||
|
||||
// Sender delivers notifications on one channel. Send returns nil once the
|
||||
// channel has accepted m, and an error otherwise; the worker retries it,
|
||||
// unless the error wraps ErrUndeliverable.
|
||||
// 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).
|
||||
@@ -241,7 +235,7 @@ func (s *Service) deliver(ctx context.Context, row db.NotificationOutbox) {
|
||||
return
|
||||
}
|
||||
|
||||
if errors.Is(err, ErrUndeliverable) || !now.Before(deadline) {
|
||||
if !now.Before(deadline) {
|
||||
logger.ErrorContext(ctx, "notification failed; giving up", "attempt", attempt, "error", err)
|
||||
s.giveUp(ctx, logger, row, 1, err.Error())
|
||||
return
|
||||
|
||||
@@ -1,195 +0,0 @@
|
||||
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()
|
||||
}
|
||||
@@ -1,520 +0,0 @@
|
||||
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)
|
||||
}
|
||||
}
|
||||
@@ -28,7 +28,7 @@ const (
|
||||
func newAIRouter(t *testing.T) http.Handler {
|
||||
t.Helper()
|
||||
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
|
||||
r, err := NewRouter(logger, "dev", nil, stubAuth{}, stubSessions{}, newTestSettings(t), newTestSearch(t), newTestAI(t), newTestChat(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), newTestPush(t), newTestRunner(t), newTestPhotos(t), fstest.MapFS{"index.html": {Data: []byte(fixtureIndex)}})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
|
||||
@@ -1,84 +0,0 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/chat"
|
||||
)
|
||||
|
||||
// chatWebhookBody is the PUT /settings/chat-webhook body. Both fields are
|
||||
// required.
|
||||
type chatWebhookBody struct {
|
||||
URL *string `json:"url"`
|
||||
SpaceLabel *string `json:"space_label"`
|
||||
}
|
||||
|
||||
// getChatWebhook answers the stored webhook's settings, which never hold
|
||||
// its URL.
|
||||
func getChatWebhook(logger *slog.Logger, svc *chat.Service) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
settings, err := svc.Get(r.Context())
|
||||
if err != nil {
|
||||
logger.ErrorContext(r.Context(), "get chat webhook", "error", err)
|
||||
writeError(w, r, logger, http.StatusInternalServerError, "internal", "internal error")
|
||||
return
|
||||
}
|
||||
writeJSON(w, r, logger, http.StatusOK, settings)
|
||||
}
|
||||
}
|
||||
|
||||
// putChatWebhook stores the webhook URL sealed, with its space label, and
|
||||
// answers the settings as GET does.
|
||||
func putChatWebhook(logger *slog.Logger, svc *chat.Service) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
body, err := decodePatch[chatWebhookBody](http.MaxBytesReader(w, r.Body, maxSettingsBody))
|
||||
if err != nil {
|
||||
writeError(w, r, logger, http.StatusBadRequest, "invalid_request", err.Error())
|
||||
return
|
||||
}
|
||||
if body.URL == nil || body.SpaceLabel == nil {
|
||||
writeError(w, r, logger, http.StatusBadRequest, "invalid_request", "url and space_label are required")
|
||||
return
|
||||
}
|
||||
settings, err := svc.Put(r.Context(), *body.URL, *body.SpaceLabel)
|
||||
var invalid *chat.ValidationError
|
||||
if errors.As(err, &invalid) {
|
||||
writeError(w, r, logger, http.StatusBadRequest, "invalid_webhook", invalid.Error())
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
logger.ErrorContext(r.Context(), "save chat webhook", "error", err)
|
||||
writeError(w, r, logger, http.StatusInternalServerError, "internal", "internal error")
|
||||
return
|
||||
}
|
||||
writeJSON(w, r, logger, http.StatusOK, settings)
|
||||
}
|
||||
}
|
||||
|
||||
// postChatWebhookTest sends chat.TestText to the stored webhook and answers
|
||||
// 200 with the status Google Chat answered, whatever it was, and the
|
||||
// recorded outcome. No webhook stored, or one replaced during the test, is
|
||||
// 409; no answer from Google Chat is 502.
|
||||
func postChatWebhookTest(logger *slog.Logger, svc *chat.Service) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
result, err := svc.Test(r.Context())
|
||||
switch {
|
||||
case errors.Is(err, chat.ErrNotConfigured):
|
||||
writeError(w, r, logger, http.StatusConflict, "not_configured", "no Google Chat webhook is configured")
|
||||
case errors.Is(err, chat.ErrWebhookChanged):
|
||||
writeError(w, r, logger, http.StatusConflict, "webhook_changed", "the Google Chat webhook changed during the test; test it again")
|
||||
case err != nil && r.Context().Err() != nil:
|
||||
logger.WarnContext(r.Context(), "chat webhook test interrupted", "error", err)
|
||||
case errors.Is(err, chat.ErrUnreachable):
|
||||
logger.WarnContext(r.Context(), "chat webhook test failed", "error", err)
|
||||
writeError(w, r, logger, http.StatusBadGateway, "chat_unreachable", "Google Chat did not answer the test message")
|
||||
case err != nil:
|
||||
logger.ErrorContext(r.Context(), "chat webhook test", "error", err)
|
||||
writeError(w, r, logger, http.StatusInternalServerError, "internal", "internal error")
|
||||
default:
|
||||
writeJSON(w, r, logger, http.StatusOK, result)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,239 +0,0 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"errors"
|
||||
"io"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/url"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
"testing/fstest"
|
||||
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/chat"
|
||||
)
|
||||
|
||||
const (
|
||||
chatWebhookPath = "/api/v1/settings/chat-webhook"
|
||||
chatWebhookTestPath = "/api/v1/settings/chat-webhook/test"
|
||||
testChatKey = "AIzaSyDdI0hCZtE6vySjMm-WEfRq3CPzqKqqsHI"
|
||||
testChatToken = "q0Lz3kV9Yw8pXcRtN2bHsJdFgA7uEmKiOlP1nZxQ%3D"
|
||||
testChatHook = "https://chat.googleapis.com/v1/spaces/AAQA1b2C3d4/messages?key=" + testChatKey + "&token=" + testChatToken
|
||||
)
|
||||
|
||||
// refuseTransport fails every request, as a Chat endpoint that is down.
|
||||
type refuseTransport struct{}
|
||||
|
||||
func (refuseTransport) RoundTrip(*http.Request) (*http.Response, error) {
|
||||
return nil, errors.New("connection refused")
|
||||
}
|
||||
|
||||
// chatTransport sends every request to the server at target, as if it
|
||||
// were chat.googleapis.com.
|
||||
type chatTransport struct {
|
||||
target *url.URL
|
||||
}
|
||||
|
||||
func (c chatTransport) RoundTrip(req *http.Request) (*http.Response, error) {
|
||||
out := req.Clone(req.Context())
|
||||
out.URL.Scheme, out.URL.Host = c.target.Scheme, c.target.Host
|
||||
return http.DefaultTransport.RoundTrip(out)
|
||||
}
|
||||
|
||||
// newTestChat returns a Google Chat service over a migrated temp database
|
||||
// whose requests all fail.
|
||||
func newTestChat(t *testing.T) *chat.Service {
|
||||
t.Helper()
|
||||
return newTestChatVia(t, refuseTransport{})
|
||||
}
|
||||
|
||||
func newTestChatVia(t *testing.T, transport http.RoundTripper) *chat.Service {
|
||||
t.Helper()
|
||||
pub, err := url.Parse("https://mealquest.example")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
svc, err := chat.NewService(newTestStore(t), newTestBox(t), pub, transport)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return svc
|
||||
}
|
||||
|
||||
// lockedBuffer is a bytes.Buffer safe for a logger.
|
||||
type lockedBuffer struct {
|
||||
mu sync.Mutex
|
||||
buf bytes.Buffer
|
||||
}
|
||||
|
||||
func (b *lockedBuffer) Write(p []byte) (int, error) {
|
||||
b.mu.Lock()
|
||||
defer b.mu.Unlock()
|
||||
return b.buf.Write(p)
|
||||
}
|
||||
|
||||
func (b *lockedBuffer) String() string {
|
||||
b.mu.Lock()
|
||||
defer b.mu.Unlock()
|
||||
return b.buf.String()
|
||||
}
|
||||
|
||||
// newChatRouter returns a router serving svc, logging at debug level to
|
||||
// logs.
|
||||
func newChatRouter(t *testing.T, svc *chat.Service, logs io.Writer) http.Handler {
|
||||
t.Helper()
|
||||
logger := slog.New(slog.NewTextHandler(logs, &slog.HandlerOptions{Level: slog.LevelDebug}))
|
||||
r, err := NewRouter(logger, "dev", nil, stubAuth{}, stubSessions{}, newTestSettings(t), newTestSearch(t), newTestAI(t), svc, newTestDinners(t), newTestSuggest(t), newTestPlan(t), newTestShopping(t), newTestGoogle(t), newTestPush(t), newTestRunner(t),
|
||||
newTestPhotos(t), fstest.MapFS{"index.html": {Data: []byte(fixtureIndex)}})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return r
|
||||
}
|
||||
|
||||
func TestChatWebhookAPI(t *testing.T) {
|
||||
t.Parallel()
|
||||
var (
|
||||
mu sync.Mutex
|
||||
status = http.StatusOK
|
||||
received []string
|
||||
)
|
||||
chatSrv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
body, _ := io.ReadAll(r.Body)
|
||||
mu.Lock()
|
||||
defer mu.Unlock()
|
||||
received = append(received, string(body))
|
||||
w.WriteHeader(status)
|
||||
}))
|
||||
t.Cleanup(chatSrv.Close)
|
||||
target, _ := url.Parse(chatSrv.URL)
|
||||
setStatus := func(s int) {
|
||||
mu.Lock()
|
||||
defer mu.Unlock()
|
||||
status = s
|
||||
}
|
||||
|
||||
var logs lockedBuffer
|
||||
router := newChatRouter(t, newTestChatVia(t, chatTransport{target}), &logs)
|
||||
var bodies []string
|
||||
call := func(method, target, body string) (*httptest.ResponseRecorder, map[string]any) {
|
||||
t.Helper()
|
||||
var r io.Reader
|
||||
if body != "" {
|
||||
r = strings.NewReader(body)
|
||||
}
|
||||
rec, decoded := serve(t, router, signedIn(method, target, r))
|
||||
bodies = append(bodies, rec.Body.String())
|
||||
return rec, decoded
|
||||
}
|
||||
|
||||
rec, body := call(http.MethodGet, chatWebhookPath, "")
|
||||
if rec.Code != http.StatusOK || body["url_masked"] != nil || body["space_label"] != nil || body["last_test_at"] != nil || body["last_test_status"] != nil || len(body) != 4 {
|
||||
t.Errorf("GET unconfigured = %d %v", rec.Code, body)
|
||||
}
|
||||
rec, body = call(http.MethodPost, chatWebhookTestPath, "")
|
||||
if rec.Code != http.StatusConflict || body["error"].(map[string]any)["code"] != "not_configured" {
|
||||
t.Errorf("POST test unconfigured = %d %v", rec.Code, body)
|
||||
}
|
||||
|
||||
q := "?key=" + testChatKey + "&token=" + testChatToken
|
||||
for _, bad := range []struct{ body, code string }{
|
||||
{`{"url":"https://chat.googleapis.com.evil.example/v1/spaces/X/messages` + q + `","space_label":"Family"}`, "invalid_webhook"},
|
||||
{`{"url":"http://chat.googleapis.com/v1/spaces/X/messages` + q + `","space_label":"Family"}`, "invalid_webhook"},
|
||||
{`{"url":"https://chat.googleapis.com/v1/spaces/X/messages?key=` + testChatKey + `","space_label":"Family"}`, "invalid_webhook"},
|
||||
{`{"url":"https://chat.googleapis.com/v1/spaces/X/threads` + q + `","space_label":"Family"}`, "invalid_webhook"},
|
||||
{`{"url":"` + testChatHook + `","space_label":""}`, "invalid_webhook"},
|
||||
{`{"url":"` + testChatHook + `"}`, "invalid_request"},
|
||||
{`{"space_label":"Family"}`, "invalid_request"},
|
||||
{`{"url":"` + testChatHook + `","space_label":null}`, "invalid_request"},
|
||||
{`{"url":"` + testChatHook + `","space_label":"Family","extra":1}`, "invalid_request"},
|
||||
{`not json`, "invalid_request"},
|
||||
} {
|
||||
rec, body := call(http.MethodPut, chatWebhookPath, bad.body)
|
||||
if rec.Code != http.StatusBadRequest || body["error"].(map[string]any)["code"] != bad.code {
|
||||
t.Errorf("PUT %s = %d %v, want 400 %s", bad.body, rec.Code, body, bad.code)
|
||||
}
|
||||
}
|
||||
|
||||
rec, body = call(http.MethodPut, chatWebhookPath, `{"url":"`+testChatHook+`","space_label":"Family dinner"}`)
|
||||
wantMasked := "https://chat.googleapis.com/v1/spaces/AAQA1b2C3d4/messages?key=…&token=…ZxQ="
|
||||
if rec.Code != http.StatusOK || body["space_label"] != "Family dinner" || body["url_masked"] != wantMasked ||
|
||||
body["last_test_at"] != nil || body["last_test_status"] != nil || len(body) != 4 {
|
||||
t.Errorf("PUT = %d %v", rec.Code, body)
|
||||
}
|
||||
if _, ok := body["url"]; ok {
|
||||
t.Errorf("PUT response has a url field: %v", body)
|
||||
}
|
||||
rec, body = call(http.MethodGet, chatWebhookPath, "")
|
||||
if rec.Code != http.StatusOK || body["space_label"] != "Family dinner" || body["url_masked"] != wantMasked {
|
||||
t.Errorf("GET = %d %v", rec.Code, body)
|
||||
}
|
||||
|
||||
rec, body = call(http.MethodPost, chatWebhookTestPath, "")
|
||||
if rec.Code != http.StatusOK || body["http_status"] != float64(200) || body["last_test_status"] != "ok" || body["last_test_at"] == nil {
|
||||
t.Errorf("POST test = %d %v, want 200 ok", rec.Code, body)
|
||||
}
|
||||
mu.Lock()
|
||||
if len(received) != 1 || !strings.Contains(received[0], `"text":"MealQuest · test message"`) {
|
||||
t.Errorf("Chat received %q, want the test message", received)
|
||||
}
|
||||
mu.Unlock()
|
||||
|
||||
setStatus(http.StatusForbidden)
|
||||
rec, body = call(http.MethodPost, chatWebhookTestPath, "")
|
||||
if rec.Code != http.StatusOK || body["http_status"] != float64(403) || body["last_test_status"] != "failed" {
|
||||
t.Errorf("POST test answered 403 = %d %v, want 403 failed", rec.Code, body)
|
||||
}
|
||||
rec, body = call(http.MethodGet, chatWebhookPath, "")
|
||||
if body["last_test_status"] != "failed" || body["last_test_at"] == nil {
|
||||
t.Errorf("GET after a failed test = %d %v", rec.Code, body)
|
||||
}
|
||||
|
||||
chatSrv.Close()
|
||||
rec, body = call(http.MethodPost, chatWebhookTestPath, "")
|
||||
if rec.Code != http.StatusBadGateway || body["error"].(map[string]any)["code"] != "chat_unreachable" {
|
||||
t.Errorf("POST test against a stopped endpoint = %d %v, want 502 chat_unreachable", rec.Code, body)
|
||||
}
|
||||
|
||||
tokenDecoded, _ := url.QueryUnescape(testChatToken)
|
||||
for what, texts := range map[string][]string{"response": bodies, "log": {logs.String()}} {
|
||||
for _, text := range texts {
|
||||
for _, secret := range []string{testChatHook, testChatKey, testChatToken, tokenDecoded, testChatKey[:12], testChatToken[:12]} {
|
||||
if strings.Contains(text, secret) {
|
||||
t.Errorf("%s holds the webhook URL or a credential: %s", what, text)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if !strings.Contains(logs.String(), "chat webhook test failed") {
|
||||
t.Errorf("log %q does not record the failed test, so the scan proves nothing", logs.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestChatWebhookNeedsCSRF(t *testing.T) {
|
||||
t.Parallel()
|
||||
router := newChatRouter(t, newTestChat(t), io.Discard)
|
||||
for _, tc := range []struct{ method, target string }{
|
||||
{http.MethodPut, chatWebhookPath},
|
||||
{http.MethodPost, chatWebhookTestPath},
|
||||
} {
|
||||
req := httptest.NewRequest(tc.method, tc.target, strings.NewReader(`{"url":"`+testChatHook+`","space_label":"Family"}`))
|
||||
req.AddCookie(validSessionCookie)
|
||||
rec := httptest.NewRecorder()
|
||||
router.ServeHTTP(rec, req)
|
||||
if rec.Code != http.StatusForbidden {
|
||||
t.Errorf("%s %s without CSRF token = %d, want 403", tc.method, tc.target, rec.Code)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewRouterRequiresChat(t *testing.T) {
|
||||
t.Parallel()
|
||||
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
|
||||
if _, err := NewRouter(logger, "dev", nil, stubAuth{}, stubSessions{}, newTestSettings(t), newTestSearch(t), newTestAI(t), nil, 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 Google Chat service")
|
||||
}
|
||||
}
|
||||
@@ -91,7 +91,7 @@ func newDinnersEnv(t *testing.T) *dinnersEnv {
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
e.h, err = NewRouter(logger, "dev", nil, stubAuth{}, stubSessions{}, newTestSettings(t), newTestSearch(t), newTestAI(t), newTestChat(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), newTestPush(t), runner, newTestPhotos(t), fstest.MapFS{"index.html": {Data: []byte(fixtureIndex)}})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
|
||||
@@ -68,7 +68,7 @@ func newGoogleEnv(t *testing.T) *googleEnv {
|
||||
t.Fatal(err)
|
||||
}
|
||||
logger := slog.New(slog.NewJSONHandler(e.logs, &slog.HandlerOptions{Level: slog.LevelDebug}))
|
||||
e.h, err = NewRouter(logger, "dev", nil, stubAuth{}, stubSessions{}, newTestSettings(t), newTestSearch(t), newTestAI(t), newTestChat(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, newTestPush(t), newTestRunner(t), newTestPhotos(t),
|
||||
fstest.MapFS{"index.html": {Data: []byte(fixtureIndex)}})
|
||||
if err != nil {
|
||||
|
||||
@@ -83,7 +83,7 @@ func newLoginRouter(t *testing.T, issuer string) http.Handler {
|
||||
t.Fatal(err)
|
||||
}
|
||||
r, err := NewRouter(logger, "dev", trusted, flow, sessions, newTestSettings(t),
|
||||
newTestSearch(t), newTestAI(t), newTestChat(t), newTestDinners(t), newTestSuggest(t), newTestPlan(t), newTestShopping(t), newTestGoogle(t), newTestPush(t), newTestRunner(t), newTestPhotos(t), fstest.MapFS{"index.html": {Data: []byte(fixtureIndex)}})
|
||||
newTestSearch(t), newTestAI(t), newTestDinners(t), newTestSuggest(t), newTestPlan(t), newTestShopping(t), newTestGoogle(t), newTestPush(t), newTestRunner(t), newTestPhotos(t), fstest.MapFS{"index.html": {Data: []byte(fixtureIndex)}})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
@@ -51,7 +51,7 @@ func newMediaRouter(t *testing.T) (r http.Handler, id, orphan string) {
|
||||
t.Fatal(err)
|
||||
}
|
||||
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
|
||||
r, err = NewRouter(logger, "dev", nil, stubAuth{}, stubSessions{}, newTestSettings(t), newTestSearch(t), newTestAI(t), newTestChat(t), newTestDinners(t), newTestSuggest(t), newTestPlan(t), newTestShopping(t), newTestGoogle(t), newTestPush(t), newTestRunner(t),
|
||||
r, err = NewRouter(logger, "dev", nil, stubAuth{}, stubSessions{}, newTestSettings(t), newTestSearch(t), newTestAI(t), newTestDinners(t), newTestSuggest(t), newTestPlan(t), newTestShopping(t), newTestGoogle(t), newTestPush(t), newTestRunner(t),
|
||||
photos, fstest.MapFS{"index.html": {Data: []byte(fixtureIndex)}})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
|
||||
@@ -45,7 +45,7 @@ func newOriginRouter(t *testing.T, logs io.Writer) http.Handler {
|
||||
}
|
||||
logger := slog.New(forwarded.LogHandler(slog.NewJSONHandler(logs, nil)))
|
||||
r, err := NewRouter(logger, "dev", trusted, probeAuth{}, stubSessions{}, newTestSettings(t),
|
||||
newTestSearch(t), newTestAI(t), newTestChat(t), newTestDinners(t), newTestSuggest(t), newTestPlan(t), newTestShopping(t), newTestGoogle(t), newTestPush(t), newTestRunner(t), newTestPhotos(t), fstest.MapFS{"index.html": {Data: []byte(fixtureIndex)}})
|
||||
newTestSearch(t), newTestAI(t), newTestDinners(t), newTestSuggest(t), newTestPlan(t), newTestShopping(t), newTestGoogle(t), newTestPush(t), newTestRunner(t), newTestPhotos(t), fstest.MapFS{"index.html": {Data: []byte(fixtureIndex)}})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
@@ -93,7 +93,7 @@ func newPlansRouterTitled(t *testing.T, title shopping.Titler) plansEnv {
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
h, err := NewRouter(logger, "dev", nil, stubAuth{}, stubSessions{}, newTestSettings(t), newTestSearch(t), newTestAI(t), newTestChat(t),
|
||||
h, err := NewRouter(logger, "dev", nil, stubAuth{}, stubSessions{}, newTestSettings(t), newTestSearch(t), newTestAI(t),
|
||||
newTestDinners(t), newTestSuggest(t), svc, shop, googleSvc, newTestPush(t), runner, newTestPhotos(t), fstest.MapFS{"index.html": {Data: []byte(fixtureIndex)}})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
|
||||
@@ -52,7 +52,7 @@ func newTestPushWith(t *testing.T) (*push.Service, *store.Store, vapid.KeyPair)
|
||||
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), newTestChat(t), newTestDinners(t), newTestSuggest(t), newTestPlan(t), newTestShopping(t), newTestGoogle(t), svc, 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), svc, newTestRunner(t),
|
||||
newTestPhotos(t), fstest.MapFS{"index.html": {Data: []byte(fixtureIndex)}})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
@@ -100,7 +100,7 @@ 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), newTestChat(t), newTestDinners(t), newTestSuggest(t), newTestPlan(t), newTestShopping(t), newTestGoogle(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), newTestGoogle(t), nil, newTestRunner(t), newTestPhotos(t), fstest.MapFS{"index.html": {}}); err == nil {
|
||||
t.Fatal("NewRouter accepted a nil push service")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -22,7 +22,7 @@ const (
|
||||
func newRecipeSearchRouter(t *testing.T, search *recipes.SearchSettingsService) http.Handler {
|
||||
t.Helper()
|
||||
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
|
||||
r, err := NewRouter(logger, "dev", nil, stubAuth{}, stubSessions{}, newTestSettings(t), search, newTestAI(t), newTestChat(t), newTestDinners(t), newTestSuggest(t), newTestPlan(t), newTestShopping(t), newTestGoogle(t), newTestPush(t), newTestRunner(t),
|
||||
r, err := NewRouter(logger, "dev", nil, stubAuth{}, stubSessions{}, newTestSettings(t), search, newTestAI(t), newTestDinners(t), newTestSuggest(t), newTestPlan(t), newTestShopping(t), newTestGoogle(t), newTestPush(t), newTestRunner(t),
|
||||
newTestPhotos(t), fstest.MapFS{"index.html": {Data: []byte(fixtureIndex)}})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
|
||||
@@ -11,7 +11,6 @@ import (
|
||||
"github.com/go-chi/chi/v5"
|
||||
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/ai/aisettings"
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/chat"
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/dinners"
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/forwarded"
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/frontend"
|
||||
@@ -41,7 +40,6 @@ type RouteMounter interface {
|
||||
// /media; settings serves /api/v1/settings/household; search serves
|
||||
// /api/v1/settings/recipe-search and its /test; ai serves
|
||||
// /api/v1/settings/ai, its /api-key, /verify, /models and /reset-prompt;
|
||||
// chatSvc serves GET and PUT /api/v1/settings/chat-webhook and its /test;
|
||||
// dinnerSvc serves GET and POST /api/v1/dinners, PATCH and DELETE
|
||||
// /api/v1/dinners/{id}, POST /api/v1/dinners/{id}/favorite, GET
|
||||
// /api/v1/dinners/{id}/recipe-candidates and POST /api/v1/dinners/{id}/ground;
|
||||
@@ -65,7 +63,7 @@ type RouteMounter interface {
|
||||
// GET /media/photos/{id}-{size}.jpg; assets is the web build
|
||||
// served for every other GET. /health, /auth and the web build are public.
|
||||
// Requests to /api/v1 other than GET also need the session's CSRF token in CSRFHeader.
|
||||
func NewRouter(logger *slog.Logger, version string, trusted forwarded.Trusted, auth RouteMounter, sessions SessionLookup, settings *household.SettingsService, search *recipes.SearchSettingsService, ai *aisettings.Service, chatSvc *chat.Service, dinnerSvc *dinners.Service, suggestSvc *suggest.Service, planSvc *plan.Service, shopSvc *shopping.Service, googleSvc *gcal.Service, pushSvc *push.Service, runner *jobs.Runner, photos PhotoFiles, assets fs.FS) (http.Handler, error) {
|
||||
func NewRouter(logger *slog.Logger, version string, trusted forwarded.Trusted, auth RouteMounter, sessions SessionLookup, settings *household.SettingsService, search *recipes.SearchSettingsService, ai *aisettings.Service, dinnerSvc *dinners.Service, suggestSvc *suggest.Service, planSvc *plan.Service, shopSvc *shopping.Service, googleSvc *gcal.Service, pushSvc *push.Service, runner *jobs.Runner, photos PhotoFiles, assets fs.FS) (http.Handler, error) {
|
||||
if settings == nil {
|
||||
return nil, errors.New("server: nil household settings service")
|
||||
}
|
||||
@@ -75,9 +73,6 @@ func NewRouter(logger *slog.Logger, version string, trusted forwarded.Trusted, a
|
||||
if ai == nil {
|
||||
return nil, errors.New("server: nil AI settings service")
|
||||
}
|
||||
if chatSvc == nil {
|
||||
return nil, errors.New("server: nil Google Chat service")
|
||||
}
|
||||
if dinnerSvc == nil {
|
||||
return nil, errors.New("server: nil dinners service")
|
||||
}
|
||||
@@ -139,9 +134,6 @@ func NewRouter(logger *slog.Logger, version string, trusted forwarded.Trusted, a
|
||||
apiV1.Post("/settings/ai/verify", postAIVerify(logger, ai))
|
||||
apiV1.Get("/settings/ai/models", getAIModels(logger, ai))
|
||||
apiV1.Post("/settings/ai/reset-prompt", postAIResetPrompt(logger, ai))
|
||||
apiV1.Get("/settings/chat-webhook", getChatWebhook(logger, chatSvc))
|
||||
apiV1.Put("/settings/chat-webhook", putChatWebhook(logger, chatSvc))
|
||||
apiV1.Post("/settings/chat-webhook/test", postChatWebhookTest(logger, chatSvc))
|
||||
apiV1.Get("/dinners", getDinners(logger, dinnerSvc))
|
||||
apiV1.Post("/dinners", postDinner(logger, dinnerSvc))
|
||||
apiV1.Patch("/dinners/{id}", patchDinner(logger, dinnerSvc))
|
||||
|
||||
@@ -54,7 +54,7 @@ func newTestRouterWith(t *testing.T, version string, settings *household.Setting
|
||||
"index.html": {Data: []byte(fixtureIndex)},
|
||||
"assets/index-AbC123.js": {Data: []byte(fixtureAsset)},
|
||||
}
|
||||
r, err := NewRouter(logger, version, nil, stubAuth{}, stubSessions{}, settings, newTestSearch(t), newTestAI(t), newTestChat(t), newTestDinners(t), newTestSuggest(t), newTestPlan(t), newTestShopping(t), newTestGoogle(t), newTestPush(t), runner, newTestPhotos(t), assets)
|
||||
r, err := NewRouter(logger, version, nil, stubAuth{}, stubSessions{}, settings, newTestSearch(t), newTestAI(t), newTestDinners(t), newTestSuggest(t), newTestPlan(t), newTestShopping(t), newTestGoogle(t), newTestPush(t), runner, newTestPhotos(t), assets)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@@ -287,7 +287,7 @@ func TestNewRouterRequiresAISettings(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
|
||||
if _, err := NewRouter(logger, "dev", nil, stubAuth{}, stubSessions{}, newTestSettings(t), newTestSearch(t), nil, newTestChat(t), newTestDinners(t), newTestSuggest(t), newTestPlan(t), newTestShopping(t), newTestGoogle(t), newTestPush(t), newTestRunner(t), newTestPhotos(t), fstest.MapFS{"index.html": {}}); err == nil {
|
||||
if _, err := NewRouter(logger, "dev", nil, stubAuth{}, stubSessions{}, newTestSettings(t), newTestSearch(t), nil, newTestDinners(t), newTestSuggest(t), newTestPlan(t), newTestShopping(t), newTestGoogle(t), newTestPush(t), newTestRunner(t), newTestPhotos(t), fstest.MapFS{"index.html": {}}); err == nil {
|
||||
t.Fatal("NewRouter accepted a nil AI settings service")
|
||||
}
|
||||
}
|
||||
@@ -296,7 +296,7 @@ func TestNewRouterRequiresSearchSettings(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
|
||||
if _, err := NewRouter(logger, "dev", nil, stubAuth{}, stubSessions{}, newTestSettings(t), nil, newTestAI(t), newTestChat(t), newTestDinners(t), newTestSuggest(t), newTestPlan(t), newTestShopping(t), newTestGoogle(t), newTestPush(t), newTestRunner(t), newTestPhotos(t), fstest.MapFS{"index.html": {}}); err == nil {
|
||||
if _, err := NewRouter(logger, "dev", nil, stubAuth{}, stubSessions{}, newTestSettings(t), nil, newTestAI(t), newTestDinners(t), newTestSuggest(t), newTestPlan(t), newTestShopping(t), newTestGoogle(t), newTestPush(t), newTestRunner(t), newTestPhotos(t), fstest.MapFS{"index.html": {}}); err == nil {
|
||||
t.Fatal("NewRouter accepted a nil recipe search settings service")
|
||||
}
|
||||
}
|
||||
@@ -305,7 +305,7 @@ func TestNewRouterRequiresPhotoFiles(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
|
||||
if _, err := NewRouter(logger, "dev", nil, stubAuth{}, stubSessions{}, newTestSettings(t), newTestSearch(t), newTestAI(t), newTestChat(t), newTestDinners(t), newTestSuggest(t), newTestPlan(t), newTestShopping(t), newTestGoogle(t), newTestPush(t), newTestRunner(t), nil, fstest.MapFS{"index.html": {}}); err == nil {
|
||||
if _, err := NewRouter(logger, "dev", nil, stubAuth{}, stubSessions{}, newTestSettings(t), newTestSearch(t), newTestAI(t), newTestDinners(t), newTestSuggest(t), newTestPlan(t), newTestShopping(t), newTestGoogle(t), newTestPush(t), newTestRunner(t), nil, fstest.MapFS{"index.html": {}}); err == nil {
|
||||
t.Fatal("NewRouter accepted nil photo files")
|
||||
}
|
||||
}
|
||||
@@ -314,7 +314,7 @@ func TestNewRouterRequiresSettings(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
|
||||
if _, err := NewRouter(logger, "dev", nil, stubAuth{}, stubSessions{}, nil, newTestSearch(t), newTestAI(t), newTestChat(t), newTestDinners(t), newTestSuggest(t), newTestPlan(t), newTestShopping(t), newTestGoogle(t), newTestPush(t), newTestRunner(t), newTestPhotos(t), fstest.MapFS{"index.html": {}}); err == nil {
|
||||
if _, err := NewRouter(logger, "dev", nil, stubAuth{}, stubSessions{}, nil, newTestSearch(t), newTestAI(t), newTestDinners(t), newTestSuggest(t), newTestPlan(t), newTestShopping(t), newTestGoogle(t), newTestPush(t), newTestRunner(t), newTestPhotos(t), fstest.MapFS{"index.html": {}}); err == nil {
|
||||
t.Fatal("NewRouter accepted a nil settings service")
|
||||
}
|
||||
}
|
||||
@@ -323,7 +323,7 @@ func TestNewRouterRequiresDinners(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
|
||||
if _, err := NewRouter(logger, "dev", nil, stubAuth{}, stubSessions{}, newTestSettings(t), newTestSearch(t), newTestAI(t), newTestChat(t), nil, newTestSuggest(t), newTestPlan(t), newTestShopping(t), newTestGoogle(t), newTestPush(t), newTestRunner(t), newTestPhotos(t), fstest.MapFS{"index.html": {}}); err == nil {
|
||||
if _, err := NewRouter(logger, "dev", nil, stubAuth{}, stubSessions{}, newTestSettings(t), newTestSearch(t), newTestAI(t), nil, newTestSuggest(t), newTestPlan(t), newTestShopping(t), newTestGoogle(t), newTestPush(t), newTestRunner(t), newTestPhotos(t), fstest.MapFS{"index.html": {}}); err == nil {
|
||||
t.Fatal("NewRouter accepted a nil dinners service")
|
||||
}
|
||||
}
|
||||
@@ -332,7 +332,7 @@ func TestNewRouterRequiresSuggestions(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
|
||||
if _, err := NewRouter(logger, "dev", nil, stubAuth{}, stubSessions{}, newTestSettings(t), newTestSearch(t), newTestAI(t), newTestChat(t), newTestDinners(t), nil, newTestPlan(t), newTestShopping(t), newTestGoogle(t), newTestPush(t), newTestRunner(t), newTestPhotos(t), fstest.MapFS{"index.html": {}}); err == nil {
|
||||
if _, err := NewRouter(logger, "dev", nil, stubAuth{}, stubSessions{}, newTestSettings(t), newTestSearch(t), newTestAI(t), newTestDinners(t), nil, newTestPlan(t), newTestShopping(t), newTestGoogle(t), newTestPush(t), newTestRunner(t), newTestPhotos(t), fstest.MapFS{"index.html": {}}); err == nil {
|
||||
t.Fatal("NewRouter accepted a nil suggestions service")
|
||||
}
|
||||
}
|
||||
@@ -341,7 +341,7 @@ func TestNewRouterRequiresPlan(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
|
||||
if _, err := NewRouter(logger, "dev", nil, stubAuth{}, stubSessions{}, newTestSettings(t), newTestSearch(t), newTestAI(t), newTestChat(t), newTestDinners(t), newTestSuggest(t), nil, newTestShopping(t), newTestGoogle(t), newTestPush(t), newTestRunner(t), newTestPhotos(t), fstest.MapFS{"index.html": {}}); err == nil {
|
||||
if _, err := NewRouter(logger, "dev", nil, stubAuth{}, stubSessions{}, newTestSettings(t), newTestSearch(t), newTestAI(t), newTestDinners(t), newTestSuggest(t), nil, newTestShopping(t), newTestGoogle(t), newTestPush(t), newTestRunner(t), newTestPhotos(t), fstest.MapFS{"index.html": {}}); err == nil {
|
||||
t.Fatal("NewRouter accepted a nil plan service")
|
||||
}
|
||||
}
|
||||
@@ -350,7 +350,7 @@ func TestNewRouterRequiresShopping(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
|
||||
if _, err := NewRouter(logger, "dev", nil, stubAuth{}, stubSessions{}, newTestSettings(t), newTestSearch(t), newTestAI(t), newTestChat(t), newTestDinners(t), newTestSuggest(t), newTestPlan(t), nil, newTestGoogle(t), newTestPush(t), newTestRunner(t), newTestPhotos(t), fstest.MapFS{"index.html": {}}); err == nil {
|
||||
if _, err := NewRouter(logger, "dev", nil, stubAuth{}, stubSessions{}, newTestSettings(t), newTestSearch(t), newTestAI(t), newTestDinners(t), newTestSuggest(t), newTestPlan(t), nil, newTestGoogle(t), newTestPush(t), newTestRunner(t), newTestPhotos(t), fstest.MapFS{"index.html": {}}); err == nil {
|
||||
t.Fatal("NewRouter accepted a nil shopping service")
|
||||
}
|
||||
}
|
||||
@@ -359,7 +359,7 @@ func TestNewRouterRequiresGoogle(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
|
||||
if _, err := NewRouter(logger, "dev", nil, stubAuth{}, stubSessions{}, newTestSettings(t), newTestSearch(t), newTestAI(t), newTestChat(t), newTestDinners(t), newTestSuggest(t), newTestPlan(t), newTestShopping(t), nil, newTestPush(t), newTestRunner(t), newTestPhotos(t), fstest.MapFS{"index.html": {}}); err == nil {
|
||||
if _, err := NewRouter(logger, "dev", nil, stubAuth{}, stubSessions{}, newTestSettings(t), newTestSearch(t), newTestAI(t), newTestDinners(t), newTestSuggest(t), newTestPlan(t), newTestShopping(t), nil, newTestPush(t), newTestRunner(t), newTestPhotos(t), fstest.MapFS{"index.html": {}}); err == nil {
|
||||
t.Fatal("NewRouter accepted a nil Google Calendar service")
|
||||
}
|
||||
}
|
||||
@@ -368,7 +368,7 @@ func TestNewRouterRequiresJobRunner(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
|
||||
if _, err := NewRouter(logger, "dev", nil, stubAuth{}, stubSessions{}, newTestSettings(t), newTestSearch(t), newTestAI(t), newTestChat(t), newTestDinners(t), newTestSuggest(t), newTestPlan(t), newTestShopping(t), newTestGoogle(t), newTestPush(t), nil, newTestPhotos(t), fstest.MapFS{"index.html": {}}); err == nil {
|
||||
if _, err := NewRouter(logger, "dev", nil, stubAuth{}, stubSessions{}, newTestSettings(t), newTestSearch(t), newTestAI(t), newTestDinners(t), newTestSuggest(t), newTestPlan(t), newTestShopping(t), newTestGoogle(t), newTestPush(t), nil, newTestPhotos(t), fstest.MapFS{"index.html": {}}); err == nil {
|
||||
t.Fatal("NewRouter accepted a nil job runner")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -277,7 +277,7 @@ func TestHouseholdSettingsWithRealSession(t *testing.T) {
|
||||
}
|
||||
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
|
||||
router, err := NewRouter(logger, "dev", nil, stubAuth{}, sessions, household.NewSettingsService(st),
|
||||
newTestSearch(t), newTestAI(t), newTestChat(t), newTestDinners(t), newTestSuggest(t), newTestPlan(t), newTestShopping(t), newTestGoogle(t), newTestPush(t), newTestRunner(t), newTestPhotos(t), fstest.MapFS{"index.html": {Data: []byte(fixtureIndex)}})
|
||||
newTestSearch(t), newTestAI(t), newTestDinners(t), newTestSuggest(t), newTestPlan(t), newTestShopping(t), newTestGoogle(t), newTestPush(t), newTestRunner(t), newTestPhotos(t), fstest.MapFS{"index.html": {Data: []byte(fixtureIndex)}})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
@@ -44,7 +44,7 @@ func newSuggestionsRouter(t *testing.T, names ...string) (http.Handler, *store.S
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
h, err := NewRouter(logger, "dev", nil, stubAuth{}, stubSessions{}, newTestSettings(t), newTestSearch(t), newTestAI(t), newTestChat(t),
|
||||
h, err := NewRouter(logger, "dev", nil, stubAuth{}, stubSessions{}, newTestSettings(t), newTestSearch(t), newTestAI(t),
|
||||
newTestDinners(t), svc, newTestPlan(t), newTestShopping(t), newTestGoogle(t), newTestPush(t), runner, newTestPhotos(t), fstest.MapFS{"index.html": {Data: []byte(fixtureIndex)}})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
|
||||
@@ -18,27 +18,3 @@ 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 = ?;
|
||||
|
||||
@@ -146,11 +146,3 @@ ON CONFLICT (id) DO UPDATE SET
|
||||
last_test_status = excluded.last_test_status,
|
||||
updated_at = excluded.updated_at
|
||||
RETURNING *;
|
||||
|
||||
-- name: RecordChatTest :execrows
|
||||
-- Records the outcome of a test message sent to the webhook sealed in
|
||||
-- webhook_url_ct; affects no row when the webhook has been replaced or
|
||||
-- removed since the test began.
|
||||
UPDATE chat_settings
|
||||
SET last_test_at = ?, last_test_status = ?, updated_at = ?
|
||||
WHERE id = 1 AND webhook_url_ct = ?;
|
||||
|
||||
Reference in New Issue
Block a user