package api_test import ( "net/http" "testing" ) // deviceOf — идентификатор устройства: 16 байт base64url (docs/crypto.md). func deviceOf(seed byte) string { return bytesOf(16, seed) } // join регистрирует аккаунт и его устройство, отдаёт cookie и id. func (e *env) join(nick string, seed byte) (*http.Cookie, string) { e.t.Helper() c := e.signUp(nick) return c, e.addDevice(c, deviceOf(seed)) } // addDevice регистрирует устройство под уже открытой сессией. func (e *env) addDevice(c *http.Cookie, id string) string { e.t.Helper() rec := e.do(http.MethodPost, "/api/devices", map[string]any{"id": id}, with(c)) if rec.Code != http.StatusCreated && rec.Code != http.StatusOK { e.t.Fatalf("регистрация устройства: %d (%s)", rec.Code, rec.Body.String()) } return id } func TestDevices(t *testing.T) { e := newEnv(t) c := e.signUp("marta") id := deviceOf(1) first := e.do(http.MethodPost, "/api/devices", map[string]any{"id": id}, with(c)) expect(t, first, http.StatusCreated, "") var created struct { ID string `json:"id"` } decodeBody(t, first, &created) if created.ID != id { t.Errorf("id в ответе: получено %q, ожидалось %q", created.ID, id) } // Повтор — то же устройство того же пользователя. expect(t, e.do(http.MethodPost, "/api/devices", map[string]any{"id": id}, with(c)), http.StatusOK, "") rec := e.do(http.MethodGet, "/api/devices", nil, with(c)) expect(t, rec, http.StatusOK, "") var list []struct { ID string `json:"id"` CreatedAt int64 `json:"createdAt"` LastSeen int64 `json:"lastSeen"` HasPush bool `json:"hasPush"` Current bool `json:"current"` } decodeBody(t, rec, &list) if len(list) != 1 { t.Fatalf("устройств: получено %d, ожидалось 1", len(list)) } if list[0].ID != id || list[0].CreatedAt == 0 || list[0].LastSeen == 0 || list[0].HasPush || !list[0].Current { t.Errorf("устройство: %+v", list[0]) } // Второе устройство — своя сессия, свой вход. current у каждой сессии // своё: сессия привязана к устройству (ADR-021). login := e.do(http.MethodPost, "/api/login", map[string]any{"nick": "marta", "authKey": bytesOf(32, 1)}) expect(t, login, http.StatusOK, "") second := e.cookie(login) e.addDevice(second, deviceOf(2)) rec = e.do(http.MethodGet, "/api/devices", nil, with(c)) decodeBody(t, rec, &list) if len(list) != 2 { t.Fatalf("устройств: получено %d, ожидалось 2", len(list)) } if !list[0].Current || list[1].Current { t.Errorf("текущее устройство первой сессии: %+v", list) } rec = e.do(http.MethodGet, "/api/devices", nil, with(second)) decodeBody(t, rec, &list) if list[0].Current || !list[1].Current { t.Errorf("текущее устройство второй сессии: %+v", list) } } // Занятый чужим идентификатор — 409: клиент берёт новый (ADR-017). func TestDeviceConflict(t *testing.T) { e := newEnv(t) marta, id := e.join("marta", 1) petya := e.signUp("petya") expect(t, e.do(http.MethodPost, "/api/devices", map[string]any{"id": id}, with(petya)), http.StatusConflict, "device_conflict") // Устройство осталось за прежним владельцем. rec := e.do(http.MethodGet, "/api/devices", nil, with(marta)) var mine []struct { ID string `json:"id"` } decodeBody(t, rec, &mine) if len(mine) != 1 || mine[0].ID != id { t.Errorf("устройства marta: %+v", mine) } rec = e.do(http.MethodGet, "/api/devices", nil, with(petya)) var theirs []struct { ID string `json:"id"` } decodeBody(t, rec, &theirs) if len(theirs) != 0 { t.Errorf("устройства petya: %+v", theirs) } } func TestDeviceIDForm(t *testing.T) { e := newEnv(t) c := e.signUp("marta") for _, id := range []any{"", "короткий", bytesOf(8, 1), bytesOf(32, 1), 42} { rec := e.do(http.MethodPost, "/api/devices", map[string]any{"id": id}, with(c)) if rec.Code == http.StatusCreated || rec.Code == http.StatusOK { t.Errorf("id %v принят: %d", id, rec.Code) } } } // X-Device чужого пользователя — 403 unknown_device на всех маршрутах, // где устройство важно (docs/protocol.md, «Общие правила»). func TestForeignDevice(t *testing.T) { e := newEnv(t) _, martaDevice := e.join("marta", 1) petya, petyaDevice := e.join("petya", 2) body := message(ulid(nowMillis(), 3), "marta") expect(t, e.do(http.MethodPost, "/api/messages", body, with(petya), withDevice(martaDevice)), http.StatusForbidden, "unknown_device") expect(t, e.do(http.MethodPost, "/api/ack", map[string]any{"ids": []string{}}, with(petya), withDevice(martaDevice)), http.StatusForbidden, "unknown_device") // Заголовка нет вовсе или в нём мусор — тот же ответ. expect(t, e.do(http.MethodPost, "/api/messages", body, with(petya)), http.StatusForbidden, "unknown_device") expect(t, e.do(http.MethodPost, "/api/messages", body, with(petya), withDevice("мусор")), http.StatusForbidden, "unknown_device") expect(t, e.do(http.MethodPost, "/api/messages", body, with(petya), withDevice(deviceOf(9))), http.StatusForbidden, "unknown_device") // Со своим устройством — обычная отправка. expect(t, e.do(http.MethodPost, "/api/messages", message(ulid(nowMillis(), 4), "marta"), with(petya), withDevice(petyaDevice)), http.StatusAccepted, "") // Комнаты: заголовок здесь необязателен — он всего лишь просит не слать // событие отправившему устройству, — но принадлежность проверяется // та же (docs/protocol.md, «Общие правила», «Комнаты»). room := map[string]any{ "id": roomIDOf(40), "name": "общая", "keyId": keyID(40), "keys": keysFor([]string{"petya"}, 40), } expect(t, e.do(http.MethodPost, "/api/rooms", room, with(petya), withDevice(martaDevice)), http.StatusForbidden, "unknown_device") expect(t, e.do(http.MethodPost, "/api/rooms", room, with(petya), withDevice("мусор")), http.StatusForbidden, "unknown_device") expect(t, e.do(http.MethodPost, "/api/rooms", room, with(petya), withDevice(deviceOf(9))), http.StatusForbidden, "unknown_device") // Отказ ничего не создал: идентификатор комнаты свободен. expect(t, e.do(http.MethodPost, "/api/rooms", room, with(petya)), http.StatusCreated, "") leave := "/api/rooms/" + roomIDOf(40) + "/leave" expect(t, e.do(http.MethodPost, leave, nil, with(petya), withDevice(martaDevice)), http.StatusForbidden, "unknown_device") expect(t, e.do(http.MethodPost, leave, nil, with(petya), withDevice("мусор")), http.StatusForbidden, "unknown_device") // Отказ ничего не изменил: из комнаты никто не вышел. if got := e.room(petya, roomIDOf(40)); got == nil { t.Fatal("комната пропала после отказа по устройству") } expect(t, e.do(http.MethodPost, leave, nil, with(petya), withDevice(petyaDevice)), http.StatusNoContent, "") } // Удаление устройства уносит очередь и сессии устройства. func TestDeleteDevice(t *testing.T) { e := newEnv(t) marta, martaDevice := e.join("marta", 1) petya, petyaDevice := e.join("petya", 2) expect(t, e.do(http.MethodPost, "/api/messages", message(ulid(nowMillis(), 3), "petya"), with(marta), withDevice(martaDevice)), http.StatusAccepted, "") if got := e.queue(petyaDevice); len(got) != 1 { t.Fatalf("очередь petya: получено %d конвертов, ожидался 1", len(got)) } // Чужое устройство удалить нельзя — и это не ошибка. expect(t, e.do(http.MethodDelete, "/api/devices/"+petyaDevice, nil, with(marta)), http.StatusNoContent, "") if got := e.queue(petyaDevice); len(got) != 1 { t.Errorf("очередь petya после чужого удаления: получено %d конвертов", len(got)) } expect(t, e.do(http.MethodDelete, "/api/devices/"+petyaDevice, nil, with(petya)), http.StatusNoContent, "") if got := e.queue(petyaDevice); len(got) != 0 { t.Errorf("очередь после удаления устройства: получено %d конвертов, ожидалось 0", len(got)) } // Сессия, привязанная к устройству, ушла каскадом. expect(t, e.do(http.MethodGet, "/api/me", nil, with(petya)), http.StatusUnauthorized, "unauthenticated") }