Files
MealQuest/internal/server/push_test.go
T
dev_agent fd087e0ba3
CI / ci (pull_request) Successful in 8m6s
CI / ci (push) Successful in 11m26s
Feature 21: Home Dashboard (#181)
Co-authored-by: dev_agent <dev_agent@cubecraftlabs.com>
2026-10-10 20:07:53 -04:00

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)
}
}