CI / ci (push) Successful in 8m31s
Co-authored-by: orchestrator-agent <20+orchestrator-agent@code.cubecraftcreations.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), 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), 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)
|
|
}
|
|
}
|