| 1 | package calls |
| 2 | |
| 3 | import ( |
| 4 | "context" |
| 5 | "net/http" |
| 6 | "net/http/httptest" |
| 7 | "strings" |
| 8 | "sync" |
| 9 | "testing" |
| 10 | |
| 11 | "amadan.net/oficina/calls/internal/calls/apns" |
| 12 | ) |
| 13 | |
| 14 | // recordingPusher stands in for Apple. The verdict is per token so one |
| 15 | // test can have a live device and a dead one at the same time. |
| 16 | type recordingPusher struct { |
| 17 | mu sync.Mutex |
| 18 | sent []string |
| 19 | verdicts map[string]apns.Verdict |
| 20 | } |
| 21 | |
| 22 | func (p *recordingPusher) Push(ctx context.Context, token, platform, name string) (apns.Verdict, error) { |
| 23 | p.mu.Lock() |
| 24 | defer p.mu.Unlock() |
| 25 | p.sent = append(p.sent, token+"|"+platform+"|"+name) |
| 26 | if v, ok := p.verdicts[token]; ok { |
| 27 | if v == apns.Gone { |
| 28 | return v, context.Canceled |
| 29 | } |
| 30 | return v, nil |
| 31 | } |
| 32 | return apns.Delivered, nil |
| 33 | } |
| 34 | |
| 35 | func (p *recordingPusher) all() []string { |
| 36 | p.mu.Lock() |
| 37 | defer p.mu.Unlock() |
| 38 | return append([]string(nil), p.sent...) |
| 39 | } |
| 40 | |
| 41 | func deviceJSON(f *homeRouterFixture, body string) *httptest.ResponseRecorder { |
| 42 | r := httptest.NewRequest("POST", homeTestOrigin+"/native/devices", strings.NewReader(body)) |
| 43 | r.Header.Set("Content-Type", "application/json") |
| 44 | r.Header.Set("Origin", homeTestOrigin) |
| 45 | for _, c := range f.cookies { |
| 46 | r.AddCookie(c) |
| 47 | } |
| 48 | w := httptest.NewRecorder() |
| 49 | f.handler.ServeHTTP(w, r) |
| 50 | return w |
| 51 | } |
| 52 | |
| 53 | func TestRegisteringADeviceStoresItAndIsIdempotent(t *testing.T) { |
| 54 | f := newHomeRouterFixture(t) |
| 55 | f.local(t) |
| 56 | if w := deviceJSON(f, `{"token":"AABBCC0011","platform":"macos"}`); w.Code != http.StatusNoContent { |
| 57 | t.Fatal("refused", w.Code, w.Body.String()) |
| 58 | } |
| 59 | var count int |
| 60 | if err := f.app.db.Writer().QueryRow("SELECT count(*) FROM device_tokens").Scan(&count); err != nil { |
| 61 | t.Fatal(err) |
| 62 | } |
| 63 | if count != 1 { |
| 64 | t.Fatal("expected one row", count) |
| 65 | } |
| 66 | // The app re-registers on every launch. That must move last_seen, |
| 67 | // not accumulate rows. |
| 68 | if w := deviceJSON(f, `{"token":"aabbcc0011","platform":"macos"}`); w.Code != http.StatusNoContent { |
| 69 | t.Fatal("re-registration refused", w.Code) |
| 70 | } |
| 71 | if err := f.app.db.Writer().QueryRow("SELECT count(*) FROM device_tokens").Scan(&count); err != nil { |
| 72 | t.Fatal(err) |
| 73 | } |
| 74 | if count != 1 { |
| 75 | t.Fatal("re-registration made a second row", count) |
| 76 | } |
| 77 | var stored string |
| 78 | f.app.db.Writer().QueryRow("SELECT token FROM device_tokens").Scan(&stored) |
| 79 | if stored != "aabbcc0011" { |
| 80 | t.Fatal("token not normalised to lower case", stored) |
| 81 | } |
| 82 | } |
| 83 | |
| 84 | func TestADeviceRegistrationMustBeHexAndAKnownPlatform(t *testing.T) { |
| 85 | f := newHomeRouterFixture(t) |
| 86 | f.local(t) |
| 87 | for name, body := range map[string]string{ |
| 88 | "not hex": `{"token":"zzzz","platform":"ios"}`, |
| 89 | "empty token": `{"token":"","platform":"ios"}`, |
| 90 | "no platform": `{"token":"aabb"}`, |
| 91 | "bad platform": `{"token":"aabb","platform":"android"}`, |
| 92 | "too long": `{"token":"` + strings.Repeat("ab", 200) + `","platform":"ios"}`, |
| 93 | "not json": `hello`, |
| 94 | } { |
| 95 | if w := deviceJSON(f, body); w.Code == http.StatusNoContent { |
| 96 | t.Errorf("accepted %s", name) |
| 97 | } |
| 98 | } |
| 99 | var count int |
| 100 | f.app.db.Writer().QueryRow("SELECT count(*) FROM device_tokens").Scan(&count) |
| 101 | if count != 0 { |
| 102 | t.Fatal("a refused registration still stored something", count) |
| 103 | } |
| 104 | } |
| 105 | |
| 106 | func TestADeviceRegistrationNeedsBothLocksAndASession(t *testing.T) { |
| 107 | f := newHomeRouterFixture(t) |
| 108 | f.local(t) |
| 109 | // A form content type is the cross-site shape an HTML form can send. |
| 110 | form := httptest.NewRequest("POST", homeTestOrigin+"/native/devices", strings.NewReader("")) |
| 111 | form.Header.Set("Content-Type", "application/x-www-form-urlencoded") |
| 112 | form.Header.Set("Origin", homeTestOrigin) |
| 113 | for _, c := range f.cookies { |
| 114 | form.AddCookie(c) |
| 115 | } |
| 116 | w := httptest.NewRecorder() |
| 117 | f.handler.ServeHTTP(w, form) |
| 118 | if w.Code != http.StatusUnsupportedMediaType { |
| 119 | t.Fatal("form post accepted", w.Code) |
| 120 | } |
| 121 | // And no origin is csrf.Protect's refusal, before the handler. |
| 122 | bare := httptest.NewRequest("POST", homeTestOrigin+"/native/devices", strings.NewReader(`{"token":"aabb","platform":"ios"}`)) |
| 123 | bare.Header.Set("Content-Type", "application/json") |
| 124 | for _, c := range f.cookies { |
| 125 | bare.AddCookie(c) |
| 126 | } |
| 127 | w = httptest.NewRecorder() |
| 128 | f.handler.ServeHTTP(w, bare) |
| 129 | if w.Code == http.StatusNoContent { |
| 130 | t.Fatal("origin-less registration accepted") |
| 131 | } |
| 132 | // Signed out entirely. |
| 133 | g := newHomeRouterFixture(t) |
| 134 | if w := deviceJSON(g, `{"token":"aabb","platform":"ios"}`); w.Code == http.StatusNoContent { |
| 135 | t.Fatal("signed-out registration accepted") |
| 136 | } |
| 137 | } |
| 138 | |
| 139 | // The decision this whole feature turns on: who is told. |
| 140 | func TestAKnockSkipsMembersWhoAreAlreadyLooking(t *testing.T) { |
| 141 | f := newHomeRouterFixture(t) |
| 142 | f.local(t) |
| 143 | if w := deviceJSON(f, `{"token":"aabbcc","platform":"macos"}`); w.Code != http.StatusNoContent { |
| 144 | t.Fatal(w.Code) |
| 145 | } |
| 146 | var personID int64 |
| 147 | if err := f.app.db.Writer().QueryRow("SELECT person_id FROM device_tokens").Scan(&personID); err != nil { |
| 148 | t.Fatal(err) |
| 149 | } |
| 150 | pusher := &recordingPusher{} |
| 151 | f.app.push = pusher |
| 152 | |
| 153 | // In the room: the queue is on their screen already, and a push |
| 154 | // would be a second telling of something they can see. |
| 155 | f.app.knocked("room1", []int64{personID}, "Bea") |
| 156 | if got := pusher.all(); len(got) != 0 { |
| 157 | t.Fatal("pushed to a member who was watching", got) |
| 158 | } |
| 159 | |
| 160 | // Not in the room: this is the case the feature exists for, and the |
| 161 | // case that was impossible before it. |
| 162 | f.app.knocked("room1", nil, "Bea") |
| 163 | got := pusher.all() |
| 164 | if len(got) != 1 || got[0] != "aabbcc|macos|Bea" { |
| 165 | t.Fatal("absent member was not told", got) |
| 166 | } |
| 167 | } |
| 168 | |
| 169 | func TestAKnockDeletesATokenAppleSaysIsGone(t *testing.T) { |
| 170 | f := newHomeRouterFixture(t) |
| 171 | f.local(t) |
| 172 | deviceJSON(f, `{"token":"deadbeef","platform":"ios"}`) |
| 173 | f.app.push = &recordingPusher{verdicts: map[string]apns.Verdict{"deadbeef": apns.Gone}} |
| 174 | f.app.knocked("room1", nil, "Bea") |
| 175 | var count int |
| 176 | f.app.db.Writer().QueryRow("SELECT count(*) FROM device_tokens").Scan(&count) |
| 177 | if count != 0 { |
| 178 | t.Fatal("a token Apple reported unregistered was kept", count) |
| 179 | } |
| 180 | } |
| 181 | |
| 182 | func TestAKnockKeepsATokenWhenAppleIsMerelyUnreachable(t *testing.T) { |
| 183 | // The distinction that matters: an outage must never be read as "the |
| 184 | // device is gone", or one bad afternoon empties the table. |
| 185 | f := newHomeRouterFixture(t) |
| 186 | f.local(t) |
| 187 | deviceJSON(f, `{"token":"abcdef","platform":"ios"}`) |
| 188 | f.app.push = &recordingPusher{verdicts: map[string]apns.Verdict{"abcdef": apns.Retry}} |
| 189 | f.app.knocked("room1", nil, "Bea") |
| 190 | var count int |
| 191 | f.app.db.Writer().QueryRow("SELECT count(*) FROM device_tokens").Scan(&count) |
| 192 | if count != 1 { |
| 193 | t.Fatal("a retryable refusal deleted the token", count) |
| 194 | } |
| 195 | } |
| 196 | |
| 197 | func TestNoPushKeyIsQuietRatherThanBroken(t *testing.T) { |
| 198 | // Nil push is every deployment without a key, and it must be a |
| 199 | // no-op: knocks still reach everyone in the room over their streams, |
| 200 | // exactly as before any of this existed. |
| 201 | f := newHomeRouterFixture(t) |
| 202 | f.local(t) |
| 203 | deviceJSON(f, `{"token":"aabb","platform":"ios"}`) |
| 204 | f.app.push = nil |
| 205 | f.app.knocked("room1", nil, "Bea") |
| 206 | } |
| 207 | |