Этап 2: чат 1:1 — устройства, очередь, SSE, шифрование сообщений
Сервер: регистрация устройств и X-Device, hub с одним потоком на устройство, очередь per-device с фан-аутом без эха отправителю, POST /api/messages с проверками в порядке protocol.md, ACK, SSE с воспроизведением очереди, ready и пингом раз в 20 секунд, контакты в обе стороны при первом сообщении, лимит 30 сообщений в минуту. Клиент: ULID, ключ 1:1 из ECDH через HKDF, шифрование конверта с AAD, sync.js как единственный писатель в IndexedDB, ACK строго после записи, список чатов, экран чата по эталону, разделители дат и «новые», pending и failed с повтором, полоса «нет соединения». ADR-033: у неотправленного есть текст отказа — clock_skew стало видно. ADR-034: входящее с известным id не перезаписывает запись. Собеседник знает открытый id конверта и подменял им чужое сообщение в чужой истории — вплоть до стирания своего присланного, чего «удалить у всех не существует» не допускает. ADR-035: один поток событий на браузерный профиль (locks + BroadcastChannel): две вкладки отбирали поток друг у друга и оставались без живой доставки. ADR-036: повтор отправки сохраняет ULID, пока он в пределах окна часов, — иначе потерянный ответ давал у собеседника два сообщения вместо одного. Приёмка на боевом сервере: два аккаунта, пять устройств, живая доставка, копия на второе устройство, очередь офлайн-устройству, ACK, подмена from игнорируется, чужой deviceId и запрос без Origin отбиваются, плейнтекста в базе и WAL ноль вхождений. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015DbCjVfTFq4ZFG8juD45YJ
This commit is contained in:
@@ -0,0 +1,180 @@
|
||||
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)
|
||||
}
|
||||
|
||||
// Push-подписка — этап 4: пути ещё нет, а неизвестный путь отвечает
|
||||
// 404 not_found (ADR-026).
|
||||
expect(t, e.do(http.MethodPut, "/api/devices/"+id+"/push", map[string]any{}, with(c)),
|
||||
http.StatusNotFound, "not_found")
|
||||
}
|
||||
|
||||
// Занятый чужим идентификатор — 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, "")
|
||||
}
|
||||
|
||||
// Удаление устройства уносит очередь и сессии устройства.
|
||||
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")
|
||||
}
|
||||
Reference in New Issue
Block a user