Этап 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,121 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Token bucket из ADR-021: 30 в минуту, пакет 10.
|
||||
func TestBuckets(t *testing.T) {
|
||||
b := newBuckets(messagesPerMinute, messagesBurst)
|
||||
now := time.Now()
|
||||
|
||||
for i := 0; i < messagesBurst; i++ {
|
||||
if _, ok := b.take("marta", now); !ok {
|
||||
t.Fatalf("запрос %d из пакета отклонён", i+1)
|
||||
}
|
||||
}
|
||||
wait, ok := b.take("marta", now)
|
||||
if ok {
|
||||
t.Fatal("пакет не кончился")
|
||||
}
|
||||
// Тридцать в минуту — токен раз в две секунды.
|
||||
if wait != 2*time.Second {
|
||||
t.Errorf("ожидание: получено %v, ожидалось 2s", wait)
|
||||
}
|
||||
if got := retryAfter(wait); got != 2 {
|
||||
t.Errorf("Retry-After: получено %d, ожидалось 2", got)
|
||||
}
|
||||
|
||||
// Через две секунды набегает ровно один токен.
|
||||
if _, ok := b.take("marta", now.Add(2*time.Second)); !ok {
|
||||
t.Error("токен не набежал")
|
||||
}
|
||||
if _, ok := b.take("marta", now.Add(2*time.Second)); ok {
|
||||
t.Error("набежало больше одного токена")
|
||||
}
|
||||
|
||||
// Ведро не переполняется: за час копится пакет, не тридцать в минуту.
|
||||
for i := 0; i < messagesBurst; i++ {
|
||||
if _, ok := b.take("marta", now.Add(time.Hour)); !ok {
|
||||
t.Fatalf("запрос %d после долгой паузы отклонён", i+1)
|
||||
}
|
||||
}
|
||||
if _, ok := b.take("marta", now.Add(time.Hour)); ok {
|
||||
t.Error("ведро больше пакета")
|
||||
}
|
||||
|
||||
// Лимит на ключ: чужое ведро полное.
|
||||
if _, ok := b.take("petya", now); !ok {
|
||||
t.Error("лимит одного пользователя задел другого")
|
||||
}
|
||||
}
|
||||
|
||||
// Часы могут прыгнуть назад; долг за это никому не выставляется.
|
||||
func TestBucketsClockBack(t *testing.T) {
|
||||
b := newBuckets(messagesPerMinute, messagesBurst)
|
||||
now := time.Now()
|
||||
|
||||
for i := 0; i < messagesBurst; i++ {
|
||||
b.take("marta", now)
|
||||
}
|
||||
if _, ok := b.take("marta", now.Add(-time.Hour)); ok {
|
||||
t.Error("время назад добавило токенов")
|
||||
}
|
||||
}
|
||||
|
||||
// Полные вёдра выкидываются: карта не растёт на каждый ник навсегда.
|
||||
func TestBucketsSweep(t *testing.T) {
|
||||
b := newBuckets(messagesPerMinute, messagesBurst)
|
||||
now := time.Now()
|
||||
|
||||
for i := 0; i < sweepAt; i++ {
|
||||
b.take(string(rune(i)), now)
|
||||
}
|
||||
if len(b.seen) != sweepAt {
|
||||
t.Fatalf("вёдер: получено %d, ожидалось %d", len(b.seen), sweepAt)
|
||||
}
|
||||
// Все вёдра успели наполниться заново — чистка их и уносит.
|
||||
b.take("marta", now.Add(time.Hour))
|
||||
if len(b.seen) != 1 {
|
||||
t.Errorf("вёдер после чистки: получено %d, ожидалось 1", len(b.seen))
|
||||
}
|
||||
}
|
||||
|
||||
// Ждать меньше секунды бессмысленно: Retry-After в секундах.
|
||||
func TestRetryAfter(t *testing.T) {
|
||||
cases := map[time.Duration]int{
|
||||
0: 1,
|
||||
100 * time.Millisecond: 1,
|
||||
time.Second: 1,
|
||||
1500 * time.Millisecond: 2,
|
||||
2 * time.Second: 2,
|
||||
}
|
||||
for wait, want := range cases {
|
||||
if got := retryAfter(wait); got != want {
|
||||
t.Errorf("retryAfter(%v): получено %d, ожидалось %d", wait, got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ULID: 26 символов Crockford base32, время в первых десяти.
|
||||
func TestULIDTime(t *testing.T) {
|
||||
// 01ARZ3NDEK — 2016-07-30T23:54:10.259Z.
|
||||
ms, ok := ulidTime("01ARZ3NDEKTSV4RRFFQ69G5FAV")
|
||||
if !ok || ms != 1469922850259 {
|
||||
t.Errorf("ulidTime: получено %d, %v; ожидалось 1469922850259", ms, ok)
|
||||
}
|
||||
for _, id := range []string{
|
||||
"",
|
||||
"01ARZ3NDEKTSV4RRFFQ69G5FA", // 25 символов
|
||||
"01ARZ3NDEKTSV4RRFFQ69G5FAVX", // 27 символов
|
||||
"01arz3ndektsv4rrffq69g5fav", // строчные
|
||||
"01ARZ3NDEKTSV4RRFFQ69G5FAU", // U вне алфавита Crockford
|
||||
"81ARZ3NDEKTSV4RRFFQ69G5FAV", // время больше 48 бит
|
||||
"01ARZ3NDEKTSV4RRFFQ69G5F☺",
|
||||
} {
|
||||
if _, ok := ulidTime(id); ok {
|
||||
t.Errorf("принят кривой ulid %q", id)
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user