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" 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" ) // Пределы объявляются тем же значением, которое сервис применяет, а не его // копией. Приложение, знающее предел своей константой, расходится с сервером // молча — до первого отказа на записи, которую человек уже успел отправить. func TestConfig_LimitsAreTheAppliedOnes(t *testing.T) { env := setupTestEnv(t, readableMetaViewer()) w := httptest.NewRecorder() env.serve(w, httptest.NewRequest("GET", "/app/config", http.NoBody)) require.Equal(t, http.StatusOK, w.Code) var config ConfigView require.NoError(t, json.Unmarshal(w.Body.Bytes(), &config)) // Потолок размера — то же число, которым ограничено тело запроса приёма и // которым сервис отвергает запись. assert.Equal(t, entity.MaxRecordSize, config.MaxRecordSizeBytes) assert.Equal(t, MaxPageLimit, config.MaxPageSize) assert.Equal(t, entity.MaxTopicsPerRecord, config.MaxTopicsPerRecord) assert.Equal(t, PollIntervalMs, config.PollIntervalMs) // Перечень расширений — тот же, что сужает метку метрики, за вычетом // собственного умолчания сервиса: `audio` не формат, и подсказкой человеку // выходить не должно. assert.Equal(t, metrics.PublicFormats(), config.KnownExtensions) assert.NotContains(t, config.KnownExtensions, "audio", "умолчание сервиса форматом не является") assert.Contains(t, config.KnownExtensions, "mp3") } // Кто вошёл — приложение узнаёт ответом: кука недоступна скриптам страницы, и // прочитать из неё имя оно не может вовсе. Адрес почты при этом наружу не идёт. func TestMe_CarriesAccountWithoutEmail(t *testing.T) { env := setupTestEnv(t, readableMetaViewer()) w := httptest.NewRecorder() env.serve(w, httptest.NewRequest("GET", "/app/me", http.NoBody)) require.Equal(t, http.StatusOK, w.Code) var me MeView require.NoError(t, json.Unmarshal(w.Body.Bytes(), &me)) assert.Equal(t, env.account.ID, me.ID) assert.NotContains(t, w.Body.String(), "person@example.com", "адрес почты принадлежит человеку, а не сервису") } // Без сессии заведённая запись неотличима от неизвестной: иначе по разнице // ответов перебирается список заведённых записей. func TestUnauthorized_ExistingRecordLooksLikeUnknown(t *testing.T) { env := setupTestEnv(t, readableMetaViewer()) record := jobWithFile(t, env) existing := httptest.NewRecorder() 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/"+unknownRecordID, http.NoBody)) require.Equal(t, http.StatusUnauthorized, existing.Code) require.Equal(t, http.StatusUnauthorized, unknown.Code) assert.Equal(t, existing.Body.String(), unknown.Body.String(), "тело одно: по разнице ответов иначе перебирается список записей") var body ErrorBody require.NoError(t, json.Unmarshal(existing.Body.Bytes(), &body)) assert.Equal(t, CodeUnauthorized, body.Code) } // Имя файла отправителя доходит до своей колонки, а колонка заголовка остаётся // пустой: приём заголовков не сочиняет, а посчитанное языковой моделью название // легло бы поверх имени, если бы они делили одну колонку. func TestIntake_SenderFilenameLandsInOwnColumn(t *testing.T) { env := setupTestEnv(t, readableMetaViewer()) w := httptest.NewRecorder() env.serve(w, createMultipartRequest(t, "разговор.mp3", []byte("данные"))) require.Equal(t, http.StatusCreated, w.Code) item := intakeItemOf(t, w) record, err := env.handler.recordRepo.GetByID(item.ID, env.account.ID) require.NoError(t, err) require.NotNil(t, record.OriginalFilename) assert.Equal(t, "разговор.mp3", *record.OriginalFilename) assert.Nil(t, record.Title, "колонка заголовка у принятой записи пуста") // Длительность и размер — снимок принятого, взятый приёмом. require.NotNil(t, record.DurationMs) assert.Equal(t, int64(42_000), *record.DurationMs) require.NotNil(t, record.SizeBytes) assert.Positive(t, *record.SizeBytes) } // Имя длиннее предела доходит до записи обрезанным. // // Управляющие знаки этой проверкой не судятся, и причина внешняя: имя с ними // ломает разбор multipart раньше нашего кода — заголовок части становится // негодным, и запрос до обработчика не доезжает вовсе. Уборку знаков поэтому // судит проверка домена рядом, где живёт само правило. func TestIntake_LongFilenameIsTrimmed(t *testing.T) { env := setupTestEnv(t, readableMetaViewer()) long := strings.Repeat("я", entity.MaxOriginalFilenameLen+50) + ".mp3" w := httptest.NewRecorder() env.serve(w, createMultipartRequest(t, long, []byte("данные"))) require.Equal(t, http.StatusCreated, w.Code) item := intakeItemOf(t, w) record, err := env.handler.recordRepo.GetByID(item.ID, env.account.ID) require.NoError(t, err) require.NotNil(t, record.OriginalFilename) stored := *record.OriginalFilename assert.Len(t, []rune(stored), entity.MaxOriginalFilenameLen, "имя обрезано по пределу, и режется оно по знакам, а не по байтам") } // Отказ по превышению потолка размера проходит через единую форму: прежде он // уходил телом ограничителя тела и читался как «сломался сервер». func TestIntake_TooLargeGoesThroughOneErrorForm(t *testing.T) { env := setupTestEnv(t, readableMetaViewer()) status, body := mapDomainError(errTooLargeForTest()) assert.Equal(t, http.StatusRequestEntityTooLarge, status) assert.Equal(t, CodeTooLarge, body.Code) require.NotNil(t, body.Limit, "предел уходит человеку числом") assert.Equal(t, entity.MaxRecordSize, *body.Limit) assert.NotEmpty(t, body.Message) // И тот же предел объявлен адресом пределов — одним числом, а не двумя. w := httptest.NewRecorder() env.serve(w, httptest.NewRequest("GET", "/app/config", http.NoBody)) var config ConfigView require.NoError(t, json.Unmarshal(w.Body.Bytes(), &config)) assert.Equal(t, *body.Limit, config.MaxRecordSizeBytes) } // Форма тела отказа одна на всех ветвях: код разбирает программа, сообщение // читает человек, сырого текста ошибки нет нигде. func TestErrorBody_OneShapeAcrossBranches(t *testing.T) { env := setupTestEnv(t, readableMetaViewer()) cases := []struct { name string req func() *http.Request code string }{ { name: "записи нет", req: func() *http.Request { return httptest.NewRequest("GET", "/app/audiorecords/"+unknownRecordID, http.NoBody) }, code: CodeNotFound, }, { name: "негодный ввод", req: func() *http.Request { return httptest.NewRequest("GET", "/app/audiorecords?limit=0", http.NoBody) }, code: CodeBadRequest, }, { name: "негодная запись", req: func() *http.Request { return createMultipartRequestWithField(t, "wrong-field", "sample.mp3", []byte("данные")) }, code: CodeBadRequest, }, } for _, tc := range cases { t.Run(tc.name, func(t *testing.T) { w := httptest.NewRecorder() env.serve(w, tc.req()) var body ErrorBody require.NoError(t, json.Unmarshal(w.Body.Bytes(), &body)) assert.Equal(t, tc.code, body.Code, "код машиночитаем и из закрытого перечня") assert.NotEmpty(t, body.Message, "рядом с кодом стоит фраза для человека") var raw map[string]json.RawMessage require.NoError(t, json.Unmarshal(w.Body.Bytes(), &raw)) assert.Contains(t, raw, "error_code") assert.Contains(t, raw, "message") assert.NotContains(t, raw, "error", "прежнее поле ушло вместе с прежним контрактом") }) } } // Ограничитель частоты покрывает адреса приложения и **не трогает** адресов // наблюдения: правило своё, и настроено оно на корень приложения. func TestRateLimitCoversAppRootOnly(t *testing.T) { env := setupTestEnv(t, readableMetaViewer()) var refused *httptest.ResponseRecorder for range appRateMaxRequests + 1 { refused = env.getOwn("/app/me") if refused.Code == http.StatusTooManyRequests { break } } require.Equal(t, http.StatusTooManyRequests, refused.Code, "бюджет под корнем приложения не исчерпался — проверке не на чем сработать") // Проба здоровья тем же бюджетом не ограничена: она лежит вне корня // приложения, а слои одеты на корень. assert.Equal(t, http.StatusOK, env.get(HealthPath).Code, "ограничитель приложения закрыл наблюдение за сервисом") } // Два клиентских адреса через один доверенный прокси расходуют **разные** // бюджеты, а заголовок пересылки с недоверенного адреса на ключ бюджета не // влияет. // // Обе половины закрывают свою поломку: бюджет, посчитанный по пиру, становится // общим на весь сервис, а вера заголовку без сверки пира отдаёт обход // ограничителя ровно тому, кого он ограничивает. 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.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), nil, 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, "после паники поверхность перестала отвечать") } // Перечень доступных видов растёт вместе с готовыми текстами, и вычитанный текст // в нём тоже: без этой проверки ветвь ни разу не исполнялась бы, а приложение не // предложило бы открыть готовый текст. func TestAvailableViewsCoverEveryKind(t *testing.T) { env := setupTestEnv(t, readableMetaViewer()) record := jobWithFile(t, env) 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 := env.handler.structureRepo structure, err := structures.Put(record.Id, 1, []entity.Replica{{StartMs: 0, EndMs: 10, Text: "реплика"}}) require.NoError(t, err) record.LiteraryTextID = &literary.Id record.TranscriptTextID = &transcript.Id record.StructureID = &structure.Id require.NoError(t, env.handler.recordRepo.Save(record, "")) w := httptest.NewRecorder() env.serve(w, httptest.NewRequest("GET", "/app/audiorecords/"+record.Id, http.NoBody)) require.Equal(t, http.StatusOK, w.Code) var card RecordView require.NoError(t, json.Unmarshal(w.Body.Bytes(), &card)) require.NotNil(t, card.AvailableViews, "поле обязано быть на проводе") assert.ElementsMatch(t, []string{entity.TextViewTranscript, entity.TextViewLiterary, entity.TextViewReplicas}, *card.AvailableViews, "каждый готовый вид назван перечнем") // И каждый названный вид действительно отдаётся своим адресом. for _, view := range *card.AvailableViews { got := textOf(t, env, record.Id, view) assert.Equal(t, http.StatusOK, got.Code, "вид %q обещан перечнем и обязан отдаваться", view) } } // errTooLargeForTest — отказ по превышению потолка, каким его строит приём. func errTooLargeForTest() error { return contract.ErrRecordTooLarge } // Отказ по превышению потолка размера проходит **настоящим путём**, а не вызовом // отображателя. Прежде проверка звала `mapDomainError` самодельной ошибкой и была // зелёной независимо от того, что происходит на проводе: предел тела срабатывает // слоем, до обработчика запрос не доходит, и отказ уходил телом библиотеки. func TestTooLargeOnTheRealPath(t *testing.T) { env := setupTestEnv(t, readableMetaViewer()) req := createMultipartRequest(t, "sample.mp3", []byte("данные")) req.ContentLength = entity.MaxRecordSize + 1 w := httptest.NewRecorder() env.serve(w, req) require.Equal(t, http.StatusRequestEntityTooLarge, w.Code) var body ErrorBody require.NoError(t, json.Unmarshal(w.Body.Bytes(), &body)) assert.Equal(t, CodeTooLarge, body.Code, "код машиночитаем") require.NotNil(t, body.Limit, "предел уходит человеку числом") assert.Equal(t, entity.MaxRecordSize, *body.Limit) assert.Equal(t, 0, countJobs(t, env), "записи не заводится") } // Отказ ограничителя частоты тоже идёт единой формой: он рождается слоем ниже // обработчика, и без перевода приложение получило бы тело библиотеки на самом // частом отказе после превышения размера. func TestRateLimitRefusalGoesThroughOneErrorForm(t *testing.T) { env := setupTestEnv(t, readableMetaViewer()) var last *httptest.ResponseRecorder for range appRateMaxRequests + 1 { last = httptest.NewRecorder() env.serve(last, httptest.NewRequest("GET", "/app/me", http.NoBody)) if last.Code == http.StatusTooManyRequests { break } } require.Equal(t, http.StatusTooManyRequests, last.Code, "ограничитель сработал") var body ErrorBody require.NoError(t, json.Unmarshal(last.Body.Bytes(), &body)) assert.Equal(t, CodeTooManyRequests, body.Code) assert.NotEmpty(t, body.Message) } // Объявленная частота опроса умещается в бюджет ограничителя с запасом: прежде // она равнялась всему бюджету, и любой соседний запрос в ту же секунду выводил // приложение за потолок — отказ, которого сервис сам же обещал избежать. func TestPollIntervalLeavesBudgetHeadroom(t *testing.T) { pollsPerWindow := int64(appRateWindowSec) * 1000 / PollIntervalMs assert.Less(t, pollsPerWindow, int64(appRateMaxRequests), "опрос с объявленной частотой не выбирает бюджет целиком") assert.Positive(t, pollsPerWindow, "и при этом опрашивать вообще можно") } // Длинное расширение из имени отправителя не роняет приём в «внутреннюю ошибку»: // хвост после последней точки задаёт отправитель, и без потолка имя `x.` с // четырьмястами знаками валит заведение временного файла. func TestIntake_AbsurdExtensionDoesNotBecomeInternalError(t *testing.T) { env := setupTestEnv(t, readableMetaViewer()) w := httptest.NewRecorder() env.serve(w, createMultipartRequest(t, "x."+strings.Repeat("a", 400), []byte("данные"))) require.Equal(t, http.StatusCreated, w.Code, "запись принята: абсурдное расширение заменено собственным умолчанием") assert.NotContains(t, env.journal.String(), "file name too long") } // Неизвестный путь и неверный метод под корнем приложения тоже идут единой // формой. Слой формы их не покрывает: отказ «ничего не совпало» рождается // маршрутом корневой группы, к которому слои нашей группы не привязаны, — и без // своего перехвата форм отказа под корнем было бы две. func TestUnknownAddressUnderAppRootUsesOneErrorForm(t *testing.T) { env := setupTestEnv(t, readableMetaViewer()) cases := []struct { name string method string path string }{ {name: "неизвестный путь", method: "GET", path: "/app/nosuchendpoint"}, {name: "неверный метод у списка", method: "DELETE", path: "/app/audiorecords"}, {name: "неверный метод у пределов", method: "POST", path: "/app/config"}, } for _, tc := range cases { t.Run(tc.name, func(t *testing.T) { w := httptest.NewRecorder() env.serve(w, httptest.NewRequest(tc.method, tc.path, http.NoBody)) require.Equal(t, http.StatusNotFound, w.Code) var body ErrorBody require.NoError(t, json.Unmarshal(w.Body.Bytes(), &body)) assert.Equal(t, CodeNotFound, body.Code, "код машиночитаем, а не тело библиотеки") assert.NotEmpty(t, body.Message) }) } } // Ссылка на текст без содержимого видом не считается. Иначе карточка обещала бы // вид, а адрес текста отвечал бы «ещё не готов» вечно: приложение опрашивало бы // его без конца, а человек видел бы завершённую запись, из которой текст // «вот-вот появится». Пустой ответ распознавания — состояние штатное. func TestEmptyTextIsNotAnAvailableView(t *testing.T) { env := setupTestEnv(t, readableMetaViewer()) record := jobWithFile(t, env) texts := env.handler.textRepo empty, err := texts.Put(record.Id, entity.TextKindTranscript, "") require.NoError(t, err) record.TranscriptTextID = &empty.Id record.MoveToState(entity.StateDone) require.NoError(t, env.handler.recordRepo.Save(record, "")) w := httptest.NewRecorder() env.serve(w, httptest.NewRequest("GET", "/app/audiorecords/"+record.Id, http.NoBody)) require.Equal(t, http.StatusOK, w.Code) var card RecordView require.NoError(t, json.Unmarshal(w.Body.Bytes(), &card)) require.NotNil(t, card.AvailableViews) assert.Empty(t, *card.AvailableViews, "ссылка есть, содержимого нет — вид доступным не считается") // И адрес текста отвечает тем же: состоянием, а не обещанием. 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 }