Этап 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:
2026-08-22 18:18:04 +03:00
co-authored by Claude Opus 5
parent 597c55301c
commit 63a7a1ef52
39 changed files with 5126 additions and 46 deletions
+377
View File
@@ -0,0 +1,377 @@
package api_test
import (
"context"
"encoding/json"
"net/http"
"strconv"
"testing"
"time"
)
// crockford — алфавит ULID (docs/crypto.md, «Идентификаторы»).
const crockford = "0123456789ABCDEFGHJKMNPQRSTVWXYZ"
func nowMillis() int64 { return time.Now().UnixMilli() }
// ulid собирает ULID с заданным временем: первые десять символов —
// 48 бит миллисекунд, остальные шестнадцать — 80 бит «случайности».
func ulid(ms int64, seed byte) string {
out := make([]byte, 26)
for i := 9; i >= 0; i-- {
out[i] = crockford[ms&31]
ms >>= 5
}
for i := 10; i < 26; i++ {
out[i] = crockford[(int(seed)+i)%32]
}
return string(out)
}
// message — тело POST /api/messages в личный чат. Шифротекст сервер
// не проверяет: ему важна только форма.
func message(id, to string) map[string]any {
return map[string]any{
"id": id,
"to": map[string]string{"dm": to},
"keyId": "dm",
"iv": bytesOf(12, 21),
"ct": bytesOf(48, 23),
}
}
// queue — очередь устройства как её видит сервер.
func (e *env) queue(device string) []string {
e.t.Helper()
got, err := e.st.Queue(context.Background(), device)
if err != nil {
e.t.Fatalf("очередь %s: %v", device, err)
}
return got
}
// envelopes разбирает конверты очереди.
func (e *env) envelopes(device string) []envelope {
e.t.Helper()
raw := e.queue(device)
out := make([]envelope, 0, len(raw))
for _, s := range raw {
var env envelope
if err := json.Unmarshal([]byte(s), &env); err != nil {
e.t.Fatalf("разбор конверта %q: %v", s, err)
}
out = append(out, env)
}
return out
}
// envelope — конверт в том виде, в каком его видит клиент.
type envelope struct {
ID string `json:"id"`
To struct {
DM string `json:"dm"`
Room string `json:"room"`
} `json:"to"`
From string `json:"from"`
KeyID string `json:"keyId"`
IV string `json:"iv"`
CT string `json:"ct"`
TS int64 `json:"ts"`
}
// Конверт уходит на все устройства обоих собеседников, кроме отправившего
// (ADR-017): мультидевайс без отдельной логики.
func TestFanout(t *testing.T) {
e := newEnv(t)
marta, m1 := e.join("marta", 1)
m2 := e.addDevice(marta, deviceOf(2))
petya, p1 := e.join("petya", 3)
p2 := e.addDevice(petya, deviceOf(4))
id := ulid(nowMillis(), 5)
rec := e.do(http.MethodPost, "/api/messages", message(id, "petya"), with(marta), withDevice(m1))
expect(t, rec, http.StatusAccepted, "")
var accepted struct {
ID string `json:"id"`
TS int64 `json:"ts"`
}
decodeBody(t, rec, &accepted)
if accepted.ID != id || accepted.TS == 0 {
t.Errorf("ответ: %+v", accepted)
}
if got := e.queue(m1); len(got) != 0 {
t.Errorf("эхо отправившему устройству: %v", got)
}
for _, device := range []string{m2, p1, p2} {
got := e.envelopes(device)
if len(got) != 1 {
t.Fatalf("очередь %s: получено %d конвертов, ожидался 1", device, len(got))
}
env := got[0]
if env.ID != id || env.From != "marta" || env.To.DM != "petya" || env.KeyID != "dm" {
t.Errorf("конверт для %s: %+v", device, env)
}
if env.IV != bytesOf(12, 21) || env.CT != bytesOf(48, 23) {
t.Errorf("шифротекст изменился: %+v", env)
}
if env.TS != accepted.TS {
t.Errorf("ts: получено %d, ожидалось %d", env.TS, accepted.TS)
}
}
}
// from ставит сервер из сессии; поле from в теле запроса не читается
// вовсе (ADR-017, модель угроз).
func TestFromComesFromSession(t *testing.T) {
e := newEnv(t)
marta, m1 := e.join("marta", 1)
_, p1 := e.join("petya", 2)
body := message(ulid(nowMillis(), 3), "petya")
body["from"] = "petya"
body["ts"] = 1
expect(t, e.do(http.MethodPost, "/api/messages", body, with(marta), withDevice(m1)), http.StatusAccepted, "")
got := e.envelopes(p1)
if len(got) != 1 {
t.Fatalf("очередь: получено %d конвертов, ожидался 1", len(got))
}
if got[0].From != "marta" {
t.Errorf("from: получено %q, ожидалось \"marta\"", got[0].From)
}
if got[0].TS == 1 {
t.Errorf("ts взят из тела запроса: %d", got[0].TS)
}
}
// ACK удаляет строки очереди только своего устройства.
func TestAck(t *testing.T) {
e := newEnv(t)
marta, m1 := e.join("marta", 1)
petya, p1 := e.join("petya", 2)
p2 := e.addDevice(petya, deviceOf(3))
first := ulid(nowMillis(), 4)
second := ulid(nowMillis()+1, 5)
for _, id := range []string{first, second} {
expect(t, e.do(http.MethodPost, "/api/messages", message(id, "petya"), with(marta), withDevice(m1)),
http.StatusAccepted, "")
}
ack := map[string]any{"ids": []string{first}}
expect(t, e.do(http.MethodPost, "/api/ack", ack, with(petya), withDevice(p1)), http.StatusNoContent, "")
left := e.envelopes(p1)
if len(left) != 1 || left[0].ID != second {
t.Errorf("очередь p1 после ack: %+v", left)
}
if got := e.queue(p2); len(got) != 2 {
t.Errorf("очередь p2: получено %d конвертов, ожидалось 2", len(got))
}
// Чужие идентификаторы и повторный ack ничего не ломают.
expect(t, e.do(http.MethodPost, "/api/ack", map[string]any{"ids": []string{first, second}},
with(petya), withDevice(p1)), http.StatusNoContent, "")
if got := e.queue(p1); len(got) != 0 {
t.Errorf("очередь p1: получено %d конвертов, ожидалось 0", len(got))
}
if got := e.queue(p2); len(got) != 2 {
t.Errorf("очередь p2 после ack чужого устройства: получено %d", len(got))
}
}
func TestAckLimit(t *testing.T) {
e := newEnv(t)
c, device := e.join("marta", 1)
ids := make([]string, 500)
for i := range ids {
ids[i] = ulid(nowMillis(), byte(i))
}
expect(t, e.do(http.MethodPost, "/api/ack", map[string]any{"ids": ids}, with(c), withDevice(device)),
http.StatusNoContent, "")
rec := e.do(http.MethodPost, "/api/ack", map[string]any{"ids": append(ids, ulid(nowMillis(), 9))},
with(c), withDevice(device))
expect(t, rec, http.StatusBadRequest, "invalid")
var field struct {
Field string `json:"field"`
}
decodeBody(t, rec, &field)
if field.Field != "ids" {
t.Errorf("field: получено %q, ожидалось \"ids\"", field.Field)
}
}
// Часы клиента врут в обе стороны одинаково плохо (ADR-017).
func TestClockSkew(t *testing.T) {
e := newEnv(t)
marta, m1 := e.join("marta", 1)
e.join("petya", 2)
minute := int64(60 * 1000)
for _, shift := range []int64{-6 * minute, 6 * minute, -24 * 60 * minute, 24 * 60 * minute} {
body := message(ulid(nowMillis()+shift, 3), "petya")
expect(t, e.do(http.MethodPost, "/api/messages", body, with(marta), withDevice(m1)),
http.StatusBadRequest, "clock_skew")
}
// В пределах пяти минут — принимается.
for _, shift := range []int64{-4 * minute, 4 * minute} {
body := message(ulid(nowMillis()+shift, 4), "petya")
expect(t, e.do(http.MethodPost, "/api/messages", body, with(marta), withDevice(m1)),
http.StatusAccepted, "")
}
}
// Строки contacts заводятся в обе стороны при первом сообщении (ADR-019):
// новое устройство видит список чатов без истории.
func TestContactsFromFirstMessage(t *testing.T) {
e := newEnv(t)
marta, m1 := e.join("marta", 1)
petya, _ := e.join("petya", 2)
if got := e.contacts(marta); len(got) != 0 {
t.Fatalf("контакты до первого сообщения: %+v", got)
}
expect(t, e.do(http.MethodPost, "/api/messages", message(ulid(nowMillis(), 3), "petya"),
with(marta), withDevice(m1)), http.StatusAccepted, "")
mine := e.contacts(marta)
if len(mine) != 1 || mine[0].Nick != "petya" || mine[0].CreatedAt == 0 {
t.Errorf("контакты marta: %+v", mine)
}
theirs := e.contacts(petya)
if len(theirs) != 1 || theirs[0].Nick != "marta" {
t.Errorf("контакты petya: %+v", theirs)
}
if len(theirs[0].PublicKey) == 0 {
t.Errorf("в контакте нет публичного ключа: %+v", theirs[0])
}
// Второе сообщение ничего не удваивает.
expect(t, e.do(http.MethodPost, "/api/messages", message(ulid(nowMillis(), 4), "petya"),
with(marta), withDevice(m1)), http.StatusAccepted, "")
if got := e.contacts(marta); len(got) != 1 {
t.Errorf("контакты marta после второго сообщения: %+v", got)
}
}
// Повтор POST с тем же id не ломает запрос: сервер историю идентификаторов
// не хранит, склеивает клиент (ADR-017).
func TestRepeatedMessageID(t *testing.T) {
e := newEnv(t)
marta, m1 := e.join("marta", 1)
_, p1 := e.join("petya", 2)
id := ulid(nowMillis(), 3)
for i := 0; i < 3; i++ {
expect(t, e.do(http.MethodPost, "/api/messages", message(id, "petya"), with(marta), withDevice(m1)),
http.StatusAccepted, "")
}
if got := e.queue(p1); len(got) != 1 {
t.Errorf("очередь: получено %d конвертов, ожидался 1", len(got))
}
}
func TestMessageRejects(t *testing.T) {
cases := []struct {
name string
change func(map[string]any)
status int
code string
field string
}{
{"id не ulid", func(m map[string]any) { m["id"] = "не ulid" }, http.StatusBadRequest, "invalid", "id"},
{"строчный ulid", func(m map[string]any) {
m["id"] = "01hqzz0000zzzzzzzzzzzzzzzz"
}, http.StatusBadRequest, "invalid", "id"},
{"буква вне алфавита", func(m map[string]any) {
m["id"] = "0" + "I" + ulid(nowMillis(), 1)[2:]
}, http.StatusBadRequest, "invalid", "id"},
{"нет адресата", func(m map[string]any) { delete(m, "to") }, http.StatusBadRequest, "invalid", "to"},
{"оба адресата", func(m map[string]any) {
m["to"] = map[string]string{"dm": "petya", "room": bytesOf(16, 1)}
}, http.StatusBadRequest, "invalid", "to"},
{"кривой ник", func(m map[string]any) {
m["to"] = map[string]string{"dm": "МАРТА"}
}, http.StatusBadRequest, "invalid", "to"},
{"чужой keyId в личном чате", func(m map[string]any) {
m["keyId"] = bytesOf(16, 1)
}, http.StatusBadRequest, "invalid", "keyId"},
{"iv не 12 байт", func(m map[string]any) { m["iv"] = bytesOf(16, 21) }, http.StatusBadRequest, "invalid", "iv"},
{"короткий ct", func(m map[string]any) { m["ct"] = bytesOf(8, 23) }, http.StatusBadRequest, "invalid", "ct"},
{"ct не base64url", func(m map[string]any) { m["ct"] = "!!!" }, http.StatusBadRequest, "invalid", "ct"},
{"неизвестный ник", func(m map[string]any) {
m["to"] = map[string]string{"dm": "kolya"}
}, http.StatusNotFound, "unknown_user", ""},
{"себе", func(m map[string]any) {
m["to"] = map[string]string{"dm": "marta"}
}, http.StatusBadRequest, "self", ""},
// Комнаты — этап 3; членства нет ни у кого.
{"в комнату", func(m map[string]any) {
m["to"] = map[string]string{"room": bytesOf(16, 1)}
m["keyId"] = bytesOf(16, 2)
}, http.StatusForbidden, "not_member", ""},
{"кривой roomId", func(m map[string]any) {
m["to"] = map[string]string{"room": "нет"}
}, http.StatusBadRequest, "invalid", "to"},
{"кривой keyId комнаты", func(m map[string]any) {
m["to"] = map[string]string{"room": bytesOf(16, 1)}
m["keyId"] = "dm"
}, http.StatusBadRequest, "invalid", "keyId"},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
e := newEnv(t)
marta, m1 := e.join("marta", 1)
_, p1 := e.join("petya", 2)
body := message(ulid(nowMillis(), 3), "petya")
c.change(body)
rec := e.do(http.MethodPost, "/api/messages", body, with(marta), withDevice(m1))
expect(t, rec, c.status, c.code)
if c.field != "" {
var got struct {
Field string `json:"field"`
}
decodeBody(t, rec, &got)
if got.Field != c.field {
t.Errorf("field: получено %q, ожидалось %q", got.Field, c.field)
}
}
if got := e.queue(p1); len(got) != 0 {
t.Errorf("отвергнутое сообщение попало в очередь: %v", got)
}
})
}
}
// Лимит сообщений — 30 в минуту на пользователя, пакет 10 (ADR-021).
func TestMessageRateLimit(t *testing.T) {
e := newEnv(t)
marta, m1 := e.join("marta", 1)
e.join("petya", 2)
for i := 0; i < 10; i++ {
expect(t, e.do(http.MethodPost, "/api/messages", message(ulid(nowMillis(), byte(i)), "petya"),
with(marta), withDevice(m1)), http.StatusAccepted, "")
}
rec := e.do(http.MethodPost, "/api/messages", message(ulid(nowMillis(), 11), "petya"), with(marta), withDevice(m1))
expect(t, rec, http.StatusTooManyRequests, "rate_limited")
// Токен набегает раз в две секунды: пакет кончился, ждать до двух.
after, err := strconv.Atoi(rec.Header().Get("Retry-After"))
if err != nil || after < 1 || after > 2 {
t.Errorf("Retry-After: получено %q", rec.Header().Get("Retry-After"))
}
// Лимит на пользователе, а не на устройстве.
m2 := e.addDevice(marta, deviceOf(3))
expect(t, e.do(http.MethodPost, "/api/messages", message(ulid(nowMillis(), 12), "petya"),
with(marta), withDevice(m2)), http.StatusTooManyRequests, "rate_limited")
// Другому пользователю чужой лимит не мешает.
petya, p1 := e.join("kolya", 4)
expect(t, e.do(http.MethodPost, "/api/messages", message(ulid(nowMillis(), 13), "marta"),
with(petya), withDevice(p1)), http.StatusAccepted, "")
}