package api
import (
"context"
"encoding/json"
"strings"
"testing"
"craftdesign.group/quarry/internal/ops"
)
// A test push says what the push services answered, from notify's own
// record — the texts here are the ones zeitforge recorded for an
// account with one Chrome and two iPhone subscriptions — and never
// carries a device's endpoint.
func TestAPushOutcomeSaysWhoRefusedAndWhy(t *testing.T) {
cases := []struct {
name string
in PushOutcome
outcome string
got, of int
why string
}{
{"not yet", PushOutcome{}, "unknown", 0, 0, ""},
{"all", PushOutcome{Found: true, Status: "sent"}, "delivered", 2, 2, ""},
{"apple signature", PushOutcome{Found: true, Status: "sent",
Error: `delivered 1/2: 1 permanent failure(s) (webpush: status 403: {"reason":"BadJwtToken"})`}, "partial", 1, 2, "403 BadJwtToken"},
{"timeout and signature", PushOutcome{Found: true, Status: "sent",
Error: `delivered 1/3: 2 permanent failure(s) (webpush: status 403: {"reason":"BadJwtToken"}; webpush: send: Post "https://web.push.apple.com/QF_secretEndpoint": context deadline exceeded (Client.Timeout exceeded while awaiting headers))`},
"partial", 1, 3, "403 BadJwtToken; timed out"},
{"none", PushOutcome{Found: true, Status: "error",
Error: `webpush: status 403: {"reason":"BadJwtToken"}; webpush: status 403: {"reason":"BadJwtToken"}`}, "failed", 0, 0, "2× 403 BadJwtToken"},
}
for _, c := range cases {
s := summarizePush(c.in, 2)
why := strings.Join(s.Why, "; ")
if s.Outcome != c.outcome || s.Delivered != c.got || s.Of != c.of || why != c.why {
t.Errorf("FAIL %s: %+v (why %q)", c.name, s, why)
}
if strings.Contains(why, "secretEndpoint") {
t.Errorf("FAIL %s: a device endpoint leaked into the summary", c.name)
}
}
}
// The op hands notify's per-device outcomes on beside the overall result.
func TestThePushTestReportsEachDevice(t *testing.T) {
np := &outcomeNotify{out: PushOutcome{Found: true, Status: "sent", Devices: []PushDeviceOutcome{
{Host: "fcm.googleapis.com", Outcome: "delivered"},
{Host: "web.push.apple.com", Outcome: "gone", Status: 410},
}}}
r := ops.NewRegistry()
if err := RegisterAll(r, Deps{Version: "t", Notify: np}); err != nil {
t.Fatal(err)
}
got, err := r.Invoke(ops.WithUser(context.Background(), "dan"), "me.push.test", mePushTestIn{})
if err != nil {
t.Fatal(err)
}
b, _ := json.Marshal(got)
var wire struct {
Outcome string `json:"outcome"`
Results []struct {
Host string `json:"host"`
Outcome string `json:"outcome"`
Status int `json:"status"`
} `json:"results"`
}
if err := json.Unmarshal(b, &wire); err != nil {
t.Fatal(err)
}
if wire.Outcome == "" || len(wire.Results) != 2 ||
wire.Results[1].Host != "web.push.apple.com" || wire.Results[1].Outcome != "gone" || wire.Results[1].Status != 410 {
t.Errorf("FAIL: wire %s", b)
}
}
type outcomeNotify struct {
fakeNotify
out PushOutcome
}
func (o *outcomeNotify) PushOutcome(context.Context, string, string) (PushOutcome, error) {
return o.out, nil
}
type hashDevices struct {
rows []PushDevice
deleted []string
}
func (h *hashDevices) AddPushDevice(account, endpoint, ua string) error { return nil }
func (h *hashDevices) DeletePushDevice(account, endpoint string) error { return nil }
func (h *hashDevices) MarkPushDevicePendingDelete(a, e string) error { return nil }
func (h *hashDevices) PendingPushDeletes(account string) ([]string, error) { return nil, nil }
func (h *hashDevices) PushDevices(account string) ([]PushDevice, error) { return h.rows, nil }
func (h *hashDevices) DeletePushDeviceByHash(account, hash string) error {
h.deleted = append(h.deleted, hash)
kept := h.rows[:0]
for _, r := range h.rows {
if r.EndpointHash != hash {
kept = append(kept, r)
}
}
h.rows = kept
return nil
}
func pushTestWire(t *testing.T, np *outcomeNotify, devs *hashDevices) []struct {
Outcome string `json:"outcome"`
Device string `json:"device"`
Sha string `json:"endpointSha256"`
} {
t.Helper()
r := ops.NewRegistry()
if err := RegisterAll(r, Deps{Version: "t", Notify: np, PushDevices: devs}); err != nil {
t.Fatal(err)
}
got, err := r.Invoke(ops.WithUser(context.Background(), "dan"), "me.push.test", mePushTestIn{})
if err != nil {
t.Fatal(err)
}
b, _ := json.Marshal(got)
var wire struct {
Results []struct {
Outcome string `json:"outcome"`
Device string `json:"device"`
Sha string `json:"endpointSha256"`
} `json:"results"`
}
if err := json.Unmarshal(b, &wire); err != nil {
t.Fatal(err)
}
return wire.Results
}
// A send that reached no device still lists every device, and each
// outcome is matched to quarry's row by endpointSha256.
func TestThePushTestShowsEveryDeviceWhenAllFailed(t *testing.T) {
devs := &hashDevices{rows: []PushDevice{{EndpointHash: "h1", UA: "Firefox"}, {EndpointHash: "h2", UA: "Safari"}}}
np := &outcomeNotify{out: PushOutcome{Found: true, Status: "error", Error: "webpush: status 403", Devices: []PushDeviceOutcome{
{Host: "a", Outcome: "failed", Status: 403, EndpointSHA256: "h2"},
{Host: "b", Outcome: "failed", Status: 403, EndpointSHA256: "h1"},
}}}
res := pushTestWire(t, np, devs)
if len(res) != 2 || res[0].Device != "Safari" || res[1].Device != "Firefox" {
t.Errorf("FAIL: %+v", res)
}
if len(devs.deleted) != 0 {
t.Errorf("FAIL: failed devices were removed: %v", devs.deleted)
}
}
// A device notify already deleted (gone, retired) leaves quarry's list.
func TestThePushTestRemovesGoneDeviceRows(t *testing.T) {
devs := &hashDevices{rows: []PushDevice{{EndpointHash: "h1", UA: "Firefox"}, {EndpointHash: "h2", UA: "Safari"}, {EndpointHash: "h3", UA: "Edge"}}}
np := &outcomeNotify{out: PushOutcome{Found: true, Status: "sent", Devices: []PushDeviceOutcome{
{Outcome: "delivered", EndpointSHA256: "h1"},
{Outcome: "gone", Status: 410, EndpointSHA256: "h2"},
{Outcome: "retired", Status: 403, EndpointSHA256: "h3"},
}}}
res := pushTestWire(t, np, devs)
if len(res) != 3 || res[1].Device != "Safari" {
t.Errorf("FAIL: %+v", res)
}
if len(devs.rows) != 1 || devs.rows[0].EndpointHash != "h1" {
t.Errorf("FAIL: rows left %+v", devs.rows)
}
}