Лимиты: все четыре правила ADR-021 — регистрация 5/час на IP, вход 10/10 мин на IP и ник, сообщения 30/мин, прочие изменяющие 60/мин; 429 с Retry-After; X-Real-IP читается только с loopback, иначе адрес соединения — иначе заголовок отменял бы лимит на IP; карты вёдер ограничены поколениями. Аудит нашёл то, что пропустили пять раундов ревью: ADR-056: nginx вёл access_log с IP и полными путями вопреки обещанию deploy.md. Ники и социальный граф ложились в /var/log/nginx рядом с чистым журналом bare. ADR-058: «выйти на других устройствах» не обрывал уже открытый SSE — отозванная сессия продолжала получать сообщения. ADR-059: промежуточный ключ комнаты был невосстановим. Участник, пропустивший офлайн два rekey подряд, навсегда не расшифровал бы сообщения среднего ключа — вопреки обещанию storage.md о повторной попытке после получения keyId. ADR-063: ACK уходил по одному на конверт, а не пачкой. Получатель в оживлённой комнате выедал общее ведро подтверждениями и упирался в 429 на всех изменяющих запросах, включая выход из комнаты: 116 отказов за прогон стало нулём. ADR-055, 057, 060, 061, 062: ключи вёдер и границы, +dirty у bare version, 403 unknown_device не хоронит сообщение, усечение имени в подсказке ввода, kdf как оракул после повышения цели KDF. Модель угроз пополнена тем, что действительно видит оператор: push-подписки лежат в базе открытым текстом, и вместе с VAPID-ключом с той же машины это произвольное уведомление на экране блокировки. README приведён к v1. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015DbCjVfTFq4ZFG8juD45YJ
365 lines
16 KiB
Go
365 lines
16 KiB
Go
package store
|
||
|
||
import (
|
||
"context"
|
||
"errors"
|
||
"path/filepath"
|
||
"testing"
|
||
"time"
|
||
)
|
||
|
||
// room — комната из трёх ников для тестов хранилища.
|
||
func newTestStore(t *testing.T, nicks ...string) (*Store, context.Context) {
|
||
t.Helper()
|
||
ctx := context.Background()
|
||
s := open(t, filepath.Join(t.TempDir(), "bare.db"))
|
||
for i, nick := range nicks {
|
||
err := s.CreateUser(ctx, User{
|
||
Nick: nick,
|
||
Cred: Credential{Hash: []byte("hash"), Salt: []byte("salt"), Params: "argon2id,m=19456,t=2,p=1"},
|
||
PublicKey: `{"kty":"EC"}`,
|
||
KeyBlob: `{"v":1}`,
|
||
CreatedAt: int64(i + 1),
|
||
})
|
||
if err != nil {
|
||
t.Fatalf("CreateUser %s: %v", nick, err)
|
||
}
|
||
}
|
||
return s, ctx
|
||
}
|
||
|
||
// wrap — завёрнутый ключ участнику: содержимое хранилищу безразлично.
|
||
func wrap(nicks ...string) []WrappedKey {
|
||
out := make([]WrappedKey, 0, len(nicks))
|
||
for _, nick := range nicks {
|
||
out = append(out, WrappedKey{To: nick, IV: "iv-" + nick, CT: "ct-" + nick})
|
||
}
|
||
return out
|
||
}
|
||
|
||
// rekey — смена состава и раздача нового ключа.
|
||
func rekey(t *testing.T, s *Store, ctx context.Context, room, owner, keyID string, add, remove, to []string, now int64) RoomChange {
|
||
t.Helper()
|
||
change, err := s.UpdateMembers(ctx, MembersChange{
|
||
RoomID: room,
|
||
Owner: owner,
|
||
Add: add,
|
||
Remove: remove,
|
||
KeyID: keyID,
|
||
Keys: wrap(to...),
|
||
Now: now,
|
||
})
|
||
if err != nil {
|
||
t.Fatalf("UpdateMembers %s: %v", keyID, err)
|
||
}
|
||
return change
|
||
}
|
||
|
||
// makeRoom — комната с одним владельцем и его ключом.
|
||
func makeRoom(t *testing.T, s *Store, ctx context.Context, owner string, now int64) RoomChange {
|
||
t.Helper()
|
||
change, err := s.CreateRoom(ctx, NewRoom{
|
||
ID: "room-1",
|
||
Name: "общая",
|
||
Owner: owner,
|
||
KeyID: "k1",
|
||
Key: wrap(owner)[0],
|
||
Now: now,
|
||
})
|
||
if err != nil {
|
||
t.Fatalf("CreateRoom: %v", err)
|
||
}
|
||
return change
|
||
}
|
||
|
||
// Занятый идентификатор комнаты — ErrRoomExists, и ни одной строки
|
||
// существующая комната при этом не теряет (ADR-037).
|
||
func TestCreateRoomTakenID(t *testing.T) {
|
||
s, ctx := newTestStore(t, "marta", "petya")
|
||
makeRoom(t, s, ctx, "marta", 1000)
|
||
|
||
_, err := s.CreateRoom(ctx, NewRoom{ID: "room-1", Name: "чужая", Owner: "petya",
|
||
KeyID: "k2", Key: wrap("petya")[0], Now: 2000})
|
||
if !errors.Is(err, ErrRoomExists) {
|
||
t.Fatalf("CreateRoom с занятым id: получено %v, ожидалось ErrRoomExists", err)
|
||
}
|
||
rooms, err := s.Rooms(ctx, "marta")
|
||
if err != nil {
|
||
t.Fatalf("Rooms: %v", err)
|
||
}
|
||
if len(rooms) != 1 || rooms[0].Name != "общая" || rooms[0].Owner != "marta" ||
|
||
current(rooms[0]) == nil || current(rooms[0]).KeyID != "k1" {
|
||
t.Errorf("комната после отказа: %+v", rooms)
|
||
}
|
||
if got, err := s.Rooms(ctx, "petya"); err != nil || len(got) != 0 {
|
||
t.Errorf("занятый id присоединил чужого: %+v, %v", got, err)
|
||
}
|
||
}
|
||
|
||
// current — текущий ключ участника: последний в Keys, они идут от старого
|
||
// к новому (ADR-059).
|
||
func current(r Room) *RoomKey {
|
||
if len(r.Keys) == 0 {
|
||
return nil
|
||
}
|
||
return &r.Keys[len(r.Keys)-1]
|
||
}
|
||
|
||
// У комнаты живут два последних keyId; обрезка при rekey и фоновая чистка
|
||
// держат одни и те же ключи и не трогают текущий ключ участника (ADR-018).
|
||
func TestRoomKeysTrimmedToTwo(t *testing.T) {
|
||
s, ctx := newTestStore(t, "marta", "petya", "kolya")
|
||
makeRoom(t, s, ctx, "marta", 1000)
|
||
rekey(t, s, ctx, "room-1", "marta", "k2", []string{"petya"}, nil, []string{"marta", "petya"}, 2000)
|
||
rekey(t, s, ctx, "room-1", "marta", "k3", []string{"kolya"}, nil, []string{"marta", "petya", "kolya"}, 3000)
|
||
|
||
if got := ids(t, s, `SELECT DISTINCT key_id FROM room_keys ORDER BY key_id`); !equal(got, []string{"k2", "k3"}) {
|
||
t.Errorf("ключи после rekey: получено %v, ожидалось [k2 k3]", got)
|
||
}
|
||
// Фоновая чистка ничего не добавляет к обрезке: запрос у них один.
|
||
if err := s.Cleanup(ctx, time.Now()); err != nil {
|
||
t.Fatalf("Cleanup: %v", err)
|
||
}
|
||
if got := ids(t, s, `SELECT DISTINCT key_id FROM room_keys ORDER BY key_id`); !equal(got, []string{"k2", "k3"}) {
|
||
t.Errorf("ключи после чистки: получено %v, ожидалось [k2 k3]", got)
|
||
}
|
||
// Текущий ключ есть у каждого участника, и он последний.
|
||
for _, nick := range []string{"marta", "petya", "kolya"} {
|
||
rooms, err := s.Rooms(ctx, nick)
|
||
if err != nil {
|
||
t.Fatalf("Rooms %s: %v", nick, err)
|
||
}
|
||
if len(rooms) != 1 || current(rooms[0]) == nil {
|
||
t.Fatalf("комнаты %s: %+v", nick, rooms)
|
||
}
|
||
if current(rooms[0]).KeyID != "k3" || current(rooms[0]).CT != "ct-"+nick {
|
||
t.Errorf("ключ %s: %+v", nick, current(rooms[0]))
|
||
}
|
||
}
|
||
// Участнику отдаются оба удерживаемых ключа, от старого к новому:
|
||
// без прежнего он не прочитает сообщение, отправленное до последнего
|
||
// rekey, пока его не было (ADR-059).
|
||
rooms, err := s.Rooms(ctx, "petya")
|
||
if err != nil {
|
||
t.Fatalf("Rooms petya: %v", err)
|
||
}
|
||
if len(rooms) != 1 || len(rooms[0].Keys) != 2 {
|
||
t.Fatalf("ключи petya: %+v", rooms)
|
||
}
|
||
if rooms[0].Keys[0].KeyID != "k2" || rooms[0].Keys[1].KeyID != "k3" {
|
||
t.Errorf("порядок ключей: получено %v, ожидалось [k2 k3]",
|
||
[]string{rooms[0].Keys[0].KeyID, rooms[0].Keys[1].KeyID})
|
||
}
|
||
// Ключей чужой комнаты в ответе нет.
|
||
if got, err := s.Rooms(ctx, "marta"); err != nil || len(got) != 1 || len(got[0].Keys) != 2 {
|
||
t.Errorf("ключи marta: %+v, %v", got, err)
|
||
}
|
||
}
|
||
|
||
// Два rekey в одну миллисекунду: текущим остаётся последний розданный ключ,
|
||
// и обрезка его не выбрасывает (docs/storage.md, «Текущий ключ комнаты»).
|
||
// Идентификаторы ключей случайны, поэтому свежий вполне может оказаться
|
||
// меньше прежнего по порядку сортировки — на этом и построен случай.
|
||
func TestRoomKeysWithinOneMillisecond(t *testing.T) {
|
||
s, ctx := newTestStore(t, "marta", "petya")
|
||
if _, err := s.CreateRoom(ctx, NewRoom{ID: "room-1", Name: "общая", Owner: "marta",
|
||
KeyID: "ccc", Key: wrap("marta")[0], Now: 1000}); err != nil {
|
||
t.Fatalf("CreateRoom: %v", err)
|
||
}
|
||
rekey(t, s, ctx, "room-1", "marta", "bbb", []string{"petya"}, nil, []string{"marta", "petya"}, 1000)
|
||
rekey(t, s, ctx, "room-1", "marta", "aaa", nil, nil, []string{"marta", "petya"}, 1000)
|
||
|
||
if got := ids(t, s, `SELECT DISTINCT key_id FROM room_keys ORDER BY key_id`); !equal(got, []string{"aaa", "bbb"}) {
|
||
t.Errorf("ключи: получено %v, ожидалось [aaa bbb]", got)
|
||
}
|
||
for _, nick := range []string{"marta", "petya"} {
|
||
rooms, err := s.Rooms(ctx, nick)
|
||
if err != nil || len(rooms) != 1 || current(rooms[0]) == nil {
|
||
t.Fatalf("комнаты %s: %+v, %v", nick, rooms, err)
|
||
}
|
||
if current(rooms[0]).KeyID != "aaa" {
|
||
t.Errorf("текущий ключ %s: получено %q, ожидалось \"aaa\"", nick, current(rooms[0]).KeyID)
|
||
}
|
||
}
|
||
// Свежим ключом можно писать: он остался ключом комнаты.
|
||
access, err := s.RoomAccess(ctx, "room-1", "marta", "aaa")
|
||
if err != nil || !access.Member || !access.KnownKey {
|
||
t.Errorf("доступ по свежему ключу: %+v, %v", access, err)
|
||
}
|
||
if access.Name != "общая" {
|
||
t.Errorf("имя комнаты: получено %q, ожидалось \"общая\"", access.Name)
|
||
}
|
||
}
|
||
|
||
// Убранный участник теряет и членство, и все свои ключи.
|
||
func TestRoomRemoveDropsKeys(t *testing.T) {
|
||
s, ctx := newTestStore(t, "marta", "petya")
|
||
makeRoom(t, s, ctx, "marta", 1000)
|
||
rekey(t, s, ctx, "room-1", "marta", "k2", []string{"petya"}, nil, []string{"marta", "petya"}, 2000)
|
||
|
||
change := rekey(t, s, ctx, "room-1", "marta", "k3", nil, []string{"petya"}, []string{"marta"}, 3000)
|
||
if len(change.Left) != 1 || change.Left[0].Nick != "petya" {
|
||
t.Errorf("выбывшие: %+v", change.Left)
|
||
}
|
||
if got := ids(t, s, `SELECT nick FROM room_keys ORDER BY nick, key_id`); !equal(got, []string{"marta", "marta"}) {
|
||
t.Errorf("ключи после удаления участника: %v", got)
|
||
}
|
||
rooms, err := s.Rooms(ctx, "petya")
|
||
if err != nil || len(rooms) != 0 {
|
||
t.Errorf("комнаты убранного: %+v, %v", rooms, err)
|
||
}
|
||
}
|
||
|
||
// Отказ в середине не оставляет следов: состав и ключи те же (docs/protocol.md).
|
||
func TestRoomChangeIsAtomic(t *testing.T) {
|
||
s, ctx := newTestStore(t, "marta", "petya", "kolya")
|
||
makeRoom(t, s, ctx, "marta", 1000)
|
||
rekey(t, s, ctx, "room-1", "marta", "k2", []string{"petya"}, nil, []string{"marta", "petya"}, 2000)
|
||
|
||
cases := []struct {
|
||
name string
|
||
c MembersChange
|
||
want error
|
||
}{
|
||
{"не владелец", MembersChange{RoomID: "room-1", Owner: "petya", KeyID: "k3",
|
||
Keys: wrap("marta", "petya"), Now: 3000}, ErrNotOwner},
|
||
{"нет комнаты", MembersChange{RoomID: "нет", Owner: "marta", KeyID: "k3",
|
||
Keys: wrap("marta", "petya"), Now: 3000}, ErrNotOwner},
|
||
{"нет ника", MembersChange{RoomID: "room-1", Owner: "marta", Add: []string{"никого"}, KeyID: "k3",
|
||
Keys: wrap("marta", "petya", "никого"), Now: 3000}, ErrUnknownUser},
|
||
{"не участник", MembersChange{RoomID: "room-1", Owner: "marta", Remove: []string{"kolya"}, KeyID: "k3",
|
||
Keys: wrap("marta", "petya"), Now: 3000}, ErrNotMember},
|
||
{"владелец", MembersChange{RoomID: "room-1", Owner: "marta", Remove: []string{"marta"}, KeyID: "k3",
|
||
Keys: wrap("petya"), Now: 3000}, ErrOwnerRemoval},
|
||
{"ключ уже был", MembersChange{RoomID: "room-1", Owner: "marta", Add: []string{"kolya"}, KeyID: "k2",
|
||
Keys: wrap("marta", "petya", "kolya"), Now: 3000}, ErrKeyExists},
|
||
{"ключи не по составу", MembersChange{RoomID: "room-1", Owner: "marta", Add: []string{"kolya"}, KeyID: "k3",
|
||
Keys: wrap("marta", "petya"), Now: 3000}, ErrKeysMismatch},
|
||
// Два ключа одному вместо ключа другому: длина сходится, состав — нет.
|
||
{"два ключа одному", MembersChange{RoomID: "room-1", Owner: "marta", KeyID: "k3",
|
||
Keys: wrap("marta", "marta"), Now: 3000}, ErrKeysMismatch},
|
||
}
|
||
for _, c := range cases {
|
||
t.Run(c.name, func(t *testing.T) {
|
||
if _, err := s.UpdateMembers(ctx, c.c); !errors.Is(err, c.want) {
|
||
t.Fatalf("получено %v, ожидалось %v", err, c.want)
|
||
}
|
||
if got := ids(t, s, `SELECT nick FROM room_members WHERE room_id = 'room-1' ORDER BY nick`); !equal(got, []string{"marta", "petya"}) {
|
||
t.Errorf("состав: получено %v, ожидалось [marta petya]", got)
|
||
}
|
||
if got := ids(t, s, `SELECT DISTINCT key_id FROM room_keys ORDER BY key_id`); !equal(got, []string{"k1", "k2"}) {
|
||
t.Errorf("ключи: получено %v, ожидалось [k1 k2]", got)
|
||
}
|
||
})
|
||
}
|
||
}
|
||
|
||
// Удаление аккаунта передаёт владение участнику с наименьшим joined_at,
|
||
// а комнату без участников удаляет (ADR-018).
|
||
func TestDeleteUserRooms(t *testing.T) {
|
||
s, ctx := newTestStore(t, "marta", "petya", "kolya")
|
||
makeRoom(t, s, ctx, "marta", 1000)
|
||
rekey(t, s, ctx, "room-1", "marta", "k2", []string{"kolya"}, nil, []string{"marta", "kolya"}, 2000)
|
||
rekey(t, s, ctx, "room-1", "marta", "k3", []string{"petya"}, nil, []string{"marta", "kolya", "petya"}, 3000)
|
||
|
||
// Вторая комната — только владелец.
|
||
if _, err := s.CreateRoom(ctx, NewRoom{ID: "room-2", Name: "своя", Owner: "marta",
|
||
KeyID: "k1", Key: wrap("marta")[0], Now: 4000}); err != nil {
|
||
t.Fatalf("CreateRoom: %v", err)
|
||
}
|
||
|
||
if _, err := s.DeleteUser(ctx, "marta"); err != nil {
|
||
t.Fatalf("DeleteUser: %v", err)
|
||
}
|
||
if got := ids(t, s, `SELECT id FROM rooms ORDER BY id`); !equal(got, []string{"room-1"}) {
|
||
t.Errorf("комнаты: получено %v, ожидалось [room-1]", got)
|
||
}
|
||
if got := ids(t, s, `SELECT owner FROM rooms`); !equal(got, []string{"kolya"}) {
|
||
t.Errorf("владелец: получено %v, ожидалось [kolya]", got)
|
||
}
|
||
if got := ids(t, s, `SELECT nick FROM room_members ORDER BY nick`); !equal(got, []string{"kolya", "petya"}) {
|
||
t.Errorf("состав: получено %v, ожидалось [kolya petya]", got)
|
||
}
|
||
if got := ids(t, s, `SELECT DISTINCT nick FROM room_keys ORDER BY nick`); !equal(got, []string{"kolya", "petya"}) {
|
||
t.Errorf("ключи: получено %v, ожидалось [kolya petya]", got)
|
||
}
|
||
}
|
||
|
||
// Удаление аккаунта простого участника чужую комнату не трогает: членство
|
||
// и ключи уносит каскад, владелец и остальные на месте.
|
||
func TestDeleteUserMember(t *testing.T) {
|
||
s, ctx := newTestStore(t, "marta", "petya")
|
||
if _, err := s.CreateRoom(ctx, NewRoom{ID: "room-1", Name: "общая", Owner: "petya",
|
||
KeyID: "k1", Key: wrap("petya")[0], Now: 1000}); err != nil {
|
||
t.Fatalf("CreateRoom: %v", err)
|
||
}
|
||
rekey(t, s, ctx, "room-1", "petya", "k2", []string{"marta"}, nil, []string{"petya", "marta"}, 2000)
|
||
|
||
if _, err := s.DeleteUser(ctx, "marta"); err != nil {
|
||
t.Fatalf("DeleteUser: %v", err)
|
||
}
|
||
if got := ids(t, s, `SELECT owner FROM rooms`); !equal(got, []string{"petya"}) {
|
||
t.Errorf("комнаты: получено %v, ожидалось [petya]", got)
|
||
}
|
||
if got := ids(t, s, `SELECT nick FROM room_members`); !equal(got, []string{"petya"}) {
|
||
t.Errorf("состав: получено %v, ожидалось [petya]", got)
|
||
}
|
||
if got := ids(t, s, `SELECT DISTINCT nick FROM room_keys`); !equal(got, []string{"petya"}) {
|
||
t.Errorf("ключи: получено %v, ожидалось [petya]", got)
|
||
}
|
||
}
|
||
|
||
// Выход владельца передаёт владение; выход последнего удаляет комнату.
|
||
func TestLeaveRoom(t *testing.T) {
|
||
s, ctx := newTestStore(t, "marta", "petya")
|
||
makeRoom(t, s, ctx, "marta", 1000)
|
||
rekey(t, s, ctx, "room-1", "marta", "k2", []string{"petya"}, nil, []string{"marta", "petya"}, 2000)
|
||
|
||
// Не участник — ErrNotFound, комната не тронута.
|
||
if _, err := s.LeaveRoom(ctx, "нет", "marta"); !errors.Is(err, ErrNotFound) {
|
||
t.Errorf("выход из несуществующей комнаты: %v", err)
|
||
}
|
||
|
||
change, err := s.LeaveRoom(ctx, "room-1", "marta")
|
||
if err != nil {
|
||
t.Fatalf("LeaveRoom: %v", err)
|
||
}
|
||
if change.Room.Owner != "petya" {
|
||
t.Errorf("владелец: получено %q, ожидалось \"petya\"", change.Room.Owner)
|
||
}
|
||
if len(change.Members) != 1 || change.Members[0].Nick != "petya" {
|
||
t.Fatalf("оставшиеся: %+v", change.Members)
|
||
}
|
||
if change.Members[0].Key == nil || change.Members[0].Key.KeyID != "k2" {
|
||
t.Errorf("ключ оставшегося: %+v", change.Members[0].Key)
|
||
}
|
||
if !change.Room.NeedsRekey {
|
||
t.Error("needsRekey после выхода участника")
|
||
}
|
||
if len(change.Left) != 1 || change.Left[0].Nick != "marta" {
|
||
t.Errorf("room_left вышедшему: %+v", change.Left)
|
||
}
|
||
if got := ids(t, s, `SELECT DISTINCT nick FROM room_keys`); !equal(got, []string{"petya"}) {
|
||
t.Errorf("ключи после выхода: %v", got)
|
||
}
|
||
|
||
last, err := s.LeaveRoom(ctx, "room-1", "petya")
|
||
if err != nil {
|
||
t.Fatalf("LeaveRoom: %v", err)
|
||
}
|
||
// Комнаты больше нет: room слать некому, room_left уходит другим
|
||
// устройствам вышедшего (ADR-041).
|
||
if len(last.Members) != 0 {
|
||
t.Errorf("получатели после выхода последнего: %+v", last.Members)
|
||
}
|
||
if len(last.Left) != 1 || last.Left[0].Nick != "petya" {
|
||
t.Errorf("room_left после выхода последнего: %+v", last.Left)
|
||
}
|
||
if got := ids(t, s, `SELECT id FROM rooms`); len(got) != 0 {
|
||
t.Errorf("пустая комната осталась: %v", got)
|
||
}
|
||
if got := ids(t, s, `SELECT DISTINCT nick FROM room_keys`); len(got) != 0 {
|
||
t.Errorf("ключи удалённой комнаты остались: %v", got)
|
||
}
|
||
}
|