хранилище переехало с PocketBase на SQLite со своим каталогом файлов
- база своя: два пула, захват одним UPDATE ... RETURNING, шаги схемы на goose под файловым замком, одна миграция начальной схемы вместо семи прежних - транспорт переписан на net/http: свои слои, свой ограничитель частоты, отдача файла с проверкой владельца; панель /_/ и пространство /api/ исчезли - по находкам ревью: журнал не пишет путь под корнем приложения, ключ бюджета читается справа налево, узнавание известного идёт читающим пулом
This commit is contained in:
@@ -1,16 +1,24 @@
|
||||
package http
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"log/slog"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strconv"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
pbrepo "git.vakhrushev.me/av/transcriber/internal/adapter/repo/pocketbase"
|
||||
sqliterepo "git.vakhrushev.me/av/transcriber/internal/adapter/repo/sqlite"
|
||||
"git.vakhrushev.me/av/transcriber/internal/contract"
|
||||
"git.vakhrushev.me/av/transcriber/internal/entity"
|
||||
"git.vakhrushev.me/av/transcriber/internal/metrics"
|
||||
@@ -57,7 +65,7 @@ func TestMe_CarriesAccountWithoutEmail(t *testing.T) {
|
||||
var me MeView
|
||||
require.NoError(t, json.Unmarshal(w.Body.Bytes(), &me))
|
||||
|
||||
assert.Equal(t, env.account.Id, me.ID)
|
||||
assert.Equal(t, env.account.ID, me.ID)
|
||||
assert.NotContains(t, w.Body.String(), "person@example.com",
|
||||
"адрес почты принадлежит человеку, а не сервису")
|
||||
}
|
||||
@@ -73,7 +81,7 @@ func TestUnauthorized_ExistingRecordLooksLikeUnknown(t *testing.T) {
|
||||
env.mux.ServeHTTP(existing, httptest.NewRequest("GET", "/app/audiorecords/"+record.Id, http.NoBody))
|
||||
|
||||
unknown := httptest.NewRecorder()
|
||||
env.mux.ServeHTTP(unknown, httptest.NewRequest("GET", "/app/audiorecords/nosuchrecordid", http.NoBody))
|
||||
env.mux.ServeHTTP(unknown, httptest.NewRequest("GET", "/app/audiorecords/"+unknownRecordID, http.NoBody))
|
||||
|
||||
require.Equal(t, http.StatusUnauthorized, existing.Code)
|
||||
require.Equal(t, http.StatusUnauthorized, unknown.Code)
|
||||
@@ -97,7 +105,7 @@ func TestIntake_SenderFilenameLandsInOwnColumn(t *testing.T) {
|
||||
|
||||
item := intakeItemOf(t, w)
|
||||
|
||||
record, err := env.handler.recordRepo.GetByID(item.ID, env.account.Id)
|
||||
record, err := env.handler.recordRepo.GetByID(item.ID, env.account.ID)
|
||||
require.NoError(t, err)
|
||||
|
||||
require.NotNil(t, record.OriginalFilename)
|
||||
@@ -128,7 +136,7 @@ func TestIntake_LongFilenameIsTrimmed(t *testing.T) {
|
||||
|
||||
item := intakeItemOf(t, w)
|
||||
|
||||
record, err := env.handler.recordRepo.GetByID(item.ID, env.account.Id)
|
||||
record, err := env.handler.recordRepo.GetByID(item.ID, env.account.ID)
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, record.OriginalFilename)
|
||||
|
||||
@@ -170,7 +178,9 @@ func TestErrorBody_OneShapeAcrossBranches(t *testing.T) {
|
||||
}{
|
||||
{
|
||||
name: "записи нет",
|
||||
req: func() *http.Request { return httptest.NewRequest("GET", "/app/audiorecords/nosuch", http.NoBody) },
|
||||
req: func() *http.Request {
|
||||
return httptest.NewRequest("GET", "/app/audiorecords/"+unknownRecordID, http.NoBody)
|
||||
},
|
||||
code: CodeNotFound,
|
||||
},
|
||||
{
|
||||
@@ -209,37 +219,279 @@ func TestErrorBody_OneShapeAcrossBranches(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// Своё правило ограничителя частоты заведено под корнем приложения: правило
|
||||
// хранилища настроено на его собственный корень и наших адресов не покрывает.
|
||||
func TestRateLimitRuleCoversAppRoot(t *testing.T) {
|
||||
// Ограничитель частоты покрывает адреса приложения и **не трогает** адресов
|
||||
// наблюдения: правило своё, и настроено оно на корень приложения.
|
||||
func TestRateLimitCoversAppRootOnly(t *testing.T) {
|
||||
env := setupTestEnv(t, readableMetaViewer())
|
||||
|
||||
require.NoError(t, ApplyAppRateLimit(env.app))
|
||||
|
||||
var found int
|
||||
for _, rule := range env.app.Settings().RateLimits.Rules {
|
||||
if strings.HasPrefix(rule.Label, AppRoot+"/") {
|
||||
found++
|
||||
assert.Positive(t, rule.MaxRequests)
|
||||
assert.Positive(t, rule.Duration)
|
||||
var refused *httptest.ResponseRecorder
|
||||
for range appRateMaxRequests + 1 {
|
||||
refused = env.getOwn("/app/me")
|
||||
if refused.Code == http.StatusTooManyRequests {
|
||||
break
|
||||
}
|
||||
}
|
||||
assert.Equal(t, 1, found, "правило под корнем приложения заведено, и оно одно")
|
||||
assert.True(t, env.app.Settings().RateLimits.Enabled, "и ограничитель включён")
|
||||
require.Equal(t, http.StatusTooManyRequests, refused.Code,
|
||||
"бюджет под корнем приложения не исчерпался — проверке не на чем сработать")
|
||||
|
||||
// Правило приводится к настройке **при каждом подъёме**, то есть на каждом
|
||||
// рестарте сервиса. Без этой проверки ветвь замены не исполнялась бы ни разу,
|
||||
// и правила молча копились бы с каждой выкладкой.
|
||||
require.NoError(t, ApplyAppRateLimit(env.app))
|
||||
require.NoError(t, ApplyAppRateLimit(env.app))
|
||||
// Проба здоровья тем же бюджетом не ограничена: она лежит вне корня
|
||||
// приложения, а слои одеты на корень.
|
||||
assert.Equal(t, http.StatusOK, env.get(HealthPath).Code,
|
||||
"ограничитель приложения закрыл наблюдение за сервисом")
|
||||
}
|
||||
|
||||
again := 0
|
||||
for _, rule := range env.app.Settings().RateLimits.Rules {
|
||||
if strings.HasPrefix(rule.Label, AppRoot+"/") {
|
||||
again++
|
||||
// Два клиентских адреса через один доверенный прокси расходуют **разные**
|
||||
// бюджеты, а заголовок пересылки с недоверенного адреса на ключ бюджета не
|
||||
// влияет.
|
||||
//
|
||||
// Обе половины закрывают свою поломку: бюджет, посчитанный по пиру, становится
|
||||
// общим на весь сервис, а вера заголовку без сверки пира отдаёт обход
|
||||
// ограничителя ровно тому, кого он ограничивает.
|
||||
func TestRateLimitKeyNamesTheClient(t *testing.T) {
|
||||
env := setupTestEnv(t, readableMetaViewer())
|
||||
|
||||
spend := func(peer, forwarded string) int {
|
||||
refused := 0
|
||||
for range appRateMaxRequests + 1 {
|
||||
req := httptest.NewRequest("GET", "/app/me", http.NoBody)
|
||||
req.Header.Set(LoginHeader, env.login)
|
||||
req.RemoteAddr = peer
|
||||
if forwarded != "" {
|
||||
req.Header.Set(ForwardedForHeader, forwarded)
|
||||
}
|
||||
w := httptest.NewRecorder()
|
||||
env.mux.ServeHTTP(w, req)
|
||||
if w.Code == http.StatusTooManyRequests {
|
||||
refused++
|
||||
}
|
||||
}
|
||||
return refused
|
||||
}
|
||||
|
||||
// Первый клиент выбирает свой бюджет целиком.
|
||||
require.Positive(t, spend(trustedPeer, "198.51.100.7"),
|
||||
"бюджет первого клиента не исчерпался — проверке не на чем сработать")
|
||||
|
||||
// Второй клиент за тем же прокси начинает со своего.
|
||||
firstRefusalOfSecond := 0
|
||||
for range appRateMaxRequests {
|
||||
req := httptest.NewRequest("GET", "/app/me", http.NoBody)
|
||||
req.Header.Set(LoginHeader, env.login)
|
||||
req.RemoteAddr = trustedPeer
|
||||
req.Header.Set(ForwardedForHeader, "198.51.100.8")
|
||||
w := httptest.NewRecorder()
|
||||
env.mux.ServeHTTP(w, req)
|
||||
if w.Code == http.StatusTooManyRequests {
|
||||
firstRefusalOfSecond++
|
||||
}
|
||||
}
|
||||
assert.Equal(t, 1, again, "повторный подъём правило заменяет, а не добавляет второе")
|
||||
assert.Zero(t, firstRefusalOfSecond,
|
||||
"исчерпание бюджета одним клиентом отказало другому: бюджет считается по пиру")
|
||||
}
|
||||
|
||||
// Заголовок пересылки, пришедший с недоверенного адреса, на ключ бюджета не
|
||||
// влияет: иначе спрашивающий назначал бы себе ключ счётчика сам и обходил
|
||||
// ограничитель, меняя значение.
|
||||
func TestRateLimitIgnoresForwardedHeaderFromUntrustedPeer(t *testing.T) {
|
||||
env := setupTestEnv(t, readableMetaViewer())
|
||||
|
||||
refused := 0
|
||||
for i := range appRateMaxRequests + 10 {
|
||||
req := httptest.NewRequest("GET", "/app/me", http.NoBody)
|
||||
req.RemoteAddr = untrustedPeer
|
||||
req.Header.Set(ForwardedForHeader, fmt.Sprintf("198.51.100.%d", i%200))
|
||||
w := httptest.NewRecorder()
|
||||
env.mux.ServeHTTP(w, req)
|
||||
if w.Code == http.StatusTooManyRequests {
|
||||
refused++
|
||||
}
|
||||
}
|
||||
|
||||
assert.Positive(t, refused,
|
||||
"меняя заголовок пересылки, спрашивающий обошёл ограничитель")
|
||||
}
|
||||
|
||||
// spendBudget шлёт запросы под корнем приложения и считает отказы ограничителя.
|
||||
// Заголовки пересылки ставит вызывающий: ключ бюджета выводится из них, и
|
||||
// проверке нужен каждый их вид — одна строка, несколько строк, цепочка.
|
||||
func spendBudget(env *testEnv, peer string, count int, forwarded func(i int) []string) int {
|
||||
refused := 0
|
||||
for i := range count {
|
||||
req := httptest.NewRequest("GET", "/app/me", http.NoBody)
|
||||
req.Header.Set(LoginHeader, env.login)
|
||||
req.RemoteAddr = peer
|
||||
for _, value := range forwarded(i) {
|
||||
req.Header.Add(ForwardedForHeader, value)
|
||||
}
|
||||
w := httptest.NewRecorder()
|
||||
env.mux.ServeHTTP(w, req)
|
||||
if w.Code == http.StatusTooManyRequests {
|
||||
refused++
|
||||
}
|
||||
}
|
||||
return refused
|
||||
}
|
||||
|
||||
// Значение, которое приписал сам спрашивающий, ключа бюджета не задаёт.
|
||||
//
|
||||
// Прокси заголовок **дописывает**, а не заменяет: слева в цепочке стоит то, что
|
||||
// прислал аноним, а справа — адрес, который приписал прокси. Ключ, взятый слева,
|
||||
// менялся бы на каждом запросе, и бюджет обходился бы с первого.
|
||||
func TestRateLimitIgnoresValuePresentedByTheClient(t *testing.T) {
|
||||
env := setupTestEnv(t, readableMetaViewer())
|
||||
|
||||
refused := spendBudget(env, trustedPeer, appRateMaxRequests+10, func(i int) []string {
|
||||
return []string{fmt.Sprintf("198.51.100.%d, 203.0.113.7", i%200)}
|
||||
})
|
||||
|
||||
assert.Positive(t, refused,
|
||||
"подставляя своё значение слева, спрашивающий обошёл ограничитель")
|
||||
}
|
||||
|
||||
// Цепочка законно приходит несколькими строками заголовка, и читаются они все:
|
||||
// разбор одной строки увидел бы кусок, которым распоряжается аноним.
|
||||
func TestRateLimitReadsEveryForwardedHeaderLine(t *testing.T) {
|
||||
env := setupTestEnv(t, readableMetaViewer())
|
||||
|
||||
refused := spendBudget(env, trustedPeer, appRateMaxRequests+10, func(i int) []string {
|
||||
return []string{fmt.Sprintf("198.51.100.%d", i%200), "203.0.113.8"}
|
||||
})
|
||||
|
||||
assert.Positive(t, refused,
|
||||
"вторая строка заголовка не прочитана: ключ достался присланному значению")
|
||||
}
|
||||
|
||||
// Доверенные шаги цепочки отбрасываются, и ключом становится первый недоверенный
|
||||
// справа. Два клиента за одним прокси при этом расходуют разные бюджеты.
|
||||
func TestRateLimitSkipsTrustedHopsFromTheRight(t *testing.T) {
|
||||
env := setupTestEnv(t, readableMetaViewer())
|
||||
|
||||
require.Positive(t,
|
||||
spendBudget(env, trustedPeer, appRateMaxRequests+1, func(int) []string {
|
||||
return []string{"203.0.113.11, 10.9.9.9"}
|
||||
}),
|
||||
"бюджет первого клиента не исчерпался — проверке не на чем сработать")
|
||||
|
||||
assert.Zero(t,
|
||||
spendBudget(env, trustedPeer, appRateMaxRequests, func(int) []string {
|
||||
return []string{"203.0.113.12, 10.9.9.9"}
|
||||
}),
|
||||
"исчерпание бюджета одним клиентом отказало другому: доверенный шаг стал ключом")
|
||||
}
|
||||
|
||||
// Тип содержимого ответа выбирает сервис, а не отправитель.
|
||||
//
|
||||
// Расширение приходит из имени, данное отправителем: `запись.html`, отданный
|
||||
// типом `text/html` с показом на месте, стал бы страницей в браузере. Тип
|
||||
// выводится поэтому из **закрытого** перечня известных форматов — той же единой
|
||||
// точки, что и метка метрики, — а всё прочее отдаётся `application/octet-stream`
|
||||
// на сохранение.
|
||||
func TestFileDownload_ContentTypeComesFromKnownFormats(t *testing.T) {
|
||||
env := setupTestEnv(t, readableMetaViewer())
|
||||
|
||||
fetch := func(t *testing.T, name string) *httptest.ResponseRecorder {
|
||||
t.Helper()
|
||||
|
||||
created := httptest.NewRecorder()
|
||||
env.serve(created, createMultipartRequest(t, name, []byte("данные")))
|
||||
require.Equal(t, http.StatusCreated, created.Code, name)
|
||||
|
||||
return env.getOwn("/app/audiorecords/" +
|
||||
intakeItemOf(t, created).ID + "/file?" + CopyParam + "=" + CopyOriginal)
|
||||
}
|
||||
|
||||
t.Run("расширение вне перечня исполняемым типом не отдаётся", func(t *testing.T) {
|
||||
for _, name := range []string{"запись.html", "запись.svg", "запись.xhtml"} {
|
||||
w := fetch(t, name)
|
||||
require.Equal(t, http.StatusOK, w.Code, name)
|
||||
header := w.Result().Header
|
||||
assert.Equal(t, unknownContentType, header.Get("Content-Type"), name)
|
||||
assert.Contains(t, header.Get("Content-Disposition"), dispositionAttachment, name)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("известный формат отдаётся своим типом", func(t *testing.T) {
|
||||
for name, want := range map[string]string{
|
||||
"запись.mkv": "video/x-matroska",
|
||||
"запись.mov": "video/quicktime",
|
||||
"запись.avi": "video/x-msvideo",
|
||||
"запись.mp3": "audio/mpeg",
|
||||
} {
|
||||
w := fetch(t, name)
|
||||
require.Equal(t, http.StatusOK, w.Code, name)
|
||||
header := w.Result().Header
|
||||
assert.Equal(t, want, header.Get("Content-Type"), name)
|
||||
assert.Contains(t, header.Get("Content-Disposition"), dispositionInline, name)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// Перечень типов содержимого сверяется с перечнем известных форматов
|
||||
// механически: формат, объявленный диалогу выбора файла и оставшийся без типа,
|
||||
// уехал бы ответом `application/octet-stream` — то есть сервис предлагал бы
|
||||
// загрузить то, что потом не умеет показать.
|
||||
func TestEveryKnownFormatHasContentType(t *testing.T) {
|
||||
formats := metrics.PublicFormats()
|
||||
require.NotEmpty(t, formats, "перечень форматов пуст: правилу не на чем сработать")
|
||||
|
||||
for _, format := range formats {
|
||||
contentType, disposition := presentationOf(format)
|
||||
assert.NotEqual(t, unknownContentType, contentType,
|
||||
"формат %q сервис объявляет диалогу выбора файла, но типа содержимого у него нет", format)
|
||||
assert.Equal(t, dispositionInline, disposition, format)
|
||||
}
|
||||
|
||||
for format := range contentTypes {
|
||||
assert.Equal(t, format, metrics.FormatLabel(format),
|
||||
"тип содержимого заведён формату %q, которого нет среди известных: ключ никогда не совпадёт", format)
|
||||
}
|
||||
}
|
||||
|
||||
// Паника обработчика отдаёт `500` нашей формой тела, а процесс живёт дальше.
|
||||
//
|
||||
// Слой восстановления — верхняя граница поверхности, и проверяется он через
|
||||
// **всю** цепочку: паника ловится снаружи журнала и маршрутизатора, поэтому
|
||||
// собранная иначе поверхность судила бы не то. Без него один паникующий запрос
|
||||
// уронил бы процесс вместе с конвейером и всеми, кто в это время что-то грузил.
|
||||
func TestPanickingHandlerAnswersOurFailureFormAndProcessLives(t *testing.T) {
|
||||
db, _, _ := newTestStorage(t)
|
||||
users := sqliterepo.NewUserRepository(db)
|
||||
|
||||
journal := &journalBuffer{}
|
||||
logger := slog.New(slog.NewTextHandler(journal, nil))
|
||||
|
||||
panicking := http.HandlerFunc(func(http.ResponseWriter, *http.Request) {
|
||||
panic("шаг обработчика упал")
|
||||
})
|
||||
mounts := ServiceMounts(
|
||||
AppChain(panicking, users, testTrustedNetworks(t), logger),
|
||||
http.NotFoundHandler(),
|
||||
)
|
||||
mux := BuildHandler(mounts, NewWebappHandler(builtDist(), true, logger), logger)
|
||||
|
||||
account, _, err := users.EnsureUser(contract.Identity{Login: "person"})
|
||||
require.NoError(t, err)
|
||||
require.NotEmpty(t, account.ID)
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
mux.ServeHTTP(w, asUser(httptest.NewRequest(http.MethodGet, "/app/me", http.NoBody), "person"))
|
||||
|
||||
require.Equal(t, http.StatusInternalServerError, w.Code)
|
||||
|
||||
var body ErrorBody
|
||||
require.NoError(t, json.Unmarshal(w.Body.Bytes(), &body), "тело отказа — не наша форма")
|
||||
assert.Equal(t, CodeInternal, body.Code)
|
||||
assert.NotEmpty(t, body.Message)
|
||||
assert.NotContains(t, w.Body.String(), "шаг обработчика упал",
|
||||
"значение паники ушло спрашивающему")
|
||||
|
||||
assert.Contains(t, journal.String(), "Handler panicked",
|
||||
"владелец сервиса о панике не узнал")
|
||||
|
||||
// Процесс жив: следующий запрос отвечает как ни в чём не бывало.
|
||||
alive := httptest.NewRecorder()
|
||||
mux.ServeHTTP(alive, httptest.NewRequest(http.MethodGet, HealthPath, http.NoBody))
|
||||
assert.Equal(t, http.StatusOK, alive.Code, "после паники поверхность перестала отвечать")
|
||||
}
|
||||
|
||||
// Перечень доступных видов растёт вместе с готовыми текстами, и вычитанный текст
|
||||
@@ -250,13 +502,13 @@ func TestAvailableViewsCoverEveryKind(t *testing.T) {
|
||||
|
||||
record := jobWithFile(t, env)
|
||||
|
||||
texts := pbrepo.NewTextRepository(env.app)
|
||||
texts := env.handler.textRepo
|
||||
literary, err := texts.Put(record.Id, entity.TextKindLiterary, "вычитанный текст")
|
||||
require.NoError(t, err)
|
||||
transcript, err := texts.Put(record.Id, entity.TextKindTranscript, "сырая расшифровка")
|
||||
require.NoError(t, err)
|
||||
|
||||
structures := pbrepo.NewStructureRepository(env.app)
|
||||
structures := env.handler.structureRepo
|
||||
structure, err := structures.Put(record.Id, 1, []entity.Replica{{StartMs: 0, EndMs: 10, Text: "реплика"}})
|
||||
require.NoError(t, err)
|
||||
|
||||
@@ -318,7 +570,6 @@ func TestTooLargeOnTheRealPath(t *testing.T) {
|
||||
// частом отказе после превышения размера.
|
||||
func TestRateLimitRefusalGoesThroughOneErrorForm(t *testing.T) {
|
||||
env := setupTestEnv(t, readableMetaViewer())
|
||||
require.NoError(t, ApplyAppRateLimit(env.app))
|
||||
|
||||
var last *httptest.ResponseRecorder
|
||||
for range appRateMaxRequests + 1 {
|
||||
@@ -403,7 +654,7 @@ func TestEmptyTextIsNotAnAvailableView(t *testing.T) {
|
||||
|
||||
record := jobWithFile(t, env)
|
||||
|
||||
texts := pbrepo.NewTextRepository(env.app)
|
||||
texts := env.handler.textRepo
|
||||
empty, err := texts.Put(record.Id, entity.TextKindTranscript, "")
|
||||
require.NoError(t, err)
|
||||
|
||||
@@ -425,3 +676,108 @@ func TestEmptyTextIsNotAnAvailableView(t *testing.T) {
|
||||
// И адрес текста отвечает тем же: состоянием, а не обещанием.
|
||||
assert.Equal(t, http.StatusConflict, textOf(t, env, record.Id, entity.TextViewTranscript).Code)
|
||||
}
|
||||
|
||||
// TestFailuresBornOutsideHandlerShareOneForm — **критерий приёмки**: отказы,
|
||||
// рождающиеся не в обработчике, приходят той же формой, что и отказы
|
||||
// обработчика.
|
||||
//
|
||||
// Проверка идёт **настоящими** HTTP-запросами через поднятую цепочку слоёв:
|
||||
// вызовом отображателя ошибки это не проверяется — ни один из трёх отказов до
|
||||
// него не доходит. Предел тела ловит запрос слоем чтения, ограничитель частоты —
|
||||
// слоем перед узнаванием, неизвестный путь — маршрутизатором.
|
||||
func TestFailuresBornOutsideHandlerShareOneForm(t *testing.T) {
|
||||
env := setupTestEnv(t, readableMetaViewer())
|
||||
server := env.liveServer(t)
|
||||
|
||||
// Перечень кодов закрыт: значение вне его приложению разбирать нечем.
|
||||
known := map[string]bool{
|
||||
CodeUnauthorized: true, CodeNotFound: true, CodeBadRequest: true,
|
||||
CodeTooLarge: true, CodeNotReady: true, CodeTooManyRequests: true,
|
||||
CodeInternal: true,
|
||||
}
|
||||
|
||||
assertOurForm := func(t *testing.T, status int, body []byte) {
|
||||
t.Helper()
|
||||
|
||||
var raw map[string]any
|
||||
require.NoError(t, json.Unmarshal(body, &raw), "тело не наше: %s", body)
|
||||
require.Contains(t, raw, "error_code")
|
||||
require.Contains(t, raw, "message")
|
||||
|
||||
code, ok := raw["error_code"].(string)
|
||||
require.True(t, ok)
|
||||
assert.True(t, known[code], "код отказа %q вне закрытого перечня", code)
|
||||
assert.NotEmpty(t, raw["message"])
|
||||
assert.NotEqual(t, http.StatusOK, status)
|
||||
}
|
||||
|
||||
t.Run("предел тела", func(t *testing.T) {
|
||||
// Запрос идёт **сырым соединением**: клиент стандартной библиотеки
|
||||
// отказывается слать объявленную длину, которой не соответствует тело, а
|
||||
// прислать восемь гигабайт на самом деле проверка не может. Сервер при
|
||||
// этом видит обычный запрос: заголовки разобраны, длина объявлена, тела
|
||||
// он не читает вовсе — отказ наступает раньше.
|
||||
status, body := rawRequest(t, server, ""+
|
||||
"POST /app/audiorecords HTTP/1.1\r\n"+
|
||||
"Host: transcriber.test\r\n"+
|
||||
LoginHeader+": "+env.login+"\r\n"+
|
||||
"Content-Type: multipart/form-data; boundary=x\r\n"+
|
||||
"Content-Length: "+strconv.FormatInt(entity.MaxRecordSize+1, 10)+"\r\n"+
|
||||
"Connection: close\r\n\r\n")
|
||||
|
||||
require.Equal(t, http.StatusRequestEntityTooLarge, status)
|
||||
assertOurForm(t, status, body)
|
||||
assert.Contains(t, string(body), `"limit"`, "предел уходит человеку числом")
|
||||
assert.Equal(t, 0, countJobs(t, env), "записи не заводится")
|
||||
})
|
||||
|
||||
t.Run("неизвестный путь под корнем приложения", func(t *testing.T) {
|
||||
res := env.liveRequest(t, server, "/app/nosuchendpoint", nil)
|
||||
|
||||
require.Equal(t, http.StatusNotFound, res.StatusCode)
|
||||
assertOurForm(t, res.StatusCode, res.Body)
|
||||
})
|
||||
|
||||
t.Run("ограничитель частоты", func(t *testing.T) {
|
||||
var last liveResponse
|
||||
for range appRateMaxRequests + 5 {
|
||||
last = env.liveRequest(t, server, "/app/me", nil)
|
||||
if last.StatusCode == http.StatusTooManyRequests {
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
require.Equal(t, http.StatusTooManyRequests, last.StatusCode,
|
||||
"ограничитель не сработал — проверке не на чем сработать")
|
||||
assertOurForm(t, last.StatusCode, last.Body)
|
||||
})
|
||||
}
|
||||
|
||||
// rawRequest шлёт запрос сырым соединением и отдаёт код с телом ответа.
|
||||
//
|
||||
// Нужен там, где клиент стандартной библиотеки запрос не отправит: он судит
|
||||
// соответствие объявленной длины телу, а проверке нужна ровно объявленная.
|
||||
func rawRequest(t *testing.T, server *httptest.Server, request string) (int, []byte) {
|
||||
t.Helper()
|
||||
|
||||
address := strings.TrimPrefix(server.URL, "http://")
|
||||
|
||||
dialer := &net.Dialer{Timeout: 5 * time.Second}
|
||||
conn, err := dialer.DialContext(context.Background(), "tcp", address)
|
||||
require.NoError(t, err)
|
||||
defer func() { require.NoError(t, conn.Close()) }()
|
||||
|
||||
require.NoError(t, conn.SetDeadline(time.Now().Add(5*time.Second)))
|
||||
|
||||
_, err = conn.Write([]byte(request))
|
||||
require.NoError(t, err)
|
||||
|
||||
res, err := http.ReadResponse(bufio.NewReader(conn), nil)
|
||||
require.NoError(t, err)
|
||||
defer func() { require.NoError(t, res.Body.Close()) }()
|
||||
|
||||
body, err := io.ReadAll(res.Body)
|
||||
require.NoError(t, err)
|
||||
|
||||
return res.StatusCode, body
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user