package calls import ( "context" "net/http" "net/http/httptest" "strings" "sync" "testing" "amadan.net/oficina/calls/internal/calls/apns" ) // recordingPusher stands in for Apple. The verdict is per token so one // test can have a live device and a dead one at the same time. type recordingPusher struct { mu sync.Mutex sent []string verdicts map[string]apns.Verdict } func (p *recordingPusher) Push(ctx context.Context, token, platform, name string) (apns.Verdict, error) { p.mu.Lock() defer p.mu.Unlock() p.sent = append(p.sent, token+"|"+platform+"|"+name) if v, ok := p.verdicts[token]; ok { if v == apns.Gone { return v, context.Canceled } return v, nil } return apns.Delivered, nil } func (p *recordingPusher) all() []string { p.mu.Lock() defer p.mu.Unlock() return append([]string(nil), p.sent...) } func deviceJSON(f *homeRouterFixture, body string) *httptest.ResponseRecorder { r := httptest.NewRequest("POST", homeTestOrigin+"/native/devices", strings.NewReader(body)) r.Header.Set("Content-Type", "application/json") r.Header.Set("Origin", homeTestOrigin) for _, c := range f.cookies { r.AddCookie(c) } w := httptest.NewRecorder() f.handler.ServeHTTP(w, r) return w } func TestRegisteringADeviceStoresItAndIsIdempotent(t *testing.T) { f := newHomeRouterFixture(t) f.local(t) if w := deviceJSON(f, `{"token":"AABBCC0011","platform":"macos"}`); w.Code != http.StatusNoContent { t.Fatal("refused", w.Code, w.Body.String()) } var count int if err := f.app.db.Writer().QueryRow("SELECT count(*) FROM device_tokens").Scan(&count); err != nil { t.Fatal(err) } if count != 1 { t.Fatal("expected one row", count) } // The app re-registers on every launch. That must move last_seen, // not accumulate rows. if w := deviceJSON(f, `{"token":"aabbcc0011","platform":"macos"}`); w.Code != http.StatusNoContent { t.Fatal("re-registration refused", w.Code) } if err := f.app.db.Writer().QueryRow("SELECT count(*) FROM device_tokens").Scan(&count); err != nil { t.Fatal(err) } if count != 1 { t.Fatal("re-registration made a second row", count) } var stored string f.app.db.Writer().QueryRow("SELECT token FROM device_tokens").Scan(&stored) if stored != "aabbcc0011" { t.Fatal("token not normalised to lower case", stored) } } func TestADeviceRegistrationMustBeHexAndAKnownPlatform(t *testing.T) { f := newHomeRouterFixture(t) f.local(t) for name, body := range map[string]string{ "not hex": `{"token":"zzzz","platform":"ios"}`, "empty token": `{"token":"","platform":"ios"}`, "no platform": `{"token":"aabb"}`, "bad platform": `{"token":"aabb","platform":"android"}`, "too long": `{"token":"` + strings.Repeat("ab", 200) + `","platform":"ios"}`, "not json": `hello`, } { if w := deviceJSON(f, body); w.Code == http.StatusNoContent { t.Errorf("accepted %s", name) } } var count int f.app.db.Writer().QueryRow("SELECT count(*) FROM device_tokens").Scan(&count) if count != 0 { t.Fatal("a refused registration still stored something", count) } } func TestADeviceRegistrationNeedsBothLocksAndASession(t *testing.T) { f := newHomeRouterFixture(t) f.local(t) // A form content type is the cross-site shape an HTML form can send. form := httptest.NewRequest("POST", homeTestOrigin+"/native/devices", strings.NewReader("")) form.Header.Set("Content-Type", "application/x-www-form-urlencoded") form.Header.Set("Origin", homeTestOrigin) for _, c := range f.cookies { form.AddCookie(c) } w := httptest.NewRecorder() f.handler.ServeHTTP(w, form) if w.Code != http.StatusUnsupportedMediaType { t.Fatal("form post accepted", w.Code) } // And no origin is csrf.Protect's refusal, before the handler. bare := httptest.NewRequest("POST", homeTestOrigin+"/native/devices", strings.NewReader(`{"token":"aabb","platform":"ios"}`)) bare.Header.Set("Content-Type", "application/json") for _, c := range f.cookies { bare.AddCookie(c) } w = httptest.NewRecorder() f.handler.ServeHTTP(w, bare) if w.Code == http.StatusNoContent { t.Fatal("origin-less registration accepted") } // Signed out entirely. g := newHomeRouterFixture(t) if w := deviceJSON(g, `{"token":"aabb","platform":"ios"}`); w.Code == http.StatusNoContent { t.Fatal("signed-out registration accepted") } } // The decision this whole feature turns on: who is told. func TestAKnockSkipsMembersWhoAreAlreadyLooking(t *testing.T) { f := newHomeRouterFixture(t) f.local(t) if w := deviceJSON(f, `{"token":"aabbcc","platform":"macos"}`); w.Code != http.StatusNoContent { t.Fatal(w.Code) } var personID int64 if err := f.app.db.Writer().QueryRow("SELECT person_id FROM device_tokens").Scan(&personID); err != nil { t.Fatal(err) } pusher := &recordingPusher{} f.app.push = pusher // In the room: the queue is on their screen already, and a push // would be a second telling of something they can see. f.app.knocked("room1", []int64{personID}, "Bea") if got := pusher.all(); len(got) != 0 { t.Fatal("pushed to a member who was watching", got) } // Not in the room: this is the case the feature exists for, and the // case that was impossible before it. f.app.knocked("room1", nil, "Bea") got := pusher.all() if len(got) != 1 || got[0] != "aabbcc|macos|Bea" { t.Fatal("absent member was not told", got) } } func TestAKnockDeletesATokenAppleSaysIsGone(t *testing.T) { f := newHomeRouterFixture(t) f.local(t) deviceJSON(f, `{"token":"deadbeef","platform":"ios"}`) f.app.push = &recordingPusher{verdicts: map[string]apns.Verdict{"deadbeef": apns.Gone}} f.app.knocked("room1", nil, "Bea") var count int f.app.db.Writer().QueryRow("SELECT count(*) FROM device_tokens").Scan(&count) if count != 0 { t.Fatal("a token Apple reported unregistered was kept", count) } } func TestAKnockKeepsATokenWhenAppleIsMerelyUnreachable(t *testing.T) { // The distinction that matters: an outage must never be read as "the // device is gone", or one bad afternoon empties the table. f := newHomeRouterFixture(t) f.local(t) deviceJSON(f, `{"token":"abcdef","platform":"ios"}`) f.app.push = &recordingPusher{verdicts: map[string]apns.Verdict{"abcdef": apns.Retry}} f.app.knocked("room1", nil, "Bea") var count int f.app.db.Writer().QueryRow("SELECT count(*) FROM device_tokens").Scan(&count) if count != 1 { t.Fatal("a retryable refusal deleted the token", count) } } func TestNoPushKeyIsQuietRatherThanBroken(t *testing.T) { // Nil push is every deployment without a key, and it must be a // no-op: knocks still reach everyone in the room over their streams, // exactly as before any of this existed. f := newHomeRouterFixture(t) f.local(t) deviceJSON(f, `{"token":"aabb","platform":"ios"}`) f.app.push = nil f.app.knocked("room1", nil, "Bea") }