Files
bare/internal/api/rooms_test.go
T
mayatnikovandClaude Opus 5 8f67f4aa4d Этап 4: PWA и пуши — service worker, Web Push с VAPID, баннер установки
Сервер: пакет push на webpush-go (третья и последняя прямая зависимость),
подписка устройства, правило ADR-023 «пуш только молчащему устройству и только
один» через атомарный захват push_pending, удаление подписки на 404 и 410,
TTL сутки, urgency normal. В нагрузке только {title, body, chat} — тело
собирается из константы, плейнтекст туда не попадает даже по ошибке.

Клиент: service worker с версионированным кэшем оболочки и никогда — /api/*,
push и notificationclick, подписка на VAPID-ключ сервера, запрос разрешения
после первого отправленного сообщения, разделы настроек «уведомления»
и «установить приложение», баннер установки на iOS.

ADR-045: пуш адресован получателю — по букве ADR-023 он уходил бы и молчащему
устройству отправителя с бессмысленным заголовком из собственного ника.
ADR-047: сервер ходит на endpoint подписки, который выбирает браузер. Проверка
«только https» обходилась редиректом, а имя могло смотреть внутрь сети — теперь
запрет редиректов и проверка разрешённого адреса на уровне сокета.
ADR-048: пределы отправки — недоступный push-сервис одного аккаунта больше
не съедает пуши всего сервера.
ADR-049: явно выключенные уведомления сами не включаются обратно.

Попутно: webpush-go дописывает набивку в переданный срез, а одна нагрузка
уходила всем устройствам сообщения — гонка, пойманная go test -race.
Теперь у каждого задания своя копия.

Приёмка на боевом: подписки, hasPush, чужое устройство, Origin, оболочка
из девяти файлов, Service-Worker-Allowed. Отдельно шесть непубличных адресов
и endpoint на 3 КиБ — все отбиты.

Чеклист ручной проверки на iPhone и Android — в docs/plan.md.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015DbCjVfTFq4ZFG8juD45YJ
2026-08-23 00:50:58 +03:00

1049 lines
43 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package api_test
import (
"encoding/json"
"net/http"
"net/http/httptest"
"sort"
"strings"
"testing"
"time"
)
// roomBody — тип Room из docs/protocol.md, как его видит клиент.
type roomBody struct {
ID string `json:"id"`
Name string `json:"name"`
Owner string `json:"owner"`
Members []string `json:"members"`
CreatedAt int64 `json:"createdAt"`
Key *keyBody `json:"key"`
NeedsRekey bool `json:"needsRekey"`
}
type keyBody struct {
KeyID string `json:"keyId"`
From string `json:"from"`
IV string `json:"iv"`
CT string `json:"ct"`
}
// keyID — идентификатор ключа комнаты: 16 байт base64url (docs/crypto.md).
func keyID(seed byte) string { return bytesOf(16, seed) }
// roomIDOf — идентификатор комнаты: его генерирует клиент (ADR-037).
func roomIDOf(seed byte) string { return bytesOf(16, seed+100) }
// wrapped — завёрнутый ключ участнику. Порядковый номер входит в «шифротекст»:
// по нему видно, что каждому ушёл его собственный ключ. Содержимое сервер
// не проверяет и проверить не может.
func wrapped(to string, seed byte, nth int) map[string]any {
return map[string]any{"to": to, "iv": ivOf(seed, nth), "ct": ctOf(seed, nth)}
}
func ivOf(seed byte, nth int) string { return bytesOf(12, seed+byte(nth)) }
func ctOf(seed byte, nth int) string { return bytesOf(48, seed+byte(nth)) }
func keysFor(to []string, seed byte) []any {
out := make([]any, 0, len(to))
for i, nick := range to {
out = append(out, wrapped(nick, seed, i))
}
return out
}
// makeRoom заводит комнату: состав — один создатель, ключ ровно один.
func (e *env) makeRoom(c *http.Cookie, owner, name string, seed byte, opts ...func(*http.Request)) roomBody {
e.t.Helper()
body := map[string]any{
"id": roomIDOf(seed),
"name": name,
"keyId": keyID(seed),
"keys": keysFor([]string{owner}, seed),
}
rec := e.do(http.MethodPost, "/api/rooms", body, append([]func(*http.Request){with(c)}, opts...)...)
expect(e.t, rec, http.StatusCreated, "")
var room roomBody
decodeBody(e.t, rec, &room)
return room
}
// changeMembers — смена состава и rekey одним запросом: to — итоговый
// состав, которому заворачивается новый ключ.
func (e *env) changeMembers(c *http.Cookie, id string, add, remove, to []string, seed byte) *httptest.ResponseRecorder {
e.t.Helper()
body := map[string]any{
"add": add,
"remove": remove,
"keyId": keyID(seed),
"keys": keysFor(to, seed),
}
return e.do(http.MethodPost, "/api/rooms/"+id+"/members", body, with(c))
}
func (e *env) rooms(c *http.Cookie) []roomBody {
e.t.Helper()
rec := e.do(http.MethodGet, "/api/rooms", nil, with(c))
expect(e.t, rec, http.StatusOK, "")
var out []roomBody
decodeBody(e.t, rec, &out)
return out
}
// room — комната из списка; nil, если её там нет.
func (e *env) room(c *http.Cookie, id string) *roomBody {
e.t.Helper()
for _, got := range e.rooms(c) {
if got.ID == id {
room := got
return &room
}
}
return nil
}
// roomMessage — тело POST /api/messages в комнату.
func roomMessage(id, room, key string) map[string]any {
return map[string]any{
"id": id,
"to": map[string]string{"room": room},
"keyId": key,
"iv": bytesOf(12, 21),
"ct": bytesOf(48, 23),
}
}
// nicks — состав как множество: joined_at у сервера в миллисекундах,
// и две операции подряд попадают в одну и ту же. Порядок по joined_at
// проверяет TestMembersJoinOrder, где операции разведены во времени.
func nicks(members []string) string {
sorted := append([]string(nil), members...)
sort.Strings(sorted)
return strings.Join(sorted, ",")
}
// tick разводит операции по разным миллисекундам.
func tick() { time.Sleep(2 * time.Millisecond) }
// field достаёт поле, на котором остановилась валидация.
func field(t *testing.T, rec *httptest.ResponseRecorder) string {
t.Helper()
var body struct {
Field string `json:"field"`
}
decodeBody(t, rec, &body)
return body.Field
}
func TestCreateRoom(t *testing.T) {
e := newEnv(t)
marta, _ := e.join("marta", 1)
room := e.makeRoom(marta, "marta", "общая", 40)
if room.ID != roomIDOf(40) {
t.Errorf("id комнаты: получено %q, ожидалось %q", room.ID, roomIDOf(40))
}
if room.Name != "общая" || room.Owner != "marta" || room.CreatedAt == 0 {
t.Errorf("комната: %+v", room)
}
if len(room.Members) != 1 || room.Members[0] != "marta" {
t.Errorf("состав: %v", room.Members)
}
if room.NeedsRekey {
t.Error("needsRekey в ответе на создание")
}
if room.Key == nil || room.Key.KeyID != keyID(40) || room.Key.From != "marta" ||
room.Key.IV != ivOf(40, 0) || room.Key.CT != ctOf(40, 0) {
t.Errorf("ключ: %+v", room.Key)
}
// Та же комната приходит списком, с тем же ключом.
list := e.rooms(marta)
if len(list) != 1 {
t.Fatalf("комнат: получено %d, ожидалась 1", len(list))
}
if list[0].ID != room.ID || list[0].Key == nil || list[0].Key.CT != ctOf(40, 0) {
t.Errorf("список комнат: %+v", list[0])
}
// Вторая комната — другой идентификатор.
other := e.makeRoom(marta, "marta", "вторая", 60)
if other.ID == room.ID {
t.Error("идентификаторы комнат совпали")
}
if got := e.rooms(marta); len(got) != 2 {
t.Errorf("комнат: получено %d, ожидалось 2", len(got))
}
// Чужому комната не видна.
petya, _ := e.join("petya", 2)
if got := e.rooms(petya); len(got) != 0 {
t.Errorf("чужие комнаты: %+v", got)
}
}
// Занятый идентификатор комнаты не присоединяет к чужой и не перезаписывает
// свою: клиент берёт новый (ADR-037).
func TestCreateRoomConflict(t *testing.T) {
e := newEnv(t)
marta, _ := e.join("marta", 1)
petya, _ := e.join("petya", 2)
room := e.makeRoom(marta, "marta", "общая", 40)
taken := func(c *http.Cookie, owner string) {
t.Helper()
body := map[string]any{
"id": room.ID,
"name": "чужая",
"keyId": keyID(60),
"keys": keysFor([]string{owner}, 60),
}
expect(t, e.do(http.MethodPost, "/api/rooms", body, with(c)), http.StatusConflict, "room_conflict")
}
taken(petya, "petya")
taken(marta, "marta")
if got := e.rooms(petya); len(got) != 0 {
t.Errorf("занятый id присоединил к чужой комнате: %+v", got)
}
list := e.rooms(marta)
if len(list) != 1 || list[0].Name != "общая" || list[0].Key == nil || list[0].Key.KeyID != keyID(40) {
t.Errorf("занятый id тронул существующую комнату: %+v", list)
}
}
func TestCreateRoomRejects(t *testing.T) {
cases := []struct {
name string
change func(map[string]any)
status int
code string
field string
}{
{"нет id", func(m map[string]any) { delete(m, "id") }, http.StatusBadRequest, "invalid", "id"},
{"кривой id", func(m map[string]any) { m["id"] = "room-1" }, http.StatusBadRequest, "invalid", "id"},
{"пустое имя", func(m map[string]any) { m["name"] = "" }, http.StatusBadRequest, "invalid", "name"},
{"имя длиннее 64", func(m map[string]any) {
m["name"] = strings.Repeat("я", 65)
}, http.StatusBadRequest, "invalid", "name"},
// Имя уходит в заголовок системного уведомления (ADR-045):
// ни перевода строки, ни разворота текста в нём быть не должно.
{"имя с переводом строки", func(m map[string]any) {
m["name"] = "общая\nсрочно: перезагрузите телефон"
}, http.StatusBadRequest, "invalid", "name"},
{"имя с bidi", func(m map[string]any) {
m["name"] = "общая\u202eяандекс"
}, http.StatusBadRequest, "invalid", "name"},
{"имя из пробелов", func(m map[string]any) {
m["name"] = " "
}, http.StatusBadRequest, "invalid", "name"},
{"кривой keyId", func(m map[string]any) { m["keyId"] = "dm" }, http.StatusBadRequest, "invalid", "keyId"},
{"нет ключа", func(m map[string]any) { m["keys"] = []any{} }, http.StatusBadRequest, "keys_mismatch", ""},
{"ключ чужому", func(m map[string]any) {
m["keys"] = keysFor([]string{"petya"}, 40)
}, http.StatusBadRequest, "keys_mismatch", ""},
{"два ключа", func(m map[string]any) {
m["keys"] = keysFor([]string{"marta", "petya"}, 40)
}, http.StatusBadRequest, "keys_mismatch", ""},
{"кривой iv", func(m map[string]any) {
m["keys"] = []any{map[string]any{"to": "marta", "iv": bytesOf(16, 1), "ct": ctOf(40, 0)}}
}, http.StatusBadRequest, "invalid", "keys"},
{"короткий ct", func(m map[string]any) {
m["keys"] = []any{map[string]any{"to": "marta", "iv": ivOf(40, 0), "ct": bytesOf(8, 1)}}
}, http.StatusBadRequest, "invalid", "keys"},
{"кривой ник в ключе", func(m map[string]any) {
m["keys"] = []any{map[string]any{"to": "МАРТА", "iv": ivOf(40, 0), "ct": ctOf(40, 0)}}
}, http.StatusBadRequest, "invalid", "keys"},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
e := newEnv(t)
marta, _ := e.join("marta", 1)
e.join("petya", 2)
body := map[string]any{
"id": roomIDOf(40),
"name": "общая",
"keyId": keyID(40),
"keys": keysFor([]string{"marta"}, 40),
}
c.change(body)
rec := e.do(http.MethodPost, "/api/rooms", body, with(marta))
expect(t, rec, c.status, c.code)
if c.field != "" {
if got := field(t, rec); got != c.field {
t.Errorf("field: получено %q, ожидалось %q", got, c.field)
}
}
if got := e.rooms(marta); len(got) != 0 {
t.Errorf("отвергнутое создание завело комнату: %+v", got)
}
})
}
}
// Смена состава и rekey — один запрос: у каждого участника свой ключ
// (ADR-018).
func TestMembersAdd(t *testing.T) {
e := newEnv(t)
marta, _ := e.join("marta", 1)
petya, _ := e.join("petya", 2)
kolya, _ := e.join("kolya", 3)
room := e.makeRoom(marta, "marta", "общая", 40)
rec := e.changeMembers(marta, room.ID, []string{"petya", "kolya"}, nil,
[]string{"marta", "petya", "kolya"}, 60)
expect(t, rec, http.StatusOK, "")
var got roomBody
decodeBody(t, rec, &got)
want := []string{"marta", "petya", "kolya"}
if nicks(got.Members) != nicks(want) {
t.Errorf("состав: получено %v, ожидалось %v", got.Members, want)
}
if got.Key == nil || got.Key.KeyID != keyID(60) || got.Key.CT != ctOf(60, 0) {
t.Errorf("ключ владельца в ответе: %+v", got.Key)
}
// Каждый видит комнату со своим ключом.
for i, c := range []*http.Cookie{marta, petya, kolya} {
list := e.rooms(c)
if len(list) != 1 {
t.Fatalf("комнат у %d: получено %d, ожидалась 1", i, len(list))
}
if list[0].Owner != "marta" || nicks(list[0].Members) != nicks(want) {
t.Errorf("комната у %d: %+v", i, list[0])
}
if list[0].Key == nil || list[0].Key.KeyID != keyID(60) || list[0].Key.From != "marta" ||
list[0].Key.CT != ctOf(60, i) {
t.Errorf("ключ у %d: %+v", i, list[0].Key)
}
}
// Повторное добавление участника ничего не меняет.
expect(t, e.changeMembers(marta, room.ID, []string{"petya"}, nil, want, 80), http.StatusOK, "")
if got := e.room(marta, room.ID); nicks(got.Members) != nicks(want) {
t.Errorf("состав после повторного добавления: %v", got.Members)
}
}
// Состав идёт по joined_at: кто вступил раньше, тот и раньше в списке
// (docs/protocol.md, «Типы»). Повторное добавление участника его не двигает.
func TestMembersJoinOrder(t *testing.T) {
e := newEnv(t)
marta, _ := e.join("marta", 1)
e.join("petya", 2)
e.join("kolya", 3)
room := e.makeRoom(marta, "marta", "общая", 40)
tick()
expect(t, e.changeMembers(marta, room.ID, []string{"kolya"}, nil, []string{"marta", "kolya"}, 60),
http.StatusOK, "")
tick()
expect(t, e.changeMembers(marta, room.ID, []string{"petya"}, nil, []string{"marta", "kolya", "petya"}, 80),
http.StatusOK, "")
tick()
expect(t, e.changeMembers(marta, room.ID, []string{"kolya"}, nil, []string{"marta", "kolya", "petya"}, 100),
http.StatusOK, "")
want := "marta,kolya,petya"
if got := e.room(marta, room.ID); strings.Join(got.Members, ",") != want {
t.Errorf("состав: получено %v, ожидалось %q", got.Members, want)
}
}
// Множество keys[].to обязано равняться итоговому составу; отказ не меняет
// ни состава, ни ключей (docs/protocol.md, «Комнаты»).
func TestMembersKeysMismatch(t *testing.T) {
e := newEnv(t)
marta, m1 := e.join("marta", 1)
e.join("petya", 2)
kolya, _ := e.join("kolya", 3)
room := e.makeRoom(marta, "marta", "общая", 40)
cases := []struct {
name string
add []string
to []string
}{
{"ключ не всем", []string{"petya"}, []string{"marta"}},
{"ключ лишнему", []string{"petya"}, []string{"marta", "petya", "kolya"}},
{"ключ вместо участника", []string{"petya"}, []string{"marta", "kolya"}},
{"ключей нет вовсе", []string{"petya"}, nil},
{"чистый rekey без себя", nil, []string{"petya"}},
}
for i, c := range cases {
t.Run(c.name, func(t *testing.T) {
rec := e.changeMembers(marta, room.ID, c.add, nil, c.to, byte(60+i*10))
expect(t, rec, http.StatusBadRequest, "keys_mismatch")
})
}
// Ни один отказ не изменил ни состава, ни ключа.
got := e.room(marta, room.ID)
if len(got.Members) != 1 || got.Members[0] != "marta" {
t.Errorf("состав после отказов: %v", got.Members)
}
if got.Key == nil || got.Key.KeyID != keyID(40) {
t.Errorf("ключ после отказов: %+v", got.Key)
}
if list := e.rooms(kolya); len(list) != 0 {
t.Errorf("комната у постороннего: %+v", list)
}
// Комната по-прежнему работает на старом ключе.
expect(t, e.do(http.MethodPost, "/api/messages", roomMessage(ulid(nowMillis(), 5), room.ID, keyID(40)),
with(marta), withDevice(m1)), http.StatusAccepted, "")
}
// keyId обязан быть новым для комнаты: повтор — 409, и запрос не проходит
// целиком (docs/protocol.md, «Комнаты»).
func TestMembersKeyExists(t *testing.T) {
e := newEnv(t)
marta, _ := e.join("marta", 1)
e.join("petya", 2)
room := e.makeRoom(marta, "marta", "общая", 40)
// Тот же keyId, что у ключа при создании.
rec := e.changeMembers(marta, room.ID, []string{"petya"}, nil, []string{"marta", "petya"}, 40)
expect(t, rec, http.StatusConflict, "key_exists")
// Состав не изменился: проверки идут до записи.
got := e.room(marta, room.ID)
if len(got.Members) != 1 || got.Members[0] != "marta" {
t.Errorf("состав после key_exists: %v", got.Members)
}
// Новый keyId проходит, а повтор уже его — снова 409.
expect(t, e.changeMembers(marta, room.ID, []string{"petya"}, nil, []string{"marta", "petya"}, 60),
http.StatusOK, "")
expect(t, e.changeMembers(marta, room.ID, nil, nil, []string{"marta", "petya"}, 60),
http.StatusConflict, "key_exists")
}
func TestMembersRejects(t *testing.T) {
e := newEnv(t)
marta, _ := e.join("marta", 1)
petya, _ := e.join("petya", 2)
e.join("kolya", 3)
room := e.makeRoom(marta, "marta", "общая", 40)
expect(t, e.changeMembers(marta, room.ID, []string{"petya"}, nil, []string{"marta", "petya"}, 60),
http.StatusOK, "")
// Не владелец — 403 not_owner, хоть участник, хоть посторонний.
expect(t, e.changeMembers(petya, room.ID, nil, nil, []string{"marta", "petya"}, 80),
http.StatusForbidden, "not_owner")
kolya, _ := e.join("kolya2", 4)
expect(t, e.changeMembers(kolya, room.ID, nil, nil, []string{"marta", "petya"}, 80),
http.StatusForbidden, "not_owner")
// Несуществующая комната неотличима от чужой.
expect(t, e.changeMembers(marta, bytesOf(16, 9), nil, nil, []string{"marta"}, 80),
http.StatusForbidden, "not_owner")
expect(t, e.changeMembers(marta, "мусор", nil, nil, []string{"marta"}, 80),
http.StatusForbidden, "not_owner")
// Владельца убрать нельзя.
expect(t, e.changeMembers(marta, room.ID, nil, []string{"marta"}, []string{"petya"}, 80),
http.StatusBadRequest, "owner")
// Несуществующий ник в add — unknown_user; ник не по форме — invalid
// с полем add, как и в remove (ADR-043).
expect(t, e.changeMembers(marta, room.ID, []string{"nikogo"}, nil, []string{"marta", "petya", "nikogo"}, 80),
http.StatusNotFound, "unknown_user")
rec := e.changeMembers(marta, room.ID, []string{"МАРТА"}, nil, []string{"marta", "petya"}, 80)
expect(t, rec, http.StatusBadRequest, "invalid")
if got := field(t, rec); got != "add" {
t.Errorf("field: получено %q, ожидалось \"add\"", got)
}
// Убрать можно только участника.
rec = e.changeMembers(marta, room.ID, nil, []string{"kolya"}, []string{"marta", "petya"}, 80)
expect(t, rec, http.StatusBadRequest, "invalid")
if got := field(t, rec); got != "remove" {
t.Errorf("field: получено %q, ожидалось \"remove\"", got)
}
// Один ник в add и remove сразу — противоречие.
rec = e.changeMembers(marta, room.ID, []string{"kolya"}, []string{"kolya"}, []string{"marta", "petya"}, 80)
expect(t, rec, http.StatusBadRequest, "invalid")
// Ни один отказ не тронул состав.
got := e.room(marta, room.ID)
if nicks(got.Members) != "marta,petya" {
t.Errorf("состав после отказов: %v", got.Members)
}
}
// Убранный участник теряет комнату, ключи и доставку.
func TestMembersRemove(t *testing.T) {
e := newEnv(t)
marta, m1 := e.join("marta", 1)
petya, p1 := e.join("petya", 2)
room := e.makeRoom(marta, "marta", "общая", 40)
expect(t, e.changeMembers(marta, room.ID, []string{"petya"}, nil, []string{"marta", "petya"}, 60),
http.StatusOK, "")
expect(t, e.changeMembers(marta, room.ID, nil, []string{"petya"}, []string{"marta"}, 80),
http.StatusOK, "")
if got := e.rooms(petya); len(got) != 0 {
t.Errorf("комната у убранного: %+v", got)
}
if got := e.room(marta, room.ID); len(got.Members) != 1 || got.Members[0] != "marta" {
t.Errorf("состав: %+v", got)
}
// Убранный не пишет и не получает.
expect(t, e.do(http.MethodPost, "/api/messages", roomMessage(ulid(nowMillis(), 5), room.ID, keyID(80)),
with(petya), withDevice(p1)), http.StatusForbidden, "not_member")
expect(t, e.do(http.MethodPost, "/api/messages", roomMessage(ulid(nowMillis(), 6), room.ID, keyID(80)),
with(marta), withDevice(m1)), http.StatusAccepted, "")
if got := e.queue(p1); len(got) != 0 {
t.Errorf("убранному пришло сообщение: %v", got)
}
}
// Выход владельца передаёт владение участнику с наименьшим joined_at
// (ADR-018).
func TestLeaveTransfersOwnership(t *testing.T) {
e := newEnv(t)
marta, _ := e.join("marta", 1)
petya, _ := e.join("petya", 2)
kolya, _ := e.join("kolya", 3)
room := e.makeRoom(marta, "marta", "общая", 40)
// Владение получает участник с наименьшим joined_at, поэтому petya
// и kolya вступают в разные миллисекунды.
expect(t, e.changeMembers(marta, room.ID, []string{"petya"}, nil, []string{"marta", "petya"}, 60),
http.StatusOK, "")
tick()
expect(t, e.changeMembers(marta, room.ID, []string{"kolya"}, nil, []string{"marta", "petya", "kolya"}, 80),
http.StatusOK, "")
expect(t, e.do(http.MethodPost, "/api/rooms/"+room.ID+"/leave", nil, with(marta)), http.StatusNoContent, "")
if got := e.rooms(marta); len(got) != 0 {
t.Errorf("комната у вышедшего: %+v", got)
}
got := e.room(petya, room.ID)
if got == nil {
t.Fatal("комната пропала у оставшихся")
}
if got.Owner != "petya" {
t.Errorf("владелец: получено %q, ожидалось \"petya\"", got.Owner)
}
if nicks(got.Members) != "kolya,petya" {
t.Errorf("состав: %v", got.Members)
}
// Ключ оставшихся никуда не делся: rekey делает клиент нового владельца.
if got.Key == nil || got.Key.KeyID != keyID(80) {
t.Errorf("ключ после выхода владельца: %+v", got.Key)
}
// Новый владелец меняет состав, прежний — уже нет.
expect(t, e.changeMembers(kolya, room.ID, nil, nil, []string{"petya", "kolya"}, 100),
http.StatusForbidden, "not_owner")
expect(t, e.changeMembers(petya, room.ID, nil, nil, []string{"petya", "kolya"}, 100),
http.StatusOK, "")
}
// Выход последнего участника удаляет комнату (ADR-018).
func TestLeaveDeletesEmptyRoom(t *testing.T) {
e := newEnv(t)
marta, m1 := e.join("marta", 1)
room := e.makeRoom(marta, "marta", "общая", 40)
expect(t, e.do(http.MethodPost, "/api/rooms/"+room.ID+"/leave", nil, with(marta)), http.StatusNoContent, "")
if got := e.rooms(marta); len(got) != 0 {
t.Errorf("комнаты после выхода: %+v", got)
}
// Комнаты больше нет: писать в неё некому.
expect(t, e.do(http.MethodPost, "/api/messages", roomMessage(ulid(nowMillis(), 5), room.ID, keyID(40)),
with(marta), withDevice(m1)), http.StatusForbidden, "not_member")
// Повторный выход и выход не участника — тот же 204.
expect(t, e.do(http.MethodPost, "/api/rooms/"+room.ID+"/leave", nil, with(marta)), http.StatusNoContent, "")
expect(t, e.do(http.MethodPost, "/api/rooms/"+bytesOf(16, 9)+"/leave", nil, with(marta)), http.StatusNoContent, "")
petya, _ := e.join("petya", 2)
other := e.makeRoom(petya, "petya", "вторая", 60)
expect(t, e.do(http.MethodPost, "/api/rooms/"+other.ID+"/leave", nil, with(marta)), http.StatusNoContent, "")
if got := e.rooms(petya); len(got) != 1 {
t.Errorf("чужой выход тронул комнату: %+v", got)
}
}
// DELETE /api/rooms/{id} — только владелец; комната исчезает у всех.
func TestDeleteRoom(t *testing.T) {
e := newEnv(t)
marta, m1 := e.join("marta", 1)
petya, _ := e.join("petya", 2)
room := e.makeRoom(marta, "marta", "общая", 40)
expect(t, e.changeMembers(marta, room.ID, []string{"petya"}, nil, []string{"marta", "petya"}, 60),
http.StatusOK, "")
expect(t, e.do(http.MethodDelete, "/api/rooms/"+room.ID, nil, with(petya)), http.StatusForbidden, "not_owner")
expect(t, e.do(http.MethodDelete, "/api/rooms/"+bytesOf(16, 9), nil, with(marta)), http.StatusForbidden, "not_owner")
if got := e.rooms(petya); len(got) != 1 {
t.Fatalf("комната пропала до удаления: %+v", got)
}
expect(t, e.do(http.MethodDelete, "/api/rooms/"+room.ID, nil, with(marta)), http.StatusNoContent, "")
if got := e.rooms(marta); len(got) != 0 {
t.Errorf("комнаты у владельца: %+v", got)
}
if got := e.rooms(petya); len(got) != 0 {
t.Errorf("комнаты у участника: %+v", got)
}
expect(t, e.do(http.MethodPost, "/api/messages", roomMessage(ulid(nowMillis(), 5), room.ID, keyID(60)),
with(marta), withDevice(m1)), http.StatusForbidden, "not_member")
// Удалять больше нечего — и это уже чужая комната.
expect(t, e.do(http.MethodDelete, "/api/rooms/"+room.ID, nil, with(marta)), http.StatusForbidden, "not_owner")
}
// Конверт комнаты уходит на все устройства всех участников, кроме
// отправившего (ADR-017).
func TestRoomMessageFanout(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))
kolya, k1 := e.join("kolya", 5)
room := e.makeRoom(marta, "marta", "общая", 40)
expect(t, e.changeMembers(marta, room.ID, []string{"petya"}, nil, []string{"marta", "petya"}, 60),
http.StatusOK, "")
id := ulid(nowMillis(), 6)
expect(t, e.do(http.MethodPost, "/api/messages", roomMessage(id, room.ID, keyID(60)),
with(marta), withDevice(m1)), http.StatusAccepted, "")
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))
}
if got[0].ID != id || got[0].From != "marta" || got[0].To.Room != room.ID || got[0].To.DM != "" {
t.Errorf("конверт для %s: %+v", device, got[0])
}
if got[0].KeyID != keyID(60) || got[0].TS == 0 {
t.Errorf("конверт для %s: %+v", device, got[0])
}
}
// Посторонний не получает и не пишет.
if got := e.queue(k1); len(got) != 0 {
t.Errorf("конверт постороннему: %v", got)
}
expect(t, e.do(http.MethodPost, "/api/messages", roomMessage(ulid(nowMillis(), 7), room.ID, keyID(60)),
with(kolya), withDevice(k1)), http.StatusForbidden, "not_member")
// Контактов комната не заводит: список комнат приходит из GET /api/rooms.
if got := e.contacts(marta); len(got) != 0 {
t.Errorf("сообщение в комнату завело контакт: %+v", got)
}
}
// keyId сообщения обязан быть ключом этой комнаты (docs/protocol.md).
func TestRoomMessageUnknownKey(t *testing.T) {
e := newEnv(t)
marta, m1 := e.join("marta", 1)
petya, p1 := e.join("petya", 2)
room := e.makeRoom(marta, "marta", "общая", 40)
other := e.makeRoom(petya, "petya", "чужая", 60)
// Ключ другой комнаты — не ключ этой.
expect(t, e.do(http.MethodPost, "/api/messages", roomMessage(ulid(nowMillis(), 3), room.ID, keyID(60)),
with(marta), withDevice(m1)), http.StatusBadRequest, "unknown_key")
expect(t, e.do(http.MethodPost, "/api/messages", roomMessage(ulid(nowMillis(), 4), room.ID, keyID(99)),
with(marta), withDevice(m1)), http.StatusBadRequest, "unknown_key")
// Свой — принимается.
expect(t, e.do(http.MethodPost, "/api/messages", roomMessage(ulid(nowMillis(), 5), room.ID, keyID(40)),
with(marta), withDevice(m1)), http.StatusAccepted, "")
// Членство проверяется раньше ключа.
expect(t, e.do(http.MethodPost, "/api/messages", roomMessage(ulid(nowMillis(), 6), other.ID, keyID(99)),
with(marta), withDevice(m1)), http.StatusForbidden, "not_member")
if got := e.queue(p1); len(got) != 0 {
t.Errorf("отвергнутое сообщение попало в очередь: %v", got)
}
}
// Прежний ключ комнаты остаётся рабочим, пока его не вытеснила обрезка:
// у комнаты живут два последних keyId (ADR-018).
func TestRoomKeysKeepTwo(t *testing.T) {
e := newEnv(t)
marta, m1 := e.join("marta", 1)
room := e.makeRoom(marta, "marta", "общая", 40)
expect(t, e.changeMembers(marta, room.ID, nil, nil, []string{"marta"}, 60), http.StatusOK, "")
// Два последних — первый ещё жив.
for _, key := range []string{keyID(40), keyID(60)} {
expect(t, e.do(http.MethodPost, "/api/messages", roomMessage(ulid(nowMillis(), 3), room.ID, key),
with(marta), withDevice(m1)), http.StatusAccepted, "")
}
expect(t, e.changeMembers(marta, room.ID, nil, nil, []string{"marta"}, 80), http.StatusOK, "")
// Третий rekey вытеснил самый старый ключ.
expect(t, e.do(http.MethodPost, "/api/messages", roomMessage(ulid(nowMillis(), 4), room.ID, keyID(40)),
with(marta), withDevice(m1)), http.StatusBadRequest, "unknown_key")
for _, key := range []string{keyID(60), keyID(80)} {
expect(t, e.do(http.MethodPost, "/api/messages", roomMessage(ulid(nowMillis(), 5), room.ID, key),
with(marta), withDevice(m1)), http.StatusAccepted, "")
}
// Текущий ключ участника — последний.
if got := e.room(marta, room.ID); got.Key == nil || got.Key.KeyID != keyID(80) {
t.Errorf("текущий ключ: %+v", got.Key)
}
}
// Удаление аккаунта передаёт владение по ADR-018, а комнату без участников
// удаляет.
func TestDeleteAccountWithRooms(t *testing.T) {
e := newEnv(t)
marta, _ := e.join("marta", 1)
petya, _ := e.join("petya", 2)
shared := e.makeRoom(marta, "marta", "общая", 40)
expect(t, e.changeMembers(marta, shared.ID, []string{"petya"}, nil, []string{"marta", "petya"}, 60),
http.StatusOK, "")
alone := e.makeRoom(marta, "marta", "своя", 80)
expect(t, e.do(http.MethodDelete, "/api/me", map[string]any{"authKey": bytesOf(32, 1)}, with(marta)),
http.StatusNoContent, "")
// Комната с оставшимся участником живёт, комната без участников —
// исчезла вместе с владельцем.
list := e.rooms(petya)
if len(list) != 1 || list[0].ID != shared.ID || list[0].ID == alone.ID {
t.Fatalf("комнаты petya: %+v", list)
}
if list[0].Owner != "petya" {
t.Errorf("владелец после удаления аккаунта: получено %q, ожидалось \"petya\"", list[0].Owner)
}
if len(list[0].Members) != 1 || list[0].Members[0] != "petya" {
t.Errorf("состав: %v", list[0].Members)
}
if list[0].Key == nil || list[0].Key.KeyID != keyID(60) {
t.Errorf("ключ оставшегося: %+v", list[0].Key)
}
// Ник свободен, а комната, где не осталось никого, исчезла вместе с ним.
expect(t, e.do(http.MethodPost, "/api/register", account("marta")), http.StatusCreated, "")
fresh := e.do(http.MethodPost, "/api/login", map[string]any{"nick": "marta", "authKey": bytesOf(32, 1)})
expect(t, fresh, http.StatusOK, "")
if got := e.rooms(e.cookie(fresh)); len(got) != 0 {
t.Errorf("комнаты нового аккаунта: %+v", got)
}
}
// Событие room уходит остальным устройствам создателя, но не отправившему
// (docs/protocol.md, «Комнаты»).
func TestRoomEventOnCreate(t *testing.T) {
e := newEnv(t)
marta, m1 := e.join("marta", 1)
m2 := e.addDevice(marta, deviceOf(2))
sender := e.open(m1, marta)
sender.untilReady()
other := e.open(m2, marta)
other.untilReady()
room := e.makeRoom(marta, "marta", "общая", 40, withDevice(m1))
ev := other.next()
if ev.name != "room" {
t.Fatalf("событие: получено %q, ожидалось \"room\"", ev.name)
}
var got roomBody
if err := json.Unmarshal([]byte(ev.data), &got); err != nil {
t.Fatalf("разбор события %q: %v", ev.data, err)
}
if got.ID != room.ID || got.Name != "общая" || got.Owner != "marta" {
t.Errorf("комната в событии: %+v", got)
}
if got.Key == nil || got.Key.CT != ctOf(40, 0) {
t.Errorf("ключ в событии: %+v", got.Key)
}
if got.NeedsRekey {
t.Error("needsRekey при создании")
}
select {
case ev := <-sender.events:
t.Errorf("эхо отправившему устройству: %+v", ev)
case <-time.After(200 * time.Millisecond):
}
}
// Смена состава: room всем участникам — каждому со своим ключом,
// room_left убранным.
func TestRoomEventsOnMembers(t *testing.T) {
e := newEnv(t)
marta, m1 := e.join("marta", 1)
petya, p1 := e.join("petya", 2)
kolya, k1 := e.join("kolya", 3)
room := e.makeRoom(marta, "marta", "общая", 40)
expect(t, e.changeMembers(marta, room.ID, []string{"petya", "kolya"}, nil,
[]string{"marta", "petya", "kolya"}, 60), http.StatusOK, "")
streams := map[string]*stream{
"marta": e.open(m1, marta),
"petya": e.open(p1, petya),
"kolya": e.open(k1, kolya),
}
for _, s := range streams {
s.untilReady()
}
expect(t, e.changeMembers(marta, room.ID, nil, []string{"kolya"}, []string{"marta", "petya"}, 80),
http.StatusOK, "")
for i, nick := range []string{"marta", "petya"} {
ev := streams[nick].next()
if ev.name != "room" {
t.Fatalf("событие у %s: получено %q, ожидалось \"room\"", nick, ev.name)
}
var got roomBody
if err := json.Unmarshal([]byte(ev.data), &got); err != nil {
t.Fatalf("разбор события %q: %v", ev.data, err)
}
if nicks(got.Members) != "marta,petya" {
t.Errorf("состав в событии у %s: %v", nick, got.Members)
}
if got.Key == nil || got.Key.KeyID != keyID(80) || got.Key.CT != ctOf(80, i) {
t.Errorf("ключ в событии у %s: %+v", nick, got.Key)
}
if got.NeedsRekey {
t.Errorf("needsRekey при смене состава у %s", nick)
}
}
ev := streams["kolya"].next()
if ev.name != "room_left" || ev.data != `{"id":"`+room.ID+`"}` {
t.Errorf("событие у убранного: %+v", ev)
}
}
// Выход участника: остальным — room с needsRekey (ADR-018).
func TestRoomEventOnLeave(t *testing.T) {
e := newEnv(t)
marta, m1 := e.join("marta", 1)
petya, p1 := e.join("petya", 2)
room := e.makeRoom(marta, "marta", "общая", 40)
expect(t, e.changeMembers(marta, room.ID, []string{"petya"}, nil, []string{"marta", "petya"}, 60),
http.StatusOK, "")
owner := e.open(m1, marta)
owner.untilReady()
leaving := e.open(p1, petya)
leaving.untilReady()
expect(t, e.do(http.MethodPost, "/api/rooms/"+room.ID+"/leave", nil, with(petya)), http.StatusNoContent, "")
ev := owner.next()
if ev.name != "room" {
t.Fatalf("событие: получено %q, ожидалось \"room\"", ev.name)
}
var got roomBody
if err := json.Unmarshal([]byte(ev.data), &got); err != nil {
t.Fatalf("разбор события %q: %v", ev.data, err)
}
if !got.NeedsRekey {
t.Errorf("needsRekey: получено false, ожидалось true: %s", ev.data)
}
if len(got.Members) != 1 || got.Members[0] != "marta" {
t.Errorf("состав в событии: %v", got.Members)
}
if got.Key == nil || got.Key.KeyID != keyID(60) {
t.Errorf("ключ в событии: %+v", got.Key)
}
// Другим устройствам вышедшего — room_left: комната ушла из списка,
// и ждать следующего ready им незачем (ADR-041). Запрос шёл без
// X-Device, поэтому событие получает и это устройство.
gone := leaving.next()
if gone.name != "room_left" || gone.data != `{"id":"`+room.ID+`"}` {
t.Errorf("событие вышедшему: %+v", gone)
}
}
// Выход с X-Device: room_left уходит другим устройствам вышедшего,
// но не отправившему запрос (ADR-041).
func TestRoomEventOnLeaveExcludesSender(t *testing.T) {
e := newEnv(t)
marta, _ := e.join("marta", 1)
petya, p1 := e.join("petya", 2)
p2 := e.addDevice(petya, deviceOf(3))
room := e.makeRoom(marta, "marta", "общая", 40)
expect(t, e.changeMembers(marta, room.ID, []string{"petya"}, nil, []string{"marta", "petya"}, 60),
http.StatusOK, "")
sender := e.open(p1, petya)
sender.untilReady()
other := e.open(p2, petya)
other.untilReady()
expect(t, e.do(http.MethodPost, "/api/rooms/"+room.ID+"/leave", nil, with(petya), withDevice(p1)),
http.StatusNoContent, "")
ev := other.next()
if ev.name != "room_left" || ev.data != `{"id":"`+room.ID+`"}` {
t.Errorf("событие другому устройству: %+v", ev)
}
select {
case ev := <-sender.events:
t.Errorf("эхо отправившему устройству: %+v", ev)
case <-time.After(200 * time.Millisecond):
}
}
// Удаление комнаты: room_left всем участникам, включая владельца.
func TestRoomEventOnDelete(t *testing.T) {
e := newEnv(t)
marta, m1 := e.join("marta", 1)
petya, p1 := e.join("petya", 2)
room := e.makeRoom(marta, "marta", "общая", 40)
expect(t, e.changeMembers(marta, room.ID, []string{"petya"}, nil, []string{"marta", "petya"}, 60),
http.StatusOK, "")
owner := e.open(m1, marta)
owner.untilReady()
member := e.open(p1, petya)
member.untilReady()
expect(t, e.do(http.MethodDelete, "/api/rooms/"+room.ID, nil, with(marta)), http.StatusNoContent, "")
want := `{"id":"` + room.ID + `"}`
for _, s := range []*stream{owner, member} {
ev := s.next()
if ev.name != "room_left" || ev.data != want {
t.Errorf("событие: %+v", ev)
}
}
}
// Комнаты требуют сессии, как и всё непубличное.
func TestRoomsNeedSession(t *testing.T) {
e := newEnv(t)
expect(t, e.do(http.MethodGet, "/api/rooms", nil), http.StatusUnauthorized, "unauthenticated")
expect(t, e.do(http.MethodPost, "/api/rooms", map[string]any{"name": "общая"}),
http.StatusUnauthorized, "unauthenticated")
expect(t, e.do(http.MethodPost, "/api/rooms/"+bytesOf(16, 1)+"/leave", nil),
http.StatusUnauthorized, "unauthenticated")
expect(t, e.do(http.MethodDelete, "/api/rooms/"+bytesOf(16, 1), nil),
http.StatusUnauthorized, "unauthenticated")
// Идентификатор комнаты в журнал не уходит: пишется шаблон маршрута.
if strings.Contains(e.log.String(), bytesOf(16, 1)) {
t.Errorf("идентификатор комнаты в журнале: %q", e.log.String())
}
}
// Долг по ключу — состояние комнаты: владелец, пропустивший событие,
// поднимает его из GET /api/rooms, а смена состава долг снимает (ADR-041).
func TestNeedsRekeyOutlivesEvent(t *testing.T) {
e := newEnv(t)
marta, _ := e.join("marta", 1)
petya, _ := e.join("petya", 2)
room := e.makeRoom(marta, "marta", "общая", 40)
expect(t, e.changeMembers(marta, room.ID, []string{"petya"}, nil, []string{"marta", "petya"}, 60),
http.StatusOK, "")
if got := e.room(marta, room.ID); got.NeedsRekey {
t.Error("needsRekey до выхода участника")
}
// Владелец не подключён: событие room ему уходить некуда.
expect(t, e.do(http.MethodPost, "/api/rooms/"+room.ID+"/leave", nil, with(petya)),
http.StatusNoContent, "")
got := e.room(marta, room.ID)
if got == nil || !got.NeedsRekey {
t.Fatalf("needsRekey в списке комнат: %+v", got)
}
if got.Key == nil || got.Key.KeyID != keyID(60) {
t.Errorf("ключ в списке: %+v", got.Key)
}
// Rekey закрывает долг.
expect(t, e.changeMembers(marta, room.ID, nil, nil, []string{"marta"}, 80), http.StatusOK, "")
if got := e.room(marta, room.ID); got.NeedsRekey {
t.Error("needsRekey после rekey")
}
}
// Удаление аккаунта — выход из всех его комнат: оставшимся уходит room
// с needsRekey и их собственным ключом, владение переходит (ADR-041).
func TestDeleteAccountLeavesRooms(t *testing.T) {
e := newEnv(t)
marta, _ := e.join("marta", 1)
petya, p1 := e.join("petya", 2)
kolya, k1 := e.join("kolya", 3)
room := e.makeRoom(marta, "marta", "общая", 40)
expect(t, e.changeMembers(marta, room.ID, []string{"petya", "kolya"}, nil,
[]string{"marta", "petya", "kolya"}, 60), http.StatusOK, "")
first := e.open(p1, petya)
first.untilReady()
second := e.open(k1, kolya)
second.untilReady()
expect(t, e.do(http.MethodDelete, "/api/me", map[string]any{"authKey": bytesOf(32, 1)}, with(marta)),
http.StatusNoContent, "")
for i, s := range []*stream{first, second} {
ev := s.next()
if ev.name != "room" {
t.Fatalf("событие: получено %q, ожидалось \"room\"", ev.name)
}
var got roomBody
if err := json.Unmarshal([]byte(ev.data), &got); err != nil {
t.Fatalf("разбор события %q: %v", ev.data, err)
}
if !got.NeedsRekey {
t.Errorf("needsRekey в событии: %s", ev.data)
}
// Владение — участнику с наименьшим joined_at; petya и kolya
// вступили одной операцией, поэтому порядок решает ник.
if got.Owner != "kolya" {
t.Errorf("владелец в событии: %q, ожидался kolya", got.Owner)
}
if nicks(got.Members) != nicks([]string{"petya", "kolya"}) {
t.Errorf("состав в событии: %v", got.Members)
}
// Каждому — его собственный ключ: он различается порядковым
// номером внутри «шифротекста».
if got.Key == nil || got.Key.CT != ctOf(60, i+1) {
t.Errorf("ключ в событии: %+v", got.Key)
}
}
// Признак пережил и рассылку: новый владелец увидит его после ready.
if got := e.room(kolya, room.ID); got == nil || !got.NeedsRekey || got.Owner != "kolya" {
t.Errorf("комната у нового владельца: %+v", got)
}
// Ключи удалённого аккаунта ушли каскадом, комната жива.
expect(t, e.changeMembers(kolya, room.ID, nil, nil, []string{"petya", "kolya"}, 80),
http.StatusOK, "")
if got := e.room(petya, room.ID); got.NeedsRekey {
t.Error("needsRekey после rekey нового владельца")
}
}
// Два ключа одному участнику — это множество keys[].to, не равное
// составу, и код у него тот же (ADR-043).
func TestMembersDuplicateKeyTarget(t *testing.T) {
e := newEnv(t)
marta, _ := e.join("marta", 1)
e.join("petya", 2)
room := e.makeRoom(marta, "marta", "общая", 40)
expect(t, e.changeMembers(marta, room.ID, []string{"petya"}, nil, []string{"marta", "petya"}, 60),
http.StatusOK, "")
rec := e.changeMembers(marta, room.ID, nil, nil, []string{"marta", "marta"}, 80)
expect(t, rec, http.StatusBadRequest, "keys_mismatch")
// Отказ ничего не изменил: ключ комнаты прежний.
if got := e.room(marta, room.ID); got.Key == nil || got.Key.KeyID != keyID(60) {
t.Errorf("ключ после keys_mismatch: %+v", got.Key)
}
}