package push import ( "context" "crypto/ecdh" "crypto/rand" "encoding/base64" "errors" "strings" "testing" "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/store" "code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/vapid" "code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/migrations" ) const endpoint = "https://fcm.googleapis.com/fcm/send/abc123" func newTestService(t *testing.T) (*Service, *store.Store) { t.Helper() ctx := context.Background() st, err := store.Open(ctx, t.TempDir()) if err != nil { t.Fatal(err) } t.Cleanup(func() { if err := st.Close(); err != nil { t.Error(err) } }) if err := store.Migrate(ctx, st.Writer, migrations.FS); err != nil { t.Fatal(err) } pair, err := vapid.Generate() if err != nil { t.Fatal(err) } svc, err := NewService(st, pair.PublicKey, notifytest.New(t, st)) if err != nil { t.Fatal(err) } return svc, st } // browserKeys returns a p256dh and auth as a browser's // PushSubscription.toJSON() gives them. func browserKeys(t *testing.T) (string, string) { t.Helper() k, err := ecdh.P256().GenerateKey(rand.Reader) if err != nil { t.Fatal(err) } auth := make([]byte, 16) rand.Read(auth) return base64.RawURLEncoding.EncodeToString(k.PublicKey().Bytes()), base64.RawURLEncoding.EncodeToString(auth) } type storedRow struct { id, p256dh, auth, createdAt, updatedAt string ua *string failures int } func rows(t *testing.T, st *store.Store) map[string]storedRow { t.Helper() rs, err := st.Reader.Query(`SELECT endpoint, id, p256dh, auth, user_agent, failure_count, created_at, updated_at FROM push_subscriptions`) if err != nil { t.Fatal(err) } defer rs.Close() got := map[string]storedRow{} for rs.Next() { var ep string var r storedRow if err := rs.Scan(&ep, &r.id, &r.p256dh, &r.auth, &r.ua, &r.failures, &r.createdAt, &r.updatedAt); err != nil { t.Fatal(err) } got[ep] = r } if err := rs.Err(); err != nil { t.Fatal(err) } return got } func TestNewServiceRefusesBadPublicKey(t *testing.T) { t.Parallel() _, st := newTestService(t) notifier := notifytest.New(t, st) if _, err := NewService(st, "not-a-key", notifier); err == nil { t.Error("NewService accepted a malformed public key") } if _, err := NewService(nil, "", notifier); err == nil { t.Error("NewService accepted a nil store") } pair, err := vapid.Generate() if err != nil { t.Fatal(err) } if _, err := NewService(st, pair.PublicKey, nil); err == nil { t.Error("NewService accepted a nil notifier") } } func TestSubscribeUpsertsByEndpoint(t *testing.T) { t.Parallel() svc, st := newTestService(t) ctx := context.Background() p1, a1 := browserKeys(t) ua := "Mozilla/5.0 (iPad)" first, err := svc.Subscribe(ctx, SubscribeRequest{Endpoint: endpoint, P256dh: p1, Auth: a1, UserAgent: &ua}) if err != nil { t.Fatal(err) } if first.ID == "" || first.Endpoint != endpoint || first.UserAgent == nil || *first.UserAgent != ua || first.CreatedAt != first.UpdatedAt { t.Errorf("first Subscribe = %+v", first) } if _, err := st.Writer.Exec(`UPDATE push_subscriptions SET failure_count = 3`); err != nil { t.Fatal(err) } // The same endpoint again, with the same keys and then rotated ones, // updates the one row. again, err := svc.Subscribe(ctx, SubscribeRequest{Endpoint: endpoint, P256dh: p1, Auth: a1, UserAgent: &ua}) if err != nil { t.Fatal(err) } p2, a2 := browserKeys(t) rotated, err := svc.Subscribe(ctx, SubscribeRequest{Endpoint: endpoint, P256dh: p2, Auth: a2}) if err != nil { t.Fatal(err) } for _, s := range []Subscription{again, rotated} { if s.ID != first.ID || s.CreatedAt != first.CreatedAt { t.Errorf("resubscribe = %+v, want id %s and created_at %s kept", s, first.ID, first.CreatedAt) } } if rotated.UserAgent != nil { t.Errorf("resubscribe without user_agent kept %q", *rotated.UserAgent) } got := rows(t, st) if len(got) != 1 { t.Fatalf("rows = %d, want 1: %+v", len(got), got) } r := got[endpoint] if r.p256dh != p2 || r.auth != a2 || r.ua != nil || r.failures != 0 || r.updatedAt < r.createdAt { t.Errorf("stored row = %+v, want rotated keys, no user agent, failures cleared", r) } // A second browser is a second row. other := endpoint + "-other" if _, err := svc.Subscribe(ctx, SubscribeRequest{Endpoint: other, P256dh: p1, Auth: a1}); err != nil { t.Fatal(err) } if got := rows(t, st); len(got) != 2 { t.Errorf("rows after a second endpoint = %d, want 2", len(got)) } } func TestUnsubscribe(t *testing.T) { t.Parallel() svc, st := newTestService(t) ctx := context.Background() p, a := browserKeys(t) for _, ep := range []string{endpoint, endpoint + "-keep"} { if _, err := svc.Subscribe(ctx, SubscribeRequest{Endpoint: ep, P256dh: p, Auth: a}); err != nil { t.Fatal(err) } } removed, err := svc.Unsubscribe(ctx, endpoint) if err != nil || !removed { t.Errorf("Unsubscribe = %v, %v; want true, nil", removed, err) } got := rows(t, st) if _, ok := got[endpoint]; ok || len(got) != 1 { t.Errorf("rows after Unsubscribe = %+v, want only the other endpoint", got) } removed, err = svc.Unsubscribe(ctx, endpoint) if err != nil || removed { t.Errorf("Unsubscribe of an endpoint not stored = %v, %v; want false, nil", removed, err) } var invalid *ValidationError if _, err := svc.Unsubscribe(ctx, "http://push.example.com/x"); !errors.As(err, &invalid) { t.Errorf("Unsubscribe of an http endpoint = %v, want *ValidationError", err) } } func TestSubscribeValidates(t *testing.T) { t.Parallel() svc, st := newTestService(t) p, a := browserKeys(t) pair, err := vapid.Generate() if err != nil { t.Fatal(err) } blank, long := " ", strings.Repeat("x", MaxUserAgentBytes+1) offCurve := make([]byte, 65) offCurve[0] = 0x04 raw := base64.RawURLEncoding.EncodeToString for _, tc := range []struct { name string req SubscribeRequest field string }{ {"missing endpoint", SubscribeRequest{P256dh: p, Auth: a}, "endpoint"}, {"http endpoint", SubscribeRequest{Endpoint: "http://push.example.com/x", P256dh: p, Auth: a}, "endpoint"}, {"relative endpoint", SubscribeRequest{Endpoint: "/fcm/send/x", P256dh: p, Auth: a}, "endpoint"}, {"endpoint with credentials", SubscribeRequest{Endpoint: "https://u:p@push.example.com/x", P256dh: p, Auth: a}, "endpoint"}, {"endpoint too long", SubscribeRequest{Endpoint: "https://push.example.com/" + strings.Repeat("x", MaxEndpointBytes), P256dh: p, Auth: a}, "endpoint"}, {"missing p256dh", SubscribeRequest{Endpoint: endpoint, Auth: a}, "keys.p256dh"}, {"padded p256dh", SubscribeRequest{Endpoint: endpoint, P256dh: p + "=", Auth: a}, "keys.p256dh"}, {"short p256dh", SubscribeRequest{Endpoint: endpoint, P256dh: a, Auth: a}, "keys.p256dh"}, {"p256dh off the curve", SubscribeRequest{Endpoint: endpoint, P256dh: raw(offCurve), Auth: a}, "keys.p256dh"}, {"private key as p256dh", SubscribeRequest{Endpoint: endpoint, P256dh: pair.PrivateKey, Auth: a}, "keys.p256dh"}, {"missing auth", SubscribeRequest{Endpoint: endpoint, P256dh: p}, "keys.auth"}, {"long auth", SubscribeRequest{Endpoint: endpoint, P256dh: p, Auth: raw(make([]byte, 32))}, "keys.auth"}, {"blank user agent", SubscribeRequest{Endpoint: endpoint, P256dh: p, Auth: a, UserAgent: &blank}, "user_agent"}, {"long user agent", SubscribeRequest{Endpoint: endpoint, P256dh: p, Auth: a, UserAgent: &long}, "user_agent"}, } { _, err := svc.Subscribe(context.Background(), tc.req) var invalid *ValidationError if !errors.As(err, &invalid) || len(invalid.Problems) != 1 || invalid.Problems[0].Field != tc.field { t.Errorf("%s: Subscribe error = %v, want one problem for %s", tc.name, err, tc.field) } } if got := rows(t, st); len(got) != 0 { t.Errorf("invalid requests stored %d rows", len(got)) } } func TestSendTestQueuesOnePushToPlan(t *testing.T) { t.Parallel() svc, st := newTestService(t) p256dh, auth := browserKeys(t) for _, ep := range []string{endpoint, endpoint + "-2"} { if _, err := svc.Subscribe(context.Background(), SubscribeRequest{Endpoint: ep, P256dh: p256dh, Auth: auth}); err != nil { t.Fatal(err) } } first, err := svc.SendTest(context.Background()) if err != nil { t.Fatal(err) } if !strings.HasPrefix(first.EventKey, "plan_ready:test-") || first.Subscriptions != 2 { t.Errorf("SendTest = %+v, want a plan_ready:test- key and 2 subscriptions", first) } // A second test is a new event, not taken for the first. second, err := svc.SendTest(context.Background()) if err != nil { t.Fatal(err) } if second.EventKey == first.EventKey { t.Fatalf("two tests share the key %s", first.EventKey) } // Seeded prefs send plan_ready to Google Chat too; the test never does. rows := notifytest.Rows(t, st, notify.PlanReady) if len(rows) != 2 { t.Fatalf("rows = %+v, want one per test", rows) } byKey := map[string]notifytest.Row{} for _, r := range rows { byKey[r.Key] = r } for _, sent := range []TestSent{first, second} { want := notifytest.Row{Key: sent.EventKey, Channel: notify.ChannelPush, Payload: notify.Payload{ Event: notify.PlanReady, Title: "MealQuest test notification", Body: TestBody, URL: "/plan", Tag: sent.EventKey, }} if got := byKey[sent.EventKey]; got != want { t.Errorf("row of %s = %+v, want %+v", sent.EventKey, got, want) } } } func TestSendTestWithoutSubscriptions(t *testing.T) { t.Parallel() svc, st := newTestService(t) if _, err := svc.SendTest(context.Background()); !errors.Is(err, ErrNoSubscriptions) { t.Fatalf("SendTest = %v, want ErrNoSubscriptions", err) } if rows := notifytest.Rows(t, st, notify.PlanReady); len(rows) != 0 { t.Fatalf("rows = %+v, want none", rows) } } func TestSendTestWithPushOff(t *testing.T) { t.Parallel() svc, st := newTestService(t) p256dh, auth := browserKeys(t) if _, err := svc.Subscribe(context.Background(), SubscribeRequest{Endpoint: endpoint, P256dh: p256dh, Auth: auth}); err != nil { t.Fatal(err) } if _, err := st.Writer.Exec(`UPDATE notification_prefs SET push = 0 WHERE event = 'plan_ready'`); err != nil { t.Fatal(err) } if _, err := svc.SendTest(context.Background()); !errors.Is(err, notify.ErrPushOff) { t.Fatalf("SendTest = %v, want notify.ErrPushOff", err) } if rows := notifytest.Rows(t, st, notify.PlanReady); len(rows) != 0 { t.Fatalf("rows = %+v, want none", rows) } } // Push off is reported before the subscriptions are looked at, so with // both push off and no device subscribed the answer is push off. func TestSendTestWithPushOffAndNoSubscriptions(t *testing.T) { t.Parallel() svc, st := newTestService(t) if _, err := st.Writer.Exec(`UPDATE notification_prefs SET push = 0 WHERE event = 'plan_ready'`); err != nil { t.Fatal(err) } _, err := svc.SendTest(context.Background()) if !errors.Is(err, notify.ErrPushOff) || errors.Is(err, ErrNoSubscriptions) { t.Fatalf("SendTest = %v, want notify.ErrPushOff", err) } if rows := notifytest.Rows(t, st, notify.PlanReady); len(rows) != 0 { t.Fatalf("rows = %+v, want none", rows) } }