Этап 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:
+39
-4
@@ -15,6 +15,7 @@ import (
|
||||
|
||||
"github.com/xmatic-squad/bare/internal/auth"
|
||||
"github.com/xmatic-squad/bare/internal/config"
|
||||
"github.com/xmatic-squad/bare/internal/hub"
|
||||
"github.com/xmatic-squad/bare/internal/store"
|
||||
)
|
||||
|
||||
@@ -27,17 +28,36 @@ const maxLogPath = 256
|
||||
// csp — политика из ADR-021. HSTS ставит nginx, здесь его нет.
|
||||
const csp = "default-src 'self'; img-src 'self' data:; frame-ancestors 'none'; base-uri 'none'; form-action 'self'"
|
||||
|
||||
// server — общее для обработчиков: настройки, база, куда писать журнал.
|
||||
// server — общее для обработчиков: настройки, база, открытые потоки
|
||||
// событий, лимиты, куда писать журнал.
|
||||
type server struct {
|
||||
cfg *config.Config
|
||||
st *store.Store
|
||||
hub *hub.Hub
|
||||
msgs *buckets
|
||||
logw io.Writer
|
||||
}
|
||||
|
||||
// Handler — обработчик всех маршрутов и живые SSE-потоки за ним.
|
||||
type Handler struct {
|
||||
http.Handler
|
||||
hub *hub.Hub
|
||||
}
|
||||
|
||||
// Close закрывает открытые потоки событий. Без него остановка сервера
|
||||
// ждала бы, пока клиенты уйдут сами: у потока нет конца (ADR-004).
|
||||
func (h *Handler) Close() { h.hub.CloseAll() }
|
||||
|
||||
// New собирает обработчик: /api/, /healthz, всё остальное — статика.
|
||||
// logw — куда писать строки запросов и причины отказов; nil отключает лог.
|
||||
func New(cfg *config.Config, st *store.Store, static http.Handler, logw io.Writer) http.Handler {
|
||||
s := &server{cfg: cfg, st: st, logw: logw}
|
||||
func New(cfg *config.Config, st *store.Store, static http.Handler, logw io.Writer) *Handler {
|
||||
s := &server{
|
||||
cfg: cfg,
|
||||
st: st,
|
||||
hub: hub.New(),
|
||||
msgs: newBuckets(messagesPerMinute, messagesBurst),
|
||||
logw: logw,
|
||||
}
|
||||
fail := auth.Fail{Error: Error, Internal: s.internal}
|
||||
// Сессия проверяется на всех непубличных маршрутах (docs/protocol.md).
|
||||
private := auth.Require(st, fail)
|
||||
@@ -56,13 +76,28 @@ func New(cfg *config.Config, st *store.Store, static http.Handler, logw io.Write
|
||||
mux.Handle("POST /api/password", private(http.HandlerFunc(s.password)))
|
||||
mux.Handle("GET /api/users/{nick}", private(http.HandlerFunc(s.user)))
|
||||
|
||||
mux.Handle("POST /api/devices", private(http.HandlerFunc(s.createDevice)))
|
||||
mux.Handle("GET /api/devices", private(http.HandlerFunc(s.devices)))
|
||||
mux.Handle("DELETE /api/devices/{id}", private(http.HandlerFunc(s.deleteDevice)))
|
||||
|
||||
mux.Handle("GET /api/contacts", private(http.HandlerFunc(s.contacts)))
|
||||
mux.Handle("POST /api/contacts", private(http.HandlerFunc(s.addContact)))
|
||||
mux.Handle("DELETE /api/contacts/{nick}", private(http.HandlerFunc(s.deleteContact)))
|
||||
|
||||
mux.Handle("GET /api/events", private(http.HandlerFunc(s.events)))
|
||||
mux.Handle("POST /api/messages", private(http.HandlerFunc(s.sendMessage)))
|
||||
mux.Handle("POST /api/ack", private(http.HandlerFunc(s.ack)))
|
||||
|
||||
// Всё прочее под /api/ — 404, включая неподдерживаемый метод известного
|
||||
// пути: кода 405 в протоколе нет (ADR-026). Этот маршрут заодно не даёт
|
||||
// запросам к /api/ уходить в обработчик статики.
|
||||
mux.HandleFunc("/api/", func(w http.ResponseWriter, r *http.Request) { NotFound(w) })
|
||||
mux.Handle("/", static)
|
||||
|
||||
return logging(logw, headers(auth.Origin(cfg.Origin, fail)(limitBody(mux))))
|
||||
return &Handler{
|
||||
Handler: logging(logw, headers(auth.Origin(cfg.Origin, fail)(limitBody(mux)))),
|
||||
hub: s.hub,
|
||||
}
|
||||
}
|
||||
|
||||
func healthz(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
@@ -8,6 +8,7 @@ import (
|
||||
"net/http/httptest"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
|
||||
"github.com/xmatic-squad/bare/internal/api"
|
||||
@@ -23,7 +24,34 @@ type env struct {
|
||||
t *testing.T
|
||||
h http.Handler
|
||||
st *store.Store
|
||||
log *bytes.Buffer
|
||||
log *syncLog
|
||||
srv *httptest.Server
|
||||
}
|
||||
|
||||
// syncLog — журнал сервера в памяти. Под замком, потому что пишут в него
|
||||
// и обработчики, вызванные напрямую, и обработчики настоящего сервера
|
||||
// из live: у них разные горутины.
|
||||
type syncLog struct {
|
||||
mu sync.Mutex
|
||||
buf bytes.Buffer
|
||||
}
|
||||
|
||||
func (l *syncLog) Write(p []byte) (int, error) {
|
||||
l.mu.Lock()
|
||||
defer l.mu.Unlock()
|
||||
return l.buf.Write(p)
|
||||
}
|
||||
|
||||
func (l *syncLog) String() string {
|
||||
l.mu.Lock()
|
||||
defer l.mu.Unlock()
|
||||
return l.buf.String()
|
||||
}
|
||||
|
||||
func (l *syncLog) Reset() {
|
||||
l.mu.Lock()
|
||||
defer l.mu.Unlock()
|
||||
l.buf.Reset()
|
||||
}
|
||||
|
||||
func newEnv(t *testing.T) *env { return invited(t, "") }
|
||||
@@ -48,7 +76,7 @@ func invited(t *testing.T, code string) *env {
|
||||
VAPIDPublic: "vapid",
|
||||
InviteCode: code,
|
||||
}
|
||||
e := &env{t: t, st: st, log: &bytes.Buffer{}}
|
||||
e := &env{t: t, st: st, log: &syncLog{}}
|
||||
e.h = api.New(cfg, st, static, e.log)
|
||||
return e
|
||||
}
|
||||
@@ -81,6 +109,18 @@ func (e *env) do(method, target string, body any, opts ...func(*http.Request)) *
|
||||
return rec
|
||||
}
|
||||
|
||||
// live поднимает настоящий сервер на том же обработчике. Нужен потоку
|
||||
// событий: httptest.ResponseRecorder не отдаёт тело, пока обработчик
|
||||
// не вернулся, а поток не возвращается никогда.
|
||||
func (e *env) live() *httptest.Server {
|
||||
e.t.Helper()
|
||||
if e.srv == nil {
|
||||
e.srv = httptest.NewServer(e.h)
|
||||
e.t.Cleanup(e.srv.Close)
|
||||
}
|
||||
return e.srv
|
||||
}
|
||||
|
||||
func with(c *http.Cookie) func(*http.Request) {
|
||||
return func(r *http.Request) {
|
||||
if c != nil {
|
||||
@@ -89,6 +129,10 @@ func with(c *http.Cookie) func(*http.Request) {
|
||||
}
|
||||
}
|
||||
|
||||
func withDevice(id string) func(*http.Request) {
|
||||
return func(r *http.Request) { r.Header.Set("X-Device", id) }
|
||||
}
|
||||
|
||||
func withOrigin(value string) func(*http.Request) {
|
||||
return func(r *http.Request) {
|
||||
if value == "" {
|
||||
|
||||
@@ -0,0 +1,97 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"github.com/xmatic-squad/bare/internal/auth"
|
||||
"github.com/xmatic-squad/bare/internal/store"
|
||||
)
|
||||
|
||||
// Контакт — строка в списке чатов, не разрешение на переписку: писать
|
||||
// можно любому нику, согласия не требуется (ADR-019).
|
||||
|
||||
// GET /api/contacts — список чатов 1:1 с публичными ключами собеседников.
|
||||
func (s *server) contacts(w http.ResponseWriter, r *http.Request) {
|
||||
sess, _ := auth.From(r)
|
||||
list, err := s.st.Contacts(r.Context(), sess.Nick)
|
||||
if err != nil {
|
||||
s.internal(w, r, err)
|
||||
return
|
||||
}
|
||||
type contactOut struct {
|
||||
Nick string `json:"nick"`
|
||||
PublicKey json.RawMessage `json:"publicKey"`
|
||||
CreatedAt int64 `json:"createdAt"`
|
||||
}
|
||||
out := make([]contactOut, 0, len(list))
|
||||
for _, c := range list {
|
||||
out = append(out, contactOut{c.Nick, json.RawMessage(c.PublicKey), c.CreatedAt})
|
||||
}
|
||||
writeJSON(w, http.StatusOK, out)
|
||||
}
|
||||
|
||||
// POST /api/contacts — завести чат с ником вручную, до первого сообщения.
|
||||
func (s *server) addContact(w http.ResponseWriter, r *http.Request) {
|
||||
var in struct {
|
||||
Nick string `json:"nick"`
|
||||
}
|
||||
if !decode(w, r, &in) {
|
||||
return
|
||||
}
|
||||
sess, _ := auth.From(r)
|
||||
peer, ok := s.peer(w, r, in.Nick, sess.Nick)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
created, err := s.st.AddContact(r.Context(), sess.Nick, peer.Nick, time.Now().UnixMilli())
|
||||
if err != nil {
|
||||
s.internal(w, r, err)
|
||||
return
|
||||
}
|
||||
status := http.StatusOK
|
||||
if created {
|
||||
status = http.StatusCreated
|
||||
}
|
||||
writeJSON(w, status, struct {
|
||||
Nick string `json:"nick"`
|
||||
PublicKey json.RawMessage `json:"publicKey"`
|
||||
}{peer.Nick, json.RawMessage(peer.PublicKey)})
|
||||
}
|
||||
|
||||
// DELETE /api/contacts/{nick} — убрать чат из списка. Зеркальная строка
|
||||
// у собеседника остаётся: это не блокировка (ADR-019). Строки не было —
|
||||
// тот же 204, удалять нечего.
|
||||
func (s *server) deleteContact(w http.ResponseWriter, r *http.Request) {
|
||||
sess, _ := auth.From(r)
|
||||
if err := s.st.DeleteContact(r.Context(), sess.Nick, r.PathValue("nick")); err != nil {
|
||||
s.internal(w, r, err)
|
||||
return
|
||||
}
|
||||
noContent(w)
|
||||
}
|
||||
|
||||
// peer читает собеседника по нику. Порядок отказов — docs/protocol.md:
|
||||
// сначала существование ника, потом запрет писать себе.
|
||||
func (s *server) peer(w http.ResponseWriter, r *http.Request, nick, me string) (store.User, bool) {
|
||||
if !validNick(nick) {
|
||||
unknownUser(w)
|
||||
return store.User{}, false
|
||||
}
|
||||
u, err := s.st.User(r.Context(), nick)
|
||||
if errors.Is(err, store.ErrNotFound) {
|
||||
unknownUser(w)
|
||||
return store.User{}, false
|
||||
}
|
||||
if err != nil {
|
||||
s.internal(w, r, err)
|
||||
return store.User{}, false
|
||||
}
|
||||
if u.Nick == me {
|
||||
Error(w, http.StatusBadRequest, "self", "нельзя писать себе")
|
||||
return store.User{}, false
|
||||
}
|
||||
return u, true
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
package api_test
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// contactOut — строка ответа GET /api/contacts.
|
||||
type contactOut struct {
|
||||
Nick string `json:"nick"`
|
||||
PublicKey json.RawMessage `json:"publicKey"`
|
||||
CreatedAt int64 `json:"createdAt"`
|
||||
}
|
||||
|
||||
func (e *env) contacts(c *http.Cookie) []contactOut {
|
||||
e.t.Helper()
|
||||
rec := e.do(http.MethodGet, "/api/contacts", nil, with(c))
|
||||
expect(e.t, rec, http.StatusOK, "")
|
||||
var out []contactOut
|
||||
decodeBody(e.t, rec, &out)
|
||||
return out
|
||||
}
|
||||
|
||||
func TestContacts(t *testing.T) {
|
||||
e := newEnv(t)
|
||||
marta := e.signUp("marta")
|
||||
petya := e.signUp("petya")
|
||||
|
||||
if got := e.contacts(marta); len(got) != 0 {
|
||||
t.Fatalf("контакты нового аккаунта: %+v", got)
|
||||
}
|
||||
|
||||
rec := e.do(http.MethodPost, "/api/contacts", map[string]any{"nick": "petya"}, with(marta))
|
||||
expect(t, rec, http.StatusCreated, "")
|
||||
var added struct {
|
||||
Nick string `json:"nick"`
|
||||
PublicKey json.RawMessage `json:"publicKey"`
|
||||
}
|
||||
decodeBody(t, rec, &added)
|
||||
if added.Nick != "petya" || len(added.PublicKey) == 0 {
|
||||
t.Errorf("ответ: %s", rec.Body.String())
|
||||
}
|
||||
|
||||
// Повтор — 200 и та же строка.
|
||||
expect(t, e.do(http.MethodPost, "/api/contacts", map[string]any{"nick": "petya"}, with(marta)), http.StatusOK, "")
|
||||
if got := e.contacts(marta); len(got) != 1 || got[0].Nick != "petya" {
|
||||
t.Errorf("контакты marta: %+v", got)
|
||||
}
|
||||
// Зеркальной строки POST не заводит: она появляется при первом
|
||||
// сообщении (ADR-019).
|
||||
if got := e.contacts(petya); len(got) != 0 {
|
||||
t.Errorf("контакты petya: %+v", got)
|
||||
}
|
||||
|
||||
expect(t, e.do(http.MethodPost, "/api/contacts", map[string]any{"nick": "marta"}, with(marta)),
|
||||
http.StatusBadRequest, "self")
|
||||
expect(t, e.do(http.MethodPost, "/api/contacts", map[string]any{"nick": "kolya"}, with(marta)),
|
||||
http.StatusNotFound, "unknown_user")
|
||||
expect(t, e.do(http.MethodPost, "/api/contacts", map[string]any{"nick": "МАРТА"}, with(marta)),
|
||||
http.StatusNotFound, "unknown_user")
|
||||
}
|
||||
|
||||
// Удаляется только своя строка: зеркальная у собеседника остаётся,
|
||||
// это не блокировка (ADR-019).
|
||||
func TestDeleteContact(t *testing.T) {
|
||||
e := newEnv(t)
|
||||
marta, m1 := e.join("marta", 1)
|
||||
petya, _ := e.join("petya", 2)
|
||||
|
||||
expect(t, e.do(http.MethodPost, "/api/messages", message(ulid(nowMillis(), 3), "petya"),
|
||||
with(marta), withDevice(m1)), http.StatusAccepted, "")
|
||||
|
||||
expect(t, e.do(http.MethodDelete, "/api/contacts/petya", nil, with(marta)), http.StatusNoContent, "")
|
||||
if got := e.contacts(marta); len(got) != 0 {
|
||||
t.Errorf("контакты marta: %+v", got)
|
||||
}
|
||||
if got := e.contacts(petya); len(got) != 1 || got[0].Nick != "marta" {
|
||||
t.Errorf("контакты petya: %+v", got)
|
||||
}
|
||||
|
||||
// Удалять нечего — тот же ответ.
|
||||
expect(t, e.do(http.MethodDelete, "/api/contacts/petya", nil, with(marta)), http.StatusNoContent, "")
|
||||
expect(t, e.do(http.MethodDelete, "/api/contacts/kolya", nil, with(marta)), http.StatusNoContent, "")
|
||||
}
|
||||
@@ -0,0 +1,120 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"github.com/xmatic-squad/bare/internal/auth"
|
||||
"github.com/xmatic-squad/bare/internal/store"
|
||||
)
|
||||
|
||||
// deviceID — тело и ответ POST /api/devices: идентификатор выдаёт клиент
|
||||
// (ADR-017), сервер только проверяет форму и принадлежность.
|
||||
type deviceID struct {
|
||||
ID string `json:"id"`
|
||||
}
|
||||
|
||||
// POST /api/devices — регистрация устройства. Уже заведённое своё —
|
||||
// 200 и обновлённый last_seen; занятое чужим — 409, клиент берёт новый id.
|
||||
func (s *server) createDevice(w http.ResponseWriter, r *http.Request) {
|
||||
var in deviceID
|
||||
if !decode(w, r, &in) {
|
||||
return
|
||||
}
|
||||
if !validID(in.ID) {
|
||||
Invalid(w, "id", "id — не 16 байт base64url")
|
||||
return
|
||||
}
|
||||
sess, _ := auth.From(r)
|
||||
created, err := s.st.RegisterDevice(r.Context(), in.ID, sess.Nick, sess.TokenHash, time.Now().UnixMilli())
|
||||
if errors.Is(err, store.ErrDeviceTaken) {
|
||||
Error(w, http.StatusConflict, "device_conflict", "такое устройство уже есть")
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
s.internal(w, r, err)
|
||||
return
|
||||
}
|
||||
status := http.StatusOK
|
||||
if created {
|
||||
status = http.StatusCreated
|
||||
}
|
||||
writeJSON(w, status, deviceID{in.ID})
|
||||
}
|
||||
|
||||
// deviceOut — строка ответа GET /api/devices.
|
||||
type deviceOut struct {
|
||||
ID string `json:"id"`
|
||||
CreatedAt int64 `json:"createdAt"`
|
||||
LastSeen int64 `json:"lastSeen"`
|
||||
HasPush bool `json:"hasPush"`
|
||||
Current bool `json:"current"`
|
||||
}
|
||||
|
||||
// GET /api/devices — устройства аккаунта. Самой push-подписки в ответе
|
||||
// нет, только факт её наличия.
|
||||
func (s *server) devices(w http.ResponseWriter, r *http.Request) {
|
||||
sess, _ := auth.From(r)
|
||||
list, err := s.st.Devices(r.Context(), sess.Nick)
|
||||
if err != nil {
|
||||
s.internal(w, r, err)
|
||||
return
|
||||
}
|
||||
out := make([]deviceOut, 0, len(list))
|
||||
for _, d := range list {
|
||||
out = append(out, deviceOut{
|
||||
ID: d.ID,
|
||||
CreatedAt: d.CreatedAt,
|
||||
LastSeen: d.LastSeen,
|
||||
HasPush: d.HasPush,
|
||||
Current: d.ID == sess.DeviceID,
|
||||
})
|
||||
}
|
||||
writeJSON(w, http.StatusOK, out)
|
||||
}
|
||||
|
||||
// DELETE /api/devices/{id} — удаление устройства: очередь, подписка
|
||||
// и сессии уходят каскадом, открытый поток событий закрывается.
|
||||
//
|
||||
// Чужое и несуществующее устройство отвечают тем же 204: удалять нечего,
|
||||
// а отдельного кода на этот случай в протоколе нет (docs/protocol.md).
|
||||
func (s *server) deleteDevice(w http.ResponseWriter, r *http.Request) {
|
||||
sess, _ := auth.From(r)
|
||||
id := r.PathValue("id")
|
||||
deleted, err := s.st.DeleteDevice(r.Context(), id, sess.Nick)
|
||||
if err != nil {
|
||||
s.internal(w, r, err)
|
||||
return
|
||||
}
|
||||
if deleted {
|
||||
s.hub.Close(id)
|
||||
}
|
||||
noContent(w)
|
||||
}
|
||||
|
||||
// device читает X-Device и проверяет, что устройство принадлежит
|
||||
// пользователю сессии. Заголовка нет, форма кривая, устройство чужое —
|
||||
// всё это 403 unknown_device (docs/protocol.md, «Общие правила»).
|
||||
func (s *server) device(w http.ResponseWriter, r *http.Request) (string, bool) {
|
||||
sess, _ := auth.From(r)
|
||||
id := r.Header.Get("X-Device")
|
||||
if !validID(id) {
|
||||
unknownDevice(w)
|
||||
return "", false
|
||||
}
|
||||
owned, err := s.st.DeviceOwned(r.Context(), id, sess.Nick)
|
||||
if err != nil {
|
||||
s.internal(w, r, err)
|
||||
return "", false
|
||||
}
|
||||
if !owned {
|
||||
unknownDevice(w)
|
||||
return "", false
|
||||
}
|
||||
return id, true
|
||||
}
|
||||
|
||||
func unknownDevice(w http.ResponseWriter) {
|
||||
Error(w, http.StatusForbidden, "unknown_device", "это устройство не ваше")
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
@@ -0,0 +1,118 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"github.com/xmatic-squad/bare/internal/auth"
|
||||
)
|
||||
|
||||
// pingEvery — период комментария-пинга: он держит соединение живым
|
||||
// через прокси и показывает клиенту, что поток цел (docs/protocol.md).
|
||||
const pingEvery = 20 * time.Second
|
||||
|
||||
// GET /api/events?device= — поток событий устройства (ADR-004).
|
||||
// Устройство передаётся в query: EventSource не умеет заголовки.
|
||||
//
|
||||
// Last-Event-ID игнорируется: механизм восстановления — не докрутка
|
||||
// по идентификатору, а повторная выдача очереди при каждом подключении.
|
||||
func (s *server) events(w http.ResponseWriter, r *http.Request) {
|
||||
sess, _ := auth.From(r)
|
||||
device := r.URL.Query().Get("device")
|
||||
if !validID(device) {
|
||||
unknownDevice(w)
|
||||
return
|
||||
}
|
||||
owned, err := s.st.DeviceOwned(r.Context(), device, sess.Nick)
|
||||
if err != nil {
|
||||
s.internal(w, r, err)
|
||||
return
|
||||
}
|
||||
if !owned {
|
||||
unknownDevice(w)
|
||||
return
|
||||
}
|
||||
|
||||
// Порядок — docs/protocol.md, «События»: сначала push_pending и
|
||||
// last_seen, потом поток, потом очередь. Сорвавшаяся запись last_seen
|
||||
// не должна рвать исправный поток устройства, а он был бы уже закрыт
|
||||
// открытием нового.
|
||||
now := time.Now().UnixMilli()
|
||||
if err := s.st.TouchDevice(r.Context(), device, now); err != nil {
|
||||
s.internal(w, r, err)
|
||||
return
|
||||
}
|
||||
|
||||
// Поток открывается до чтения очереди: конверт, попавший в очередь
|
||||
// между выборкой и подпиской, иначе пролежал бы там до следующего
|
||||
// подключения. Обратная крайность — дубль, а его клиент сливает по id
|
||||
// (ADR-017). Открытие закрывает прежний поток этого устройства.
|
||||
stream := s.hub.Open(device)
|
||||
defer stream.Close()
|
||||
|
||||
queued, err := s.st.Queue(r.Context(), device)
|
||||
if err != nil {
|
||||
s.internal(w, r, err)
|
||||
return
|
||||
}
|
||||
|
||||
head := w.Header()
|
||||
head.Set("Content-Type", "text/event-stream")
|
||||
head.Set("Cache-Control", "no-cache")
|
||||
// nginx буферизует ответы проксируемых приложений; для потока это
|
||||
// означало бы, что события копятся и не уходят (docs/deploy.md).
|
||||
head.Set("X-Accel-Buffering", "no")
|
||||
w.WriteHeader(http.StatusOK)
|
||||
|
||||
send := sender(w)
|
||||
for _, envelope := range queued {
|
||||
if !send("msg", envelope) {
|
||||
return
|
||||
}
|
||||
}
|
||||
if !send("ready", "{}") {
|
||||
return
|
||||
}
|
||||
|
||||
ping := time.NewTicker(pingEvery)
|
||||
defer ping.Stop()
|
||||
for {
|
||||
select {
|
||||
case <-r.Context().Done():
|
||||
// Клиент ушёл.
|
||||
return
|
||||
case <-stream.Done():
|
||||
// Поток закрыли: новое соединение того же устройства,
|
||||
// удаление устройства или остановка сервера.
|
||||
return
|
||||
case ev := <-stream.Events():
|
||||
if !send(ev.Name, ev.Data) {
|
||||
return
|
||||
}
|
||||
case <-ping.C:
|
||||
if !write(w, ": ping\n\n") {
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// sender собирает функцию записи события. Данные — компактный JSON
|
||||
// без переводов строки, поэтому кадр SSE собирается одной строкой data.
|
||||
// Ответ false означает, что писать больше некуда: соединение оборвалось.
|
||||
func sender(w http.ResponseWriter) func(name, data string) bool {
|
||||
return func(name, data string) bool {
|
||||
return write(w, fmt.Sprintf("event: %s\ndata: %s\n\n", name, data))
|
||||
}
|
||||
}
|
||||
|
||||
func write(w http.ResponseWriter, frame string) bool {
|
||||
if _, err := io.WriteString(w, frame); err != nil {
|
||||
return false
|
||||
}
|
||||
// Без Flush кадр остался бы в буфере net/http до конца ответа,
|
||||
// а конца у потока нет.
|
||||
return http.NewResponseController(w).Flush() == nil
|
||||
}
|
||||
@@ -0,0 +1,284 @@
|
||||
package api_test
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"context"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// wait — сколько тест ждёт события. Всё локально, задержек быть не должно.
|
||||
const wait = 2 * time.Second
|
||||
|
||||
// sseEvent — одно событие потока.
|
||||
type sseEvent struct {
|
||||
name string
|
||||
data string
|
||||
}
|
||||
|
||||
// stream — открытый GET /api/events. Идёт через настоящий сервер:
|
||||
// httptest.ResponseRecorder не отдаёт тело, пока обработчик не вернулся.
|
||||
type stream struct {
|
||||
t *testing.T
|
||||
ctx context.Context
|
||||
cancel context.CancelFunc
|
||||
events chan sseEvent
|
||||
head http.Header
|
||||
}
|
||||
|
||||
// open подключается к потоку событий устройства.
|
||||
func (e *env) open(device string, c *http.Cookie) *stream {
|
||||
e.t.Helper()
|
||||
srv := e.live()
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet,
|
||||
srv.URL+"/api/events?device="+url.QueryEscape(device), nil)
|
||||
if err != nil {
|
||||
cancel()
|
||||
e.t.Fatalf("запрос: %v", err)
|
||||
}
|
||||
req.AddCookie(c)
|
||||
resp, err := srv.Client().Do(req)
|
||||
if err != nil {
|
||||
cancel()
|
||||
e.t.Fatalf("подключение: %v", err)
|
||||
}
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
resp.Body.Close()
|
||||
cancel()
|
||||
e.t.Fatalf("статус потока: получено %d, ожидалось 200", resp.StatusCode)
|
||||
}
|
||||
s := &stream{t: e.t, ctx: ctx, cancel: cancel, events: make(chan sseEvent, 64), head: resp.Header}
|
||||
go s.read(resp.Body)
|
||||
e.t.Cleanup(s.close)
|
||||
return s
|
||||
}
|
||||
|
||||
// read разбирает кадры SSE: строки event и data, пустая строка — конец
|
||||
// события, строка с двоеточия — комментарий-пинг.
|
||||
func (s *stream) read(body io.ReadCloser) {
|
||||
defer body.Close()
|
||||
defer close(s.events)
|
||||
|
||||
sc := bufio.NewScanner(body)
|
||||
var ev sseEvent
|
||||
for sc.Scan() {
|
||||
line := sc.Text()
|
||||
switch {
|
||||
case line == "":
|
||||
if ev.name == "" {
|
||||
continue
|
||||
}
|
||||
select {
|
||||
case s.events <- ev:
|
||||
case <-s.ctx.Done():
|
||||
return
|
||||
}
|
||||
ev = sseEvent{}
|
||||
case strings.HasPrefix(line, ":"):
|
||||
case strings.HasPrefix(line, "event: "):
|
||||
ev.name = strings.TrimPrefix(line, "event: ")
|
||||
case strings.HasPrefix(line, "data: "):
|
||||
ev.data = strings.TrimPrefix(line, "data: ")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// next ждёт следующее событие.
|
||||
func (s *stream) next() sseEvent {
|
||||
s.t.Helper()
|
||||
select {
|
||||
case ev, ok := <-s.events:
|
||||
if !ok {
|
||||
s.t.Fatal("поток закрылся, события нет")
|
||||
}
|
||||
return ev
|
||||
case <-time.After(wait):
|
||||
s.t.Fatal("событие не пришло")
|
||||
}
|
||||
return sseEvent{}
|
||||
}
|
||||
|
||||
// untilReady собирает события до ready — то, что лежало в очереди.
|
||||
func (s *stream) untilReady() []sseEvent {
|
||||
s.t.Helper()
|
||||
var out []sseEvent
|
||||
for {
|
||||
ev := s.next()
|
||||
if ev.name == "ready" {
|
||||
if ev.data != "{}" {
|
||||
s.t.Errorf("данные ready: получено %q, ожидалось \"{}\"", ev.data)
|
||||
}
|
||||
return out
|
||||
}
|
||||
out = append(out, ev)
|
||||
}
|
||||
}
|
||||
|
||||
// ended ждёт, что поток закроет сервер.
|
||||
func (s *stream) ended() {
|
||||
s.t.Helper()
|
||||
select {
|
||||
case ev, ok := <-s.events:
|
||||
if ok {
|
||||
s.t.Fatalf("вместо закрытия пришло событие %q", ev.name)
|
||||
}
|
||||
case <-time.After(wait):
|
||||
s.t.Fatal("поток не закрылся")
|
||||
}
|
||||
}
|
||||
|
||||
func (s *stream) close() { s.cancel() }
|
||||
|
||||
// Порядок после подключения: очередь, ready, живые события
|
||||
// (docs/protocol.md, «События»).
|
||||
func TestEventsQueueThenReady(t *testing.T) {
|
||||
e := newEnv(t)
|
||||
marta, m1 := e.join("marta", 1)
|
||||
petya, p1 := e.join("petya", 2)
|
||||
|
||||
first := ulid(nowMillis(), 3)
|
||||
second := ulid(nowMillis()+1, 4)
|
||||
for _, id := range []string{first, second} {
|
||||
expect(t, e.do(http.MethodPost, "/api/messages", message(id, "petya"), with(marta), withDevice(m1)),
|
||||
http.StatusAccepted, "")
|
||||
}
|
||||
|
||||
s := e.open(p1, petya)
|
||||
for header, value := range map[string]string{
|
||||
"Content-Type": "text/event-stream",
|
||||
"Cache-Control": "no-cache",
|
||||
"X-Accel-Buffering": "no",
|
||||
} {
|
||||
if got := s.head.Get(header); got != value {
|
||||
t.Errorf("%s: получено %q, ожидалось %q", header, got, value)
|
||||
}
|
||||
}
|
||||
|
||||
queued := s.untilReady()
|
||||
if len(queued) != 2 {
|
||||
t.Fatalf("событий из очереди: получено %d, ожидалось 2", len(queued))
|
||||
}
|
||||
for i, ev := range queued {
|
||||
if ev.name != "msg" {
|
||||
t.Errorf("событие %d: получено %q, ожидалось \"msg\"", i, ev.name)
|
||||
}
|
||||
if !strings.Contains(ev.data, `"from":"marta"`) {
|
||||
t.Errorf("конверт %d: %s", i, ev.data)
|
||||
}
|
||||
}
|
||||
if !strings.Contains(queued[0].data, first) || !strings.Contains(queued[1].data, second) {
|
||||
t.Errorf("порядок очереди: %q, %q", queued[0].data, queued[1].data)
|
||||
}
|
||||
|
||||
// Реконнект без ACK повторяет очередь целиком: Last-Event-ID сервер
|
||||
// не смотрит (docs/protocol.md, «События»).
|
||||
s.close()
|
||||
again := e.open(p1, petya)
|
||||
if got := again.untilReady(); len(got) != 2 {
|
||||
t.Fatalf("после реконнекта: получено %d событий, ожидалось 2", len(got))
|
||||
}
|
||||
|
||||
// После ACK очередь пуста, остаётся только ready.
|
||||
expect(t, e.do(http.MethodPost, "/api/ack", map[string]any{"ids": []string{first, second}},
|
||||
with(petya), withDevice(p1)), http.StatusNoContent, "")
|
||||
again.close()
|
||||
third := e.open(p1, petya)
|
||||
if got := third.untilReady(); len(got) != 0 {
|
||||
t.Errorf("после ack: получено %d событий, ожидалось 0", len(got))
|
||||
}
|
||||
}
|
||||
|
||||
// Подключённое устройство получает конверт сразу после коммита.
|
||||
func TestEventsLive(t *testing.T) {
|
||||
e := newEnv(t)
|
||||
marta, m1 := e.join("marta", 1)
|
||||
petya, p1 := e.join("petya", 2)
|
||||
|
||||
s := e.open(p1, petya)
|
||||
if got := s.untilReady(); len(got) != 0 {
|
||||
t.Fatalf("очередь нового устройства: %+v", got)
|
||||
}
|
||||
|
||||
id := ulid(nowMillis(), 3)
|
||||
expect(t, e.do(http.MethodPost, "/api/messages", message(id, "petya"), with(marta), withDevice(m1)),
|
||||
http.StatusAccepted, "")
|
||||
|
||||
ev := s.next()
|
||||
if ev.name != "msg" || !strings.Contains(ev.data, id) {
|
||||
t.Errorf("живое событие: %+v", ev)
|
||||
}
|
||||
// Живая доставка не отменяет ACK: конверт лежит в очереди до него.
|
||||
if got := e.queue(p1); len(got) != 1 {
|
||||
t.Errorf("очередь: получено %d конвертов, ожидался 1", len(got))
|
||||
}
|
||||
}
|
||||
|
||||
// Отправитель эха не получает даже живьём, другие его устройства — да.
|
||||
func TestEventsNoEchoToSender(t *testing.T) {
|
||||
e := newEnv(t)
|
||||
marta, m1 := e.join("marta", 1)
|
||||
m2 := e.addDevice(marta, deviceOf(2))
|
||||
e.join("petya", 3)
|
||||
|
||||
sender := e.open(m1, marta)
|
||||
sender.untilReady()
|
||||
other := e.open(m2, marta)
|
||||
other.untilReady()
|
||||
|
||||
id := ulid(nowMillis(), 4)
|
||||
expect(t, e.do(http.MethodPost, "/api/messages", message(id, "petya"), with(marta), withDevice(m1)),
|
||||
http.StatusAccepted, "")
|
||||
|
||||
if ev := other.next(); ev.name != "msg" || !strings.Contains(ev.data, id) {
|
||||
t.Errorf("второе устройство отправителя: %+v", ev)
|
||||
}
|
||||
select {
|
||||
case ev := <-sender.events:
|
||||
t.Errorf("эхо отправившему устройству: %+v", ev)
|
||||
case <-time.After(200 * time.Millisecond):
|
||||
}
|
||||
}
|
||||
|
||||
// Одно соединение на устройство: новое закрывает предыдущее.
|
||||
func TestEventsSingleConnection(t *testing.T) {
|
||||
e := newEnv(t)
|
||||
petya, p1 := e.join("petya", 1)
|
||||
|
||||
first := e.open(p1, petya)
|
||||
first.untilReady()
|
||||
second := e.open(p1, petya)
|
||||
second.untilReady()
|
||||
|
||||
first.ended()
|
||||
}
|
||||
|
||||
// Удаление устройства закрывает его поток (docs/protocol.md, «Устройства»).
|
||||
func TestEventsClosedOnDeviceDelete(t *testing.T) {
|
||||
e := newEnv(t)
|
||||
petya, p1 := e.join("petya", 1)
|
||||
|
||||
s := e.open(p1, petya)
|
||||
s.untilReady()
|
||||
|
||||
expect(t, e.do(http.MethodDelete, "/api/devices/"+p1, nil, with(petya)), http.StatusNoContent, "")
|
||||
s.ended()
|
||||
}
|
||||
|
||||
// Чужое устройство в query — 403 unknown_device, поток не открывается.
|
||||
func TestEventsUnknownDevice(t *testing.T) {
|
||||
e := newEnv(t)
|
||||
_, martaDevice := e.join("marta", 1)
|
||||
petya, _ := e.join("petya", 2)
|
||||
|
||||
for _, device := range []string{martaDevice, deviceOf(9), "мусор", ""} {
|
||||
rec := e.do(http.MethodGet, "/api/events?device="+url.QueryEscape(device), nil, with(petya))
|
||||
expect(t, rec, http.StatusForbidden, "unknown_device")
|
||||
}
|
||||
// Без сессии — обычный 401.
|
||||
expect(t, e.do(http.MethodGet, "/api/events?device="+martaDevice, nil), http.StatusUnauthorized, "unauthenticated")
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"math"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Лимит сообщений (ADR-021): 30 в минуту на пользователя, пакет 10.
|
||||
// Остальные лимиты — этап 6.
|
||||
const (
|
||||
messagesPerMinute = 30
|
||||
messagesBurst = 10
|
||||
)
|
||||
|
||||
// sweepAt — с какого размера карты имеет смысл выкидывать полные вёдра.
|
||||
const sweepAt = 1024
|
||||
|
||||
// buckets — token bucket в памяти сервера, по ведру на ключ (ник).
|
||||
// Рестарт обнуляет лимиты: для маленького сервера это принято (ADR-021).
|
||||
type buckets struct {
|
||||
mu sync.Mutex
|
||||
rate float64 // токенов в секунду
|
||||
burst float64
|
||||
seen map[string]*bucket
|
||||
}
|
||||
|
||||
type bucket struct {
|
||||
tokens float64
|
||||
at time.Time
|
||||
}
|
||||
|
||||
func newBuckets(perMinute, burst int) *buckets {
|
||||
return &buckets{
|
||||
rate: float64(perMinute) / 60,
|
||||
burst: float64(burst),
|
||||
seen: make(map[string]*bucket),
|
||||
}
|
||||
}
|
||||
|
||||
// take забирает токен. Второе значение — можно ли; если нет, первое —
|
||||
// сколько ждать до следующего токена.
|
||||
func (b *buckets) take(key string, now time.Time) (time.Duration, bool) {
|
||||
b.mu.Lock()
|
||||
defer b.mu.Unlock()
|
||||
|
||||
e, ok := b.seen[key]
|
||||
if !ok {
|
||||
if len(b.seen) >= sweepAt {
|
||||
b.sweep(now)
|
||||
}
|
||||
e = &bucket{tokens: b.burst, at: now}
|
||||
b.seen[key] = e
|
||||
}
|
||||
e.tokens = math.Min(b.burst, e.tokens+b.refill(e.at, now))
|
||||
e.at = now
|
||||
if e.tokens < 1 {
|
||||
return time.Duration((1 - e.tokens) / b.rate * float64(time.Second)), false
|
||||
}
|
||||
e.tokens--
|
||||
return 0, true
|
||||
}
|
||||
|
||||
// refill — сколько токенов набежало. Время назад не идёт: часы могли
|
||||
// прыгнуть, но долг за это выставлять некому.
|
||||
func (b *buckets) refill(since, now time.Time) float64 {
|
||||
d := now.Sub(since)
|
||||
if d <= 0 {
|
||||
return 0
|
||||
}
|
||||
return d.Seconds() * b.rate
|
||||
}
|
||||
|
||||
// sweep выкидывает полные вёдра: они уже ничего не помнят. Иначе карта
|
||||
// росла бы на каждый новый ник и не уменьшалась никогда.
|
||||
func (b *buckets) sweep(now time.Time) {
|
||||
for key, e := range b.seen {
|
||||
if e.tokens+b.refill(e.at, now) >= b.burst {
|
||||
delete(b.seen, key)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// retryAfter — значение заголовка в секундах, не меньше одной: нулевое
|
||||
// ожидание после отказа сбивало бы клиента с толку.
|
||||
func retryAfter(wait time.Duration) int {
|
||||
if wait < time.Second {
|
||||
return 1
|
||||
}
|
||||
return int(math.Ceil(wait.Seconds()))
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,201 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"github.com/xmatic-squad/bare/internal/auth"
|
||||
"github.com/xmatic-squad/bare/internal/hub"
|
||||
"github.com/xmatic-squad/bare/internal/store"
|
||||
)
|
||||
|
||||
// clockSkew — на сколько метка времени ULID вправе разойтись с часами
|
||||
// сервера (ADR-017).
|
||||
const clockSkew = 5 * time.Minute
|
||||
|
||||
// dmKeyID — keyId личного чата: ключ выводится из ECDH, идентификатора
|
||||
// у него нет (docs/crypto.md, «Сообщение»).
|
||||
const dmKeyID = "dm"
|
||||
|
||||
// maxAck — сколько идентификаторов принимает один ACK.
|
||||
const maxAck = 500
|
||||
|
||||
// target — адресат конверта: ровно одно из двух.
|
||||
type target struct {
|
||||
DM string `json:"dm,omitempty"`
|
||||
Room string `json:"room,omitempty"`
|
||||
}
|
||||
|
||||
// envelope — конверт из docs/protocol.md. Порядок полей — как в нём.
|
||||
// from и ts ставит сервер: клиентские значения не читаются вовсе (ADR-017).
|
||||
type envelope struct {
|
||||
ID string `json:"id"`
|
||||
To target `json:"to"`
|
||||
From string `json:"from"`
|
||||
KeyID string `json:"keyId"`
|
||||
IV string `json:"iv"`
|
||||
CT string `json:"ct"`
|
||||
TS int64 `json:"ts"`
|
||||
}
|
||||
|
||||
// messageIn — тело POST /api/messages. Полей from и ts здесь нет
|
||||
// намеренно: что бы клиент ни прислал, сервер ставит своё (ADR-017).
|
||||
type messageIn struct {
|
||||
ID string `json:"id"`
|
||||
To target `json:"to"`
|
||||
KeyID string `json:"keyId"`
|
||||
IV string `json:"iv"`
|
||||
CT string `json:"ct"`
|
||||
}
|
||||
|
||||
// POST /api/messages — отправка. Сервер не умеет проверять шифротекст,
|
||||
// он проверяет форму и раскладывает конверт по очередям (ADR-008).
|
||||
// Порядок проверок — docs/protocol.md, «Сообщения».
|
||||
func (s *server) sendMessage(w http.ResponseWriter, r *http.Request) {
|
||||
device, ok := s.device(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
var in messageIn
|
||||
if !decode(w, r, &in) {
|
||||
return
|
||||
}
|
||||
ms, ok := checkForm(w, in)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
|
||||
now := time.Now()
|
||||
if d := now.Sub(time.UnixMilli(ms)); d > clockSkew || d < -clockSkew {
|
||||
Error(w, http.StatusBadRequest, "clock_skew",
|
||||
"проверьте часы на устройстве: расхождение больше 5 минут")
|
||||
return
|
||||
}
|
||||
|
||||
if in.To.Room != "" {
|
||||
// Комнаты — этап 3. Участников нет ни у одной комнаты, потому что
|
||||
// нет и самих комнат: единственный возможный ответ — not_member.
|
||||
Error(w, http.StatusForbidden, "not_member", "вы не участник комнаты")
|
||||
return
|
||||
}
|
||||
sess, _ := auth.From(r)
|
||||
if _, ok := s.peer(w, r, in.To.DM, sess.Nick); !ok {
|
||||
return
|
||||
}
|
||||
|
||||
if wait, ok := s.msgs.take(sess.Nick, now); !ok {
|
||||
s.rateLimited(w, wait)
|
||||
return
|
||||
}
|
||||
|
||||
env := envelope{
|
||||
ID: in.ID,
|
||||
To: target{DM: in.To.DM},
|
||||
From: sess.Nick,
|
||||
KeyID: in.KeyID,
|
||||
IV: in.IV,
|
||||
CT: in.CT,
|
||||
TS: now.UnixMilli(),
|
||||
}
|
||||
raw, err := json.Marshal(env)
|
||||
if err != nil {
|
||||
s.internal(w, r, err)
|
||||
return
|
||||
}
|
||||
devices, err := s.st.DeliverDM(r.Context(), store.Delivery{
|
||||
From: env.From,
|
||||
To: env.To.DM,
|
||||
Exclude: device,
|
||||
MsgID: env.ID,
|
||||
Envelope: string(raw),
|
||||
Now: env.TS,
|
||||
})
|
||||
if err != nil {
|
||||
s.internal(w, r, err)
|
||||
return
|
||||
}
|
||||
// Очередь уже записана: подключённое устройство получает конверт
|
||||
// сразу, остальные — при подключении. Пуши — этап 4.
|
||||
for _, id := range devices {
|
||||
s.hub.Send(id, hub.Event{Name: "msg", Data: string(raw)})
|
||||
}
|
||||
writeJSON(w, http.StatusAccepted, struct {
|
||||
ID string `json:"id"`
|
||||
TS int64 `json:"ts"`
|
||||
}{env.ID, env.TS})
|
||||
}
|
||||
|
||||
// checkForm проверяет форму полей конверта (docs/crypto.md, «Что сервер
|
||||
// проверяет») и отдаёт метку времени из ULID. Ответ об ошибке уже написан,
|
||||
// если вернулось false.
|
||||
func checkForm(w http.ResponseWriter, in messageIn) (int64, bool) {
|
||||
ms, ok := ulidTime(in.ID)
|
||||
if !ok {
|
||||
Invalid(w, "id", "id — не ulid из 26 символов")
|
||||
return 0, false
|
||||
}
|
||||
if (in.To.DM == "") == (in.To.Room == "") {
|
||||
Invalid(w, "to", "to — ровно одно из dm и room")
|
||||
return 0, false
|
||||
}
|
||||
if in.To.DM != "" {
|
||||
if !validNick(in.To.DM) {
|
||||
Invalid(w, "to", "ник: 2–32 символа, a–z, 0–9, _")
|
||||
return 0, false
|
||||
}
|
||||
if in.KeyID != dmKeyID {
|
||||
Invalid(w, "keyId", `keyId личного чата — "dm"`)
|
||||
return 0, false
|
||||
}
|
||||
} else {
|
||||
if !validID(in.To.Room) {
|
||||
Invalid(w, "to", "room — не 16 байт base64url")
|
||||
return 0, false
|
||||
}
|
||||
if !validID(in.KeyID) {
|
||||
Invalid(w, "keyId", "keyId — не 16 байт base64url")
|
||||
return 0, false
|
||||
}
|
||||
}
|
||||
if _, ok := decodeExactly(in.IV, ivLen); !ok {
|
||||
Invalid(w, "iv", "iv — не 12 байт base64url")
|
||||
return 0, false
|
||||
}
|
||||
if ct, err := b64.DecodeString(in.CT); err != nil || len(ct) < minCTLen {
|
||||
Invalid(w, "ct", "ct — не base64url или слишком короткий")
|
||||
return 0, false
|
||||
}
|
||||
return ms, true
|
||||
}
|
||||
|
||||
// POST /api/ack — клиент записал сообщения в IndexedDB: из очереди
|
||||
// устройства их можно убрать (ADR-008).
|
||||
func (s *server) ack(w http.ResponseWriter, r *http.Request) {
|
||||
device, ok := s.device(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
var in struct {
|
||||
IDs []string `json:"ids"`
|
||||
}
|
||||
if !decode(w, r, &in) {
|
||||
return
|
||||
}
|
||||
if len(in.IDs) > maxAck {
|
||||
Invalid(w, "ids", "не больше 500 идентификаторов")
|
||||
return
|
||||
}
|
||||
if err := s.st.Ack(r.Context(), device, in.IDs); err != nil {
|
||||
s.internal(w, r, err)
|
||||
return
|
||||
}
|
||||
noContent(w)
|
||||
}
|
||||
|
||||
// rateLimited — 429 с Retry-After в секундах (ADR-021).
|
||||
func (s *server) rateLimited(w http.ResponseWriter, wait time.Duration) {
|
||||
w.Header().Set("Retry-After", strconv.Itoa(retryAfter(wait)))
|
||||
Error(w, http.StatusTooManyRequests, "rate_limited", "слишком часто, попробуйте позже")
|
||||
}
|
||||
@@ -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, "")
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
package api
|
||||
|
||||
import "strings"
|
||||
|
||||
// ULID — 48 бит миллисекунд и 80 бит случайности, Crockford base32,
|
||||
// 26 символов (docs/crypto.md, «Идентификаторы»). Сервер читает из него
|
||||
// только время: расхождение с серверными часами больше пяти минут —
|
||||
// clock_skew (ADR-017).
|
||||
const ulidLen = 26
|
||||
|
||||
// crockford — алфавит Crockford base32: без I, L, O и U. Канонический
|
||||
// ULID записывается заглавными; строчные буквы сервер не принимает —
|
||||
// идентификатор входит в AAD шифротекста побайтно (docs/crypto.md).
|
||||
const crockford = "0123456789ABCDEFGHJKMNPQRSTVWXYZ"
|
||||
|
||||
// ulidTime разбирает ULID и отдаёт его метку времени в миллисекундах.
|
||||
func ulidTime(id string) (int64, bool) {
|
||||
if len(id) != ulidLen {
|
||||
return 0, false
|
||||
}
|
||||
var ms int64
|
||||
for i := 0; i < ulidLen; i++ {
|
||||
v := strings.IndexByte(crockford, id[i])
|
||||
if v < 0 {
|
||||
return 0, false
|
||||
}
|
||||
if i < 10 {
|
||||
ms = ms<<5 | int64(v)
|
||||
}
|
||||
}
|
||||
// Первые десять символов — 50 бит, времени отведено 48: старшие два
|
||||
// обязаны быть нулевыми.
|
||||
if ms > 1<<48-1 {
|
||||
return 0, false
|
||||
}
|
||||
return ms, true
|
||||
}
|
||||
@@ -14,6 +14,7 @@ import (
|
||||
// base64url, длины, версии (docs/crypto.md, «Что сервер проверяет»).
|
||||
const (
|
||||
authKeyLen = 32 // байт
|
||||
idLen = 16 // байт: deviceId, keyId, roomId
|
||||
ivLen = 12 // байт
|
||||
minCTLen = 16 // байт: короче тега AES-GCM шифротекста не бывает
|
||||
maxBlob = 8 << 10 // ключевой блоб, docs/protocol.md
|
||||
@@ -39,6 +40,13 @@ func decodeExactly(s string, n int) ([]byte, bool) {
|
||||
// authKey разбирает authKey клиента: base64url ровно 32 байта.
|
||||
func authKey(s string) ([]byte, bool) { return decodeExactly(s, authKeyLen) }
|
||||
|
||||
// validID — deviceId, keyId и roomId устроены одинаково: 16 случайных
|
||||
// байт base64url, 22 символа (docs/crypto.md, «Идентификаторы»).
|
||||
func validID(s string) bool {
|
||||
_, ok := decodeExactly(s, idLen)
|
||||
return ok
|
||||
}
|
||||
|
||||
// jwkPublic — публичный ключ в том виде, в каком сервер его хранит
|
||||
// и отдаёт: четыре поля и ничего больше.
|
||||
type jwkPublic struct {
|
||||
|
||||
Reference in New Issue
Block a user