Skip to content
quarry Artifact [b499e48bd0]
Login

Artifact [b499e48bd0]

Artifact b499e48bd02730d8f69420ca9535f50a06222f6970c6886c3329ef0eb056f990:


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