home.Service aggregates the Home screen from the services that own each value, at "now" in the household's zone: the date label, tonight's dinner from the current week's plan (null with no plan), the pending Side quest count from the deck, the Supply run status of the upcoming week or else the current one, and recentUpdates.unread. unread is whether a notification was stored in notification_outbox after household_settings.updates_seen_at (or at all while it is null), read through the new notify.StoredSince over the HasNotificationSince query. Nothing writes updates_seen_at yet; the updates task adds that. Tests under America/Chicago show tonight's dinner flips at local midnight, not UTC midnight, and cover no plan, no suggestions, the Supply run choice and unread. Maestro task 9ccfc3a6-cc7b-493a-aa4b-2b455d26f4df. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
344 lines
13 KiB
Go
344 lines
13 KiB
Go
package server
|
|
|
|
import (
|
|
"crypto/ecdh"
|
|
"crypto/rand"
|
|
"encoding/base64"
|
|
"encoding/hex"
|
|
"encoding/json"
|
|
"io"
|
|
"log/slog"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"strings"
|
|
"testing"
|
|
"testing/fstest"
|
|
|
|
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/notify"
|
|
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/notify/notifytest"
|
|
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/push"
|
|
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/store"
|
|
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/vapid"
|
|
)
|
|
|
|
const (
|
|
vapidPublicKeyPath = "/api/v1/push/vapid-public-key"
|
|
pushSubscriptionsPath = "/api/v1/push/subscriptions"
|
|
testPushPath = "/api/v1/settings/notifications/test-push"
|
|
testPushEndpoint = "https://updates.push.services.mozilla.com/wpush/v2/gAAAAABk"
|
|
)
|
|
|
|
// newTestPush returns a push service over a migrated temp database with a
|
|
// new VAPID key pair.
|
|
func newTestPush(t *testing.T) *push.Service {
|
|
t.Helper()
|
|
svc, _, _ := newTestPushWith(t)
|
|
return svc
|
|
}
|
|
|
|
// newTestPushWith is newTestPush also returning its store and key pair.
|
|
func newTestPushWith(t *testing.T) (*push.Service, *store.Store, vapid.KeyPair) {
|
|
t.Helper()
|
|
pair, err := vapid.Generate()
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
st := newTestStore(t)
|
|
svc, err := push.NewService(st, pair.PublicKey, notifytest.New(t, st))
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
return svc, st, pair
|
|
}
|
|
|
|
// newPushRouter returns a router serving svc.
|
|
func newPushRouter(t *testing.T, svc *push.Service) http.Handler {
|
|
t.Helper()
|
|
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
|
|
r, err := NewRouter(logger, "dev", nil, stubAuth{}, stubSessions{}, newTestSettings(t), newTestSearch(t), newTestAI(t), newTestChat(t), newTestDinners(t), newTestSuggest(t), newTestPlan(t), newTestShopping(t), newTestGoogle(t), svc, newTestIdeas(t), newTestHome(t), newTestRunner(t),
|
|
newTestPhotos(t), fstest.MapFS{"index.html": {Data: []byte(fixtureIndex)}})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
return r
|
|
}
|
|
|
|
// subscriptionJSON returns a POST /push/subscriptions body for endpoint
|
|
// with new browser keys.
|
|
func subscriptionJSON(t *testing.T, endpoint, userAgent string) string {
|
|
t.Helper()
|
|
k, err := ecdh.P256().GenerateKey(rand.Reader)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
auth := make([]byte, 16)
|
|
rand.Read(auth)
|
|
body := map[string]any{
|
|
"endpoint": endpoint,
|
|
"keys": map[string]string{
|
|
"p256dh": base64.RawURLEncoding.EncodeToString(k.PublicKey().Bytes()),
|
|
"auth": base64.RawURLEncoding.EncodeToString(auth),
|
|
},
|
|
}
|
|
if userAgent != "" {
|
|
body["user_agent"] = userAgent
|
|
}
|
|
b, err := json.Marshal(body)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
return string(b)
|
|
}
|
|
|
|
func countPushSubscriptions(t *testing.T, st *store.Store) int {
|
|
t.Helper()
|
|
var n int
|
|
if err := st.Reader.QueryRow(`SELECT count(*) FROM push_subscriptions`).Scan(&n); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
return n
|
|
}
|
|
|
|
func TestNewRouterRequiresPush(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
|
|
if _, err := NewRouter(logger, "dev", nil, stubAuth{}, stubSessions{}, newTestSettings(t), newTestSearch(t), newTestAI(t), newTestChat(t), newTestDinners(t), newTestSuggest(t), newTestPlan(t), newTestShopping(t), newTestGoogle(t), nil, newTestIdeas(t), newTestHome(t), newTestRunner(t), newTestPhotos(t), fstest.MapFS{"index.html": {}}); err == nil {
|
|
t.Fatal("NewRouter accepted a nil push service")
|
|
}
|
|
}
|
|
|
|
func TestPushAPI(t *testing.T) {
|
|
t.Parallel()
|
|
svc, st, pair := newTestPushWith(t)
|
|
router := newPushRouter(t, svc)
|
|
var bodies []string
|
|
call := func(method, target, body string) *httptest.ResponseRecorder {
|
|
t.Helper()
|
|
var r io.Reader
|
|
if body != "" {
|
|
r = strings.NewReader(body)
|
|
}
|
|
rec := httptest.NewRecorder()
|
|
router.ServeHTTP(rec, signedIn(method, target, r))
|
|
bodies = append(bodies, rec.Body.String())
|
|
return rec
|
|
}
|
|
decode := func(rec *httptest.ResponseRecorder) map[string]any {
|
|
t.Helper()
|
|
var m map[string]any
|
|
if err := json.Unmarshal(rec.Body.Bytes(), &m); err != nil {
|
|
t.Fatalf("decode %q: %v", rec.Body.String(), err)
|
|
}
|
|
return m
|
|
}
|
|
|
|
rec := call(http.MethodGet, vapidPublicKeyPath, "")
|
|
if body := decode(rec); rec.Code != http.StatusOK || len(body) != 1 || body["public_key"] != pair.PublicKey {
|
|
t.Errorf("GET public key = %d %s, want only the public key", rec.Code, rec.Body.String())
|
|
}
|
|
|
|
// Posting the same endpoint twice keeps one subscription, updated.
|
|
rec = call(http.MethodPost, pushSubscriptionsPath, subscriptionJSON(t, testPushEndpoint, "Firefox on the kitchen tablet"))
|
|
first := decode(rec)
|
|
if rec.Code != http.StatusOK || first["endpoint"] != testPushEndpoint || first["user_agent"] != "Firefox on the kitchen tablet" || first["id"] == "" {
|
|
t.Errorf("POST subscription = %d %s", rec.Code, rec.Body.String())
|
|
}
|
|
if _, ok := first["keys"]; ok {
|
|
t.Errorf("POST response holds the keys: %s", rec.Body.String())
|
|
}
|
|
rec = call(http.MethodPost, pushSubscriptionsPath, subscriptionJSON(t, testPushEndpoint, "Firefox 140"))
|
|
again := decode(rec)
|
|
if rec.Code != http.StatusOK || again["id"] != first["id"] || again["created_at"] != first["created_at"] || again["user_agent"] != "Firefox 140" {
|
|
t.Errorf("POST same endpoint = %d %s, want the first subscription updated", rec.Code, rec.Body.String())
|
|
}
|
|
if n := countPushSubscriptions(t, st); n != 1 {
|
|
t.Errorf("subscriptions after posting one endpoint twice = %d, want 1", n)
|
|
}
|
|
rec = call(http.MethodPost, pushSubscriptionsPath, subscriptionJSON(t, testPushEndpoint+"-phone", ""))
|
|
if body := decode(rec); rec.Code != http.StatusOK || body["user_agent"] != nil {
|
|
t.Errorf("POST second endpoint = %d %s", rec.Code, rec.Body.String())
|
|
}
|
|
if n := countPushSubscriptions(t, st); n != 2 {
|
|
t.Errorf("subscriptions after a second endpoint = %d, want 2", n)
|
|
}
|
|
|
|
for _, bad := range []struct{ body, code string }{
|
|
{"", "invalid_request"},
|
|
{`{"endpoint":"` + testPushEndpoint + `","keys":{"p256dh":"x","auth":"y"},"expirationTime":null}`, "invalid_request"},
|
|
{`{"endpoint":"` + testPushEndpoint + `","keys":{"p256dh":"x","auth":"y"},"extra":1}`, "invalid_request"},
|
|
{`{"endpoint":"` + testPushEndpoint + `"}`, "invalid_subscription"},
|
|
{`{"endpoint":"` + testPushEndpoint + `","keys":{"p256dh":"x","auth":"y"}}`, "invalid_subscription"},
|
|
{subscriptionJSON(t, "http://push.example.com/x", ""), "invalid_subscription"},
|
|
{`{"endpoint":"https://push.example.com/` + strings.Repeat("x", maxPushBody) + `"}`, "invalid_request"},
|
|
} {
|
|
rec := call(http.MethodPost, pushSubscriptionsPath, bad.body)
|
|
if rec.Code != http.StatusBadRequest || !strings.Contains(rec.Body.String(), `"code":"`+bad.code+`"`) {
|
|
t.Errorf("POST %.80s = %d %s, want 400 %s", bad.body, rec.Code, rec.Body.String(), bad.code)
|
|
}
|
|
}
|
|
if n := countPushSubscriptions(t, st); n != 2 {
|
|
t.Errorf("subscriptions after invalid posts = %d, want 2", n)
|
|
}
|
|
|
|
// DELETE removes by endpoint and is 204 whether or not it was stored.
|
|
for i := range 2 {
|
|
rec = call(http.MethodDelete, pushSubscriptionsPath, `{"endpoint":"`+testPushEndpoint+`"}`)
|
|
if rec.Code != http.StatusNoContent || rec.Body.Len() != 0 {
|
|
t.Errorf("DELETE #%d = %d %s, want 204", i+1, rec.Code, rec.Body.String())
|
|
}
|
|
}
|
|
if n := countPushSubscriptions(t, st); n != 1 {
|
|
t.Errorf("subscriptions after DELETE = %d, want 1", n)
|
|
}
|
|
for _, bad := range []string{"", `{}`, `{"endpoint":"not a url"}`} {
|
|
rec := call(http.MethodDelete, pushSubscriptionsPath, bad)
|
|
if rec.Code != http.StatusBadRequest {
|
|
t.Errorf("DELETE %q = %d %s, want 400", bad, rec.Code, rec.Body.String())
|
|
}
|
|
}
|
|
if n := countPushSubscriptions(t, st); n != 1 {
|
|
t.Errorf("subscriptions after invalid deletes = %d, want 1", n)
|
|
}
|
|
|
|
// The private key never leaves the process, in any encoding.
|
|
priv, err := base64.RawURLEncoding.DecodeString(pair.PrivateKey)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
leaks := []string{
|
|
pair.PrivateKey,
|
|
pair.PrivateKey[:16],
|
|
base64.StdEncoding.EncodeToString(priv),
|
|
base64.RawStdEncoding.EncodeToString(priv)[:16],
|
|
hex.EncodeToString(priv),
|
|
}
|
|
for _, b := range bodies {
|
|
for _, leak := range leaks {
|
|
if strings.Contains(b, leak) {
|
|
t.Errorf("response holds the VAPID private key: %s", b)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestPushSubscriptionsNeedCSRF(t *testing.T) {
|
|
t.Parallel()
|
|
svc, st, _ := newTestPushWith(t)
|
|
router := newPushRouter(t, svc)
|
|
if _, err := svc.Subscribe(t.Context(), push.SubscribeRequest{Endpoint: testPushEndpoint + "-stored", P256dh: mustP256dh(t), Auth: "AAAAAAAAAAAAAAAAAAAAAA"}); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
for _, tc := range []struct {
|
|
method, body string
|
|
}{
|
|
{http.MethodPost, subscriptionJSON(t, testPushEndpoint, "")},
|
|
{http.MethodDelete, `{"endpoint":"` + testPushEndpoint + `-stored"}`},
|
|
} {
|
|
for _, header := range []*string{nil, ptr(""), ptr("wrong-token")} {
|
|
req := httptest.NewRequest(tc.method, pushSubscriptionsPath, strings.NewReader(tc.body))
|
|
req.AddCookie(validSessionCookie)
|
|
if header != nil {
|
|
req.Header.Set(CSRFHeader, *header)
|
|
}
|
|
rec := httptest.NewRecorder()
|
|
router.ServeHTTP(rec, req)
|
|
if rec.Code != http.StatusForbidden || rec.Body.String() != csrfJSON {
|
|
t.Errorf("%s without a valid CSRF token (%v) = %d %s, want 403 csrf", tc.method, header, rec.Code, rec.Body.String())
|
|
}
|
|
}
|
|
}
|
|
if n := countPushSubscriptions(t, st); n != 1 {
|
|
t.Errorf("subscriptions after refused requests = %d, want the 1 stored before", n)
|
|
}
|
|
// GET needs no token.
|
|
req := httptest.NewRequest(http.MethodGet, vapidPublicKeyPath, nil)
|
|
req.AddCookie(validSessionCookie)
|
|
rec := httptest.NewRecorder()
|
|
router.ServeHTTP(rec, req)
|
|
if rec.Code != http.StatusOK {
|
|
t.Errorf("GET public key without CSRF token = %d, want 200", rec.Code)
|
|
}
|
|
}
|
|
|
|
func TestPushNeedsSession(t *testing.T) {
|
|
t.Parallel()
|
|
router := newPushRouter(t, newTestPush(t))
|
|
for _, method := range []string{http.MethodGet, http.MethodPost, http.MethodDelete} {
|
|
target := pushSubscriptionsPath
|
|
if method == http.MethodGet {
|
|
target = vapidPublicKeyPath
|
|
}
|
|
rec := httptest.NewRecorder()
|
|
router.ServeHTTP(rec, httptest.NewRequest(method, target, nil))
|
|
if rec.Code != http.StatusUnauthorized {
|
|
t.Errorf("%s %s without a session = %d, want 401", method, target, rec.Code)
|
|
}
|
|
}
|
|
}
|
|
|
|
func mustP256dh(t *testing.T) string {
|
|
t.Helper()
|
|
k, err := ecdh.P256().GenerateKey(rand.Reader)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
return base64.RawURLEncoding.EncodeToString(k.PublicKey().Bytes())
|
|
}
|
|
|
|
func TestTestPushAPI(t *testing.T) {
|
|
t.Parallel()
|
|
svc, st, _ := newTestPushWith(t)
|
|
router := newPushRouter(t, svc)
|
|
|
|
// With push off for plan_ready and no device subscribed, push off is
|
|
// what the test says.
|
|
if _, err := st.Writer.Exec(`UPDATE notification_prefs SET push = 0 WHERE event = 'plan_ready'`); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
rec, body := serve(t, router, signedIn(http.MethodPost, testPushPath, nil))
|
|
if rec.Code != http.StatusConflict || body["error"].(map[string]any)["code"] != "push_off" {
|
|
t.Errorf("POST with push off and no subscriptions = %d %s, want 409 push_off", rec.Code, rec.Body)
|
|
}
|
|
if _, err := st.Writer.Exec(`UPDATE notification_prefs SET push = 1 WHERE event = 'plan_ready'`); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
// With no device subscribed there is nothing to test.
|
|
rec, body = serve(t, router, signedIn(http.MethodPost, testPushPath, nil))
|
|
if rec.Code != http.StatusConflict || body["error"].(map[string]any)["code"] != "no_subscriptions" {
|
|
t.Errorf("POST with no subscriptions = %d %s, want 409 no_subscriptions", rec.Code, rec.Body)
|
|
}
|
|
|
|
for _, ep := range []string{testPushEndpoint, testPushEndpoint + "-phone"} {
|
|
req := signedIn(http.MethodPost, pushSubscriptionsPath, strings.NewReader(subscriptionJSON(t, ep, "")))
|
|
if rec, _ := serve(t, router, req); rec.Code != http.StatusOK {
|
|
t.Fatalf("POST subscription = %d %s", rec.Code, rec.Body)
|
|
}
|
|
}
|
|
rec, body = serve(t, router, signedIn(http.MethodPost, testPushPath, nil))
|
|
key, _ := body["event_key"].(string)
|
|
if rec.Code != http.StatusAccepted || !strings.HasPrefix(key, "plan_ready:test-") || body["subscriptions"] != float64(2) {
|
|
t.Fatalf("POST test-push = %d %s, want 202 with a plan_ready:test- key and 2 subscriptions", rec.Code, rec.Body)
|
|
}
|
|
// One push row, none for the household's Google Chat space, opening /plan.
|
|
want := []notifytest.Row{{Key: key, Channel: notify.ChannelPush, Payload: notify.Payload{
|
|
Event: notify.PlanReady, Title: "MealQuest test notification", Body: push.TestBody, URL: "/plan", Tag: key,
|
|
}}}
|
|
if got := notifytest.Rows(t, st, notify.PlanReady); len(got) != 1 || got[0] != want[0] {
|
|
t.Errorf("outbox = %+v, want %+v", got, want)
|
|
}
|
|
|
|
// With push off for plan_ready, the test says so and sends nothing.
|
|
if _, err := st.Writer.Exec(`UPDATE notification_prefs SET push = 0 WHERE event = 'plan_ready'`); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
rec, body = serve(t, router, signedIn(http.MethodPost, testPushPath, nil))
|
|
if rec.Code != http.StatusConflict || body["error"].(map[string]any)["code"] != "push_off" {
|
|
t.Errorf("POST with push off = %d %s, want 409 push_off", rec.Code, rec.Body)
|
|
}
|
|
if got := notifytest.Rows(t, st, notify.PlanReady); len(got) != 1 {
|
|
t.Errorf("outbox after push off = %+v, want still one row", got)
|
|
}
|
|
}
|