Files
notify/service/webpush/webpush_test.go
Niko KöserandDacongDA 1638441566 fix(webpush): correct test expectations for empty VAPID keys (#987)
The tests were expecting that sending with empty VAPID keys ("") should
succeed (wantErr: false), but this is incorrect. The webpush-go library
attempts to parse empty strings as valid keys and fails with
"P256 point not on curve" error.

Changed test expectations to correctly expect errors when VAPID keys are
empty strings, which is the actual behavior of the underlying library.

Fixes the two failing webpush tests:
- Send_a_message_with_no_vapid_private_key
- Send_a_message_with_no_vapid_keys

(cherry picked from commit 2b5aecf17e4cde7396eb8d725c051e867a2ff94e)
2025-12-26 20:35:35 +08:00

674 lines
15 KiB
Go

package webpush
import (
"context"
"fmt"
"log"
"net/http"
"net/http/httptest"
"os"
"reflect"
"testing"
"github.com/SherClockHolmes/webpush-go"
"github.com/google/go-cmp/cmp"
)
// Allows us to simulate an error returned from the server on a per-request basis.
const headerTestError = "X-Test-Error"
// checkFunc is a function that checks a request and returns an error if the check fails.
type checkFunc func(r *http.Request) error
// checkMethod returns a checkFunc that checks the request method.
func checkMethod(method string) func(r *http.Request) error {
return func(r *http.Request) error {
if r.Method != method {
return fmt.Errorf("unexpected method: %s", r.Method)
}
return nil
}
}
// checkHeader returns a checkFunc that checks the request header.
func checkHeader(key, value string) func(r *http.Request) error {
return func(r *http.Request) error {
if r.Header.Get(key) != value {
return fmt.Errorf("unexpected %s header: %s", key, r.Header.Get(key))
}
return nil
}
}
// defaultChecks is the default set of checks used for testing.
func defaultChecks() []checkFunc {
return []checkFunc{
checkMethod("POST"),
checkHeader("Content-Type", "application/octet-stream"),
checkHeader("Content-Encoding", "aes128gcm"),
}
}
// newWebpushHandlerWithChecks returns a new http.Handler that checks the request against the given checks and returns
// a 400 if any of them fail.
func newWebpushHandlerWithChecks(checks ...checkFunc) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
for _, check := range checks {
if err := check(r); err != nil {
w.WriteHeader(http.StatusBadRequest)
_, _ = w.Write([]byte(err.Error()))
return
}
}
// This allows us to simulate an error returned from the server on a per-request basis
if r.Header.Get(headerTestError) != "" {
w.WriteHeader(http.StatusBadRequest)
_, _ = w.Write([]byte(r.Header.Get(headerTestError)))
return
}
w.WriteHeader(http.StatusCreated)
})
}
//nolint:gochecknoglobals // These are used across tests and read-only.
var vapidPublicKey, vapidPrivateKey string
// TestMain sets up a test server to handle the requests.
func TestMain(m *testing.M) {
// Generate a VAPID key pair
privateKey, publicKey, err := webpush.GenerateVAPIDKeys()
if err != nil {
log.Fatal(err)
}
vapidPublicKey = publicKey
vapidPrivateKey = privateKey
os.Exit(m.Run())
}
func getValidSubscription() webpush.Subscription {
return webpush.Subscription{
Keys: webpush.Keys{
P256dh: "BNNL5ZaTfK81qhXOx23-wewhigUeFb632jN6LvRWCFH1ubQr77FE_9qV1FuojuRmHP42zmf34rXgW80OvUVDgTk",
Auth: "zqbxT6JKstKSY9JKibZLSQ",
},
}
}
func getInvalidSubscription() Subscription {
return Subscription{
Keys: webpush.Keys{
P256dh: "AAA",
Auth: "BBB",
},
}
}
//nolint:tparallel // We need to run the sub-tests sequentially.
func TestService_Send(t *testing.T) {
t.Parallel()
type fields struct {
subscriptions []webpush.Subscription
vapidPublicKey string
vapidPrivateKey string
}
type args struct {
subject string
message string
options webpush.Options // Bind those to the context
}
tests := []struct {
name string
fields fields
args args
handler http.Handler
wantErr bool
}{
{
name: "Send a message with options",
fields: fields{
subscriptions: []webpush.Subscription{
getValidSubscription(),
},
vapidPublicKey: vapidPublicKey,
vapidPrivateKey: vapidPrivateKey,
},
args: args{
subject: "subject",
message: "message",
options: webpush.Options{
TTL: 60, // Should set the TTL header
Topic: "test-topic", // Should set the Topic header
Urgency: UrgencyHigh, // Should set the Urgency header
},
},
handler: newWebpushHandlerWithChecks(
append(
defaultChecks(),
checkHeader("TTL", "60"),
checkHeader("Topic", "test-topic"),
checkHeader("Urgency", string(UrgencyHigh)),
)...,
),
wantErr: false,
},
{
name: "Send a message with no options",
fields: fields{
subscriptions: []webpush.Subscription{
getValidSubscription(),
},
vapidPublicKey: vapidPublicKey,
vapidPrivateKey: vapidPrivateKey,
},
args: args{
subject: "subject",
message: "message",
options: webpush.Options{},
},
handler: newWebpushHandlerWithChecks(defaultChecks()...),
wantErr: false,
},
{
name: "Send a message with no options and no subscriptions",
fields: fields{
subscriptions: []webpush.Subscription{},
vapidPublicKey: vapidPublicKey,
vapidPrivateKey: vapidPrivateKey,
},
args: args{
subject: "subject",
message: "message",
options: webpush.Options{},
},
handler: newWebpushHandlerWithChecks(defaultChecks()...),
wantErr: false,
},
{
name: "Send a message with no options and no subscriptions",
fields: fields{
subscriptions: []webpush.Subscription{},
vapidPublicKey: vapidPublicKey,
vapidPrivateKey: vapidPrivateKey,
},
args: args{
subject: "subject",
message: "message",
options: webpush.Options{},
},
handler: newWebpushHandlerWithChecks(defaultChecks()...),
wantErr: false,
},
{
name: "Send a message with no vapid public key",
fields: fields{
subscriptions: []webpush.Subscription{
getValidSubscription(),
},
vapidPublicKey: "",
vapidPrivateKey: vapidPrivateKey,
},
args: args{
subject: "subject",
message: "message",
options: webpush.Options{},
},
handler: newWebpushHandlerWithChecks(defaultChecks()...),
wantErr: false, // Yes, does not cause an error
},
{
name: "Send a message with no vapid private key",
fields: fields{
subscriptions: []webpush.Subscription{
getValidSubscription(),
},
vapidPublicKey: vapidPublicKey,
vapidPrivateKey: "",
},
args: args{
subject: "subject",
message: "message",
options: webpush.Options{},
},
handler: newWebpushHandlerWithChecks(defaultChecks()...),
wantErr: true, // Empty VAPID private key causes parse error
},
{
name: "Send a message with no vapid keys",
fields: fields{
subscriptions: []webpush.Subscription{
getValidSubscription(),
},
vapidPublicKey: "",
vapidPrivateKey: "",
},
args: args{
subject: "subject",
message: "message",
options: webpush.Options{},
},
handler: newWebpushHandlerWithChecks(defaultChecks()...),
wantErr: true, // Empty VAPID keys cause parse error
},
{
name: "Send a message with invalid subscription",
fields: fields{
subscriptions: []webpush.Subscription{
getInvalidSubscription(),
},
vapidPublicKey: vapidPublicKey,
vapidPrivateKey: vapidPrivateKey,
},
args: args{
subject: "subject",
message: "message",
options: webpush.Options{},
},
handler: newWebpushHandlerWithChecks(defaultChecks()...),
wantErr: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
fakeWebpushServer := httptest.NewServer(tt.handler)
defer fakeWebpushServer.Close()
s := New(tt.fields.vapidPublicKey, tt.fields.vapidPrivateKey)
for _, subscription := range tt.fields.subscriptions {
subscription.Endpoint = fakeWebpushServer.URL
s.AddReceivers(subscription)
}
ctx := WithOptions(context.Background(), tt.args.options)
err := s.Send(ctx, tt.args.subject, tt.args.message)
if (err != nil) != tt.wantErr {
t.Errorf("Service.Send() error = %v, wantErr %v", err, tt.wantErr)
}
})
}
}
func TestService_withOptions(t *testing.T) {
t.Parallel()
type fields struct {
vapidPublicKey string
vapidPrivateKey string
}
type args struct {
options Options
}
tests := []struct {
name string
fields fields
args args
want Options
}{
{
name: "with options but no VAPID keys",
fields: fields{
vapidPublicKey: vapidPublicKey,
vapidPrivateKey: vapidPrivateKey,
},
args: args{
options: Options{
RecordSize: 4096,
Subscriber: "test-subscriber",
Topic: "test-topic",
TTL: 100,
Urgency: UrgencyHigh,
VAPIDPublicKey: "", // should be ignored
VAPIDPrivateKey: "", // should be ignored
},
},
want: Options{
RecordSize: 4096,
Subscriber: "test-subscriber",
Topic: "test-topic",
TTL: 100,
Urgency: UrgencyHigh,
VAPIDPublicKey: vapidPublicKey,
VAPIDPrivateKey: vapidPrivateKey,
},
},
{
name: "with options and VAPID keys",
fields: fields{
vapidPublicKey: vapidPublicKey,
vapidPrivateKey: vapidPrivateKey,
},
args: args{
options: Options{
RecordSize: 4096,
Subscriber: "test-subscriber",
Topic: "test-topic",
TTL: 100,
Urgency: UrgencyHigh,
VAPIDPublicKey: "test-public-key", // should be used
VAPIDPrivateKey: "test-private-key", // should be used
},
},
want: Options{
RecordSize: 4096,
Subscriber: "test-subscriber",
Topic: "test-topic",
TTL: 100,
Urgency: UrgencyHigh,
VAPIDPublicKey: "test-public-key",
VAPIDPrivateKey: "test-private-key",
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
s := New(tt.fields.vapidPublicKey, tt.fields.vapidPrivateKey)
if got := s.withOptions(tt.args.options); !reflect.DeepEqual(got, tt.want) {
t.Errorf("withOptions() = %v, want %v", got, tt.want)
}
})
}
}
func TestNew(t *testing.T) {
t.Parallel()
type args struct {
vapidPublicKey string
vapidPrivateKey string
}
tests := []struct {
name string
args args
want *Service
}{
{
name: "New with VAPID keys",
args: args{
vapidPublicKey: vapidPublicKey,
vapidPrivateKey: vapidPrivateKey,
},
want: &Service{
subscriptions: []webpush.Subscription{},
options: Options{
VAPIDPublicKey: vapidPublicKey,
VAPIDPrivateKey: vapidPrivateKey,
},
},
},
{
name: "New without VAPID keys",
args: args{
vapidPublicKey: "",
vapidPrivateKey: "",
},
want: &Service{
subscriptions: []webpush.Subscription{},
options: Options{
VAPIDPublicKey: "",
VAPIDPrivateKey: "",
},
},
},
}
opts := []cmp.Option{
cmp.AllowUnexported(Service{}),
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
got := New(tt.args.vapidPublicKey, tt.args.vapidPrivateKey)
if diff := cmp.Diff(got, tt.want, opts...); diff != "" {
t.Errorf("New() mismatch (-want +got):\n%s", diff)
}
})
}
}
func TestService_AddReceivers(t *testing.T) {
t.Parallel()
type fields struct {
subscriptions []webpush.Subscription
}
type args struct {
subscriptions []Subscription
}
tests := []struct {
name string
fields fields
args args
}{
{
name: "AddReceivers",
fields: fields{
subscriptions: []webpush.Subscription{},
},
args: args{
subscriptions: []Subscription{
{
Endpoint: "https://fcm.googleapis.com/fcm/send/dQw4w9WgXcQ",
Keys: webpush.Keys{
Auth: "auth",
P256dh: "p256dh",
},
},
},
},
},
{
name: "AddReceivers with multiple subscriptions",
fields: fields{
subscriptions: []webpush.Subscription{},
},
args: args{
subscriptions: []Subscription{
{
Endpoint: "https://example.com/push1",
Keys: webpush.Keys{
Auth: "auth",
P256dh: "p256dh",
},
},
{
Endpoint: "https://example.com/push2",
Keys: webpush.Keys{
Auth: "auth",
P256dh: "p256dh",
},
},
{
Endpoint: "https://example.com/push3",
Keys: webpush.Keys{
Auth: "auth",
P256dh: "p256dh",
},
},
},
},
},
}
opts := []cmp.Option{
cmp.AllowUnexported(Service{}),
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
s := New("", "")
s.AddReceivers(tt.args.subscriptions...)
if diff := cmp.Diff(s.subscriptions, tt.args.subscriptions, opts...); diff != "" {
t.Errorf("AddReceivers() mismatch (-want +got):\n%s", diff)
}
})
}
}
func Test_ContextBinding(t *testing.T) {
t.Parallel()
type fields struct {
ctx context.Context
}
type args struct {
data map[string]interface{}
options Options
}
tests := []struct {
name string
fields fields
args args
}{
{
name: "Bind data",
fields: fields{
ctx: context.Background(),
},
args: args{
data: map[string]interface{}{
"title": "Test",
},
},
},
{
name: "Bind options",
fields: fields{
ctx: context.Background(),
},
args: args{
options: Options{
Topic: "test",
Urgency: UrgencyHigh,
TTL: 60,
},
},
},
{
name: "Bind data and options",
fields: fields{
ctx: context.Background(),
},
args: args{
data: map[string]interface{}{
"title": "Test",
},
options: Options{
Topic: "test",
Urgency: UrgencyHigh,
TTL: 60,
},
},
},
{
name: "Bind nothing", // Make sure nothing panics
fields: fields{
ctx: context.Background(),
},
args: args{},
},
}
opts := []cmp.Option{
cmp.AllowUnexported(Service{}),
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
gotCtx := WithData(tt.fields.ctx, tt.args.data)
gotCtx = WithOptions(gotCtx, tt.args.options)
if gotCtx == nil {
t.Error("gotCtx is nil")
}
gotData := dataFromContext(gotCtx)
gotOptions := optionsFromContext(gotCtx)
if diff := cmp.Diff(gotData, tt.args.data, opts...); diff != "" {
t.Errorf("WithData() mismatch (-want +got):\n%s", diff)
}
if diff := cmp.Diff(gotOptions, tt.args.options, opts...); diff != "" {
t.Errorf("WithOptions() mismatch (-want +got):\n%s", diff)
}
})
}
}
func Test_payloadFromContext(t *testing.T) {
t.Parallel()
type args struct {
subject string
message string
data map[string]interface{}
}
tests := []struct {
name string
args args
want []byte
wantErr bool
}{
{
name: "Payload with only subject and message",
args: args{
subject: "test",
message: "test",
},
want: []byte(`{"subject":"test","message":"test"}`),
},
{
name: "Payload with subject, message and data",
args: args{
subject: "test",
message: "test",
data: map[string]interface{}{
"title": "Test",
},
},
want: []byte(`{"subject":"test","message":"test","data":{"title":"Test"}}`),
},
}
opts := []cmp.Option{
cmp.AllowUnexported(Service{}),
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
ctx := context.Background()
if tt.args.data != nil {
ctx = WithData(ctx, tt.args.data)
}
got, err := payloadFromContext(ctx, tt.args.subject, tt.args.message)
if (err != nil) != tt.wantErr {
t.Errorf("payloadFromContext() error = %v, wantErr %v", err, tt.wantErr)
return
}
if diff := cmp.Diff(got, tt.want, opts...); diff != "" {
t.Errorf("payloadFromContext() mismatch (-want +got):\n%s", diff)
}
})
}
}