package http import ( "fmt" "net/http" "net/http/httptest" "strings" "testing" "github.com/pocketbase/pocketbase/apis" "github.com/pocketbase/pocketbase/core" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" pbrepo "git.vakhrushev.me/av/transcriber/internal/adapter/repo/pocketbase" "git.vakhrushev.me/av/transcriber/internal/adapter/repo/pocketbase/migrations" ) // Проверки этого файла судят допуск: кого пускают к приёму и опросу, чем // называется пришедший, кому верят и какие адреса остаются открытыми. // TestApiRequiresIdentity — первый критерий приёмки. Запрос неузнанного получает // отказ и ничего не заводит. func TestApiRequiresIdentity(t *testing.T) { env := setupTestEnv(t, readableMetaViewer()) t.Run("приём записи неузнанным", func(t *testing.T) { req := createMultipartRequest(t, "test.mp3", []byte("audio")) w := httptest.NewRecorder() env.mux.ServeHTTP(w, req) assert.Equal(t, http.StatusUnauthorized, w.Code) assert.NotContains(t, w.Body.String(), "job_id") // Ни файла, ни задачи: отказ наступает раньше, чем запись попадает в // хранилище. files, err := env.app.FindAllRecords(migrations.FilesCollection) require.NoError(t, err) assert.Empty(t, files) jobs, err := env.app.FindAllRecords(migrations.RecordsCollection) require.NoError(t, err) assert.Empty(t, jobs) }) t.Run("карточка записи неузнанному", func(t *testing.T) { req := httptest.NewRequest(http.MethodGet, "/app/audiorecords/anything", nil) w := httptest.NewRecorder() env.mux.ServeHTTP(w, req) assert.Equal(t, http.StatusUnauthorized, w.Code) assert.NotContains(t, w.Body.String(), "transcription_text") assert.NotContains(t, w.Body.String(), "created_at") }) } // TestUnknownJobIsIndistinguishableWithoutIdentity: по кодам ответа неузнанному // не перебирается список заведённых задач. func TestUnknownJobIsIndistinguishableWithoutIdentity(t *testing.T) { env := setupTestEnv(t, readableMetaViewer()) created := httptest.NewRecorder() env.serve(created, createMultipartRequest(t, "test.mp3", []byte("audio"))) require.Equal(t, http.StatusCreated, created.Code) jobs, err := env.app.FindAllRecords(migrations.RecordsCollection) require.NoError(t, err) require.Len(t, jobs, 1) existing := httptest.NewRecorder() env.mux.ServeHTTP(existing, httptest.NewRequest(http.MethodGet, "/app/audiorecords/"+jobs[0].Id, nil)) missing := httptest.NewRecorder() env.mux.ServeHTTP(missing, httptest.NewRequest(http.MethodGet, "/app/audiorecords/nosuchjobid", nil)) assert.Equal(t, http.StatusUnauthorized, existing.Code) assert.Equal(t, missing.Code, existing.Code) } // TestFirstRequestCreatesAccountAndSecondReuses — **первый критерий приёмки**. // // Два запроса подряд с одним значением заголовка: учётная запись заводится // первым и находится вторым, а в хранилище её строка одна. func TestFirstRequestCreatesAccountAndSecondReuses(t *testing.T) { env := setupTestEnv(t, readableMetaViewer()) const login = "newcomer" before := countAccounts(t, env) first := httptest.NewRecorder() env.mux.ServeHTTP(first, asUser(httptest.NewRequest(http.MethodGet, "/app/me", nil), login)) require.Equal(t, http.StatusOK, first.Code, "первое обращение узнано") second := httptest.NewRecorder() env.mux.ServeHTTP(second, asUser(httptest.NewRequest(http.MethodGet, "/app/me", nil), login)) require.Equal(t, http.StatusOK, second.Code) assert.Equal(t, before+1, countAccounts(t, env), "второе обращение завело вторую запись: архив разъехался бы между ними") assert.Equal(t, first.Body.String(), second.Body.String(), "второе обращение попало в другую учётную запись") } // TestUntrustedPeerIsNotIdentified — **второй критерий приёмки**. Тот же // заголовок с недоверенного адреса даёт отказ, а не вход под названным именем. func TestUntrustedPeerIsNotIdentified(t *testing.T) { env := setupTestEnv(t, readableMetaViewer()) before := countAccounts(t, env) req := httptest.NewRequest(http.MethodGet, "/app/me", nil) req.Header.Set(LoginHeader, "intruder") req.RemoteAddr = untrustedPeer w := httptest.NewRecorder() env.mux.ServeHTTP(w, req) assert.Equal(t, http.StatusUnauthorized, w.Code) assert.Equal(t, before, countAccounts(t, env), "заголовок с недоверенного адреса завёл учётную запись") } // TestStorageOwnLoginAddressesGiveNothing — **третий критерий приёмки**. // // Перечень собственных адресов входа хранилища: каждый отвечает отказом и // учётной записи не меняет. Перечень закрыт и назван поимённо — пока хоть один // из них работает, узнавание по заголовку обходится двумя запросами. func TestStorageOwnLoginAddressesGiveNothing(t *testing.T) { env := setupTestEnv(t, readableMetaViewer()) const usersRoot = "/api/collections/users" cases := []struct { name string path string body string }{ { name: "завести учётную запись самому", path: usersRoot + "/records", body: `{"email":"intruder@example.com","password":"12345678901","passwordConfirm":"12345678901"}`, }, { name: "вход по паролю", path: usersRoot + "/auth-with-password", body: `{"identity":"person@example.com","password":"whatever"}`, }, { name: "обмен кода у внешнего провайдера", path: usersRoot + "/auth-with-oauth2", body: `{"provider":"oidc","code":"whatever","codeVerifier":"whatever","redirectURL":"https://example.com/"}`, }, { name: "вход по одноразовому коду", path: usersRoot + "/auth-with-otp", body: `{"otpId":"whatever","password":"whatever"}`, }, { name: "запрос одноразового кода", path: usersRoot + "/request-otp", body: `{"email":"person@example.com"}`, }, { name: "восстановление пароля", path: usersRoot + "/request-password-reset", body: `{"email":"person@example.com"}`, }, { name: "продление сессии", path: usersRoot + "/auth-refresh", body: `{}`, }, } for _, c := range cases { t.Run(c.name, func(t *testing.T) { before := countAccounts(t, env) req := httptest.NewRequest(http.MethodPost, c.path, strings.NewReader(c.body)) req.Header.Set("Content-Type", "application/json") w := httptest.NewRecorder() env.mux.ServeHTTP(w, req) assert.GreaterOrEqual(t, w.Code, http.StatusBadRequest, "адрес %s ответил успехом: собственный вход хранилища открыт", c.path) assert.NotContains(t, w.Body.String(), `"token"`, "адрес %s выдал значение доступа", c.path) assert.Equal(t, before, countAccounts(t, env), "адрес %s изменил число учётных записей", c.path) }) } } // TestUserRecordCannotBeEditedFromOutside — путь захвата чужого имени закрыт. // // Ключ учётной записи лежит обычной колонкой, а умолчание библиотеки открывает // владельцу записи правку собственной строки. Пока узнавание жило под корнем // приложения, до этой поверхности браузер не дотягивался вовсе; теперь она // достижима, и правка своей записи была бы захватом чужого имени: поставил себе // чужой логин — и первое обращение настоящего его владельца попало бы в твою // запись вместе со всем архивом. func TestUserRecordCannotBeEditedFromOutside(t *testing.T) { env := setupTestEnv(t, readableMetaViewer()) body := strings.NewReader(`{"` + migrations.ProviderLoginField + `":"victim"}`) req := httptest.NewRequest(http.MethodPatch, "/api/collections/users/records/"+env.account.Id, body) req.Header.Set("Content-Type", "application/json") w := httptest.NewRecorder() env.mux.ServeHTTP(w, asUser(req, env.login)) assert.GreaterOrEqual(t, w.Code, http.StatusBadRequest, "правка своей учётной записи прошла") after, err := env.app.FindRecordById(migrations.UsersCollection, env.account.Id) require.NoError(t, err) assert.Equal(t, env.login, after.GetString(migrations.ProviderLoginField), "ключ учётной записи переписан снаружи") } // TestUserRecordsCannotBeListed: перечисление коллекции пользователей закрыто. // Открытое, оно отдавало бы узнанному логины всех остальных — то есть ровно те // значения, которыми довольно назваться. func TestUserRecordsCannotBeListed(t *testing.T) { env := setupTestEnv(t, readableMetaViewer()) w := httptest.NewRecorder() env.mux.ServeHTTP(w, asUser( httptest.NewRequest(http.MethodGet, "/api/collections/users/records", nil), env.login)) assert.GreaterOrEqual(t, w.Code, http.StatusBadRequest) assert.NotContains(t, w.Body.String(), env.login) } // TestDegenerateHeaderIdentifiesNobody: вырожденное значение никого не узнаёт и // ничего не заводит. // // Пустое значение здесь не крайний случай, а штатное поведение прокси: там, где // он никого не назвал, заголовок приходит пустым. Без этой проверки все // неназванные собрались бы в одну учётную запись с общим архивом. func TestDegenerateHeaderIdentifiesNobody(t *testing.T) { env := setupTestEnv(t, readableMetaViewer()) cases := map[string]string{ "пустое значение": "", "одни пробелы": " ", "управляющий знак": "ali\x00ce", "длиннее предела": strings.Repeat("a", pbrepo.MaxProviderLoginLength+1), } for name, value := range cases { t.Run(name, func(t *testing.T) { before := countAccounts(t, env) req := httptest.NewRequest(http.MethodGet, "/app/me", nil) req.Header.Set(LoginHeader, value) req.RemoteAddr = trustedPeer w := httptest.NewRecorder() env.mux.ServeHTTP(w, req) assert.Equal(t, http.StatusUnauthorized, w.Code) assert.Equal(t, before, countAccounts(t, env), "вырожденное значение завело учётную запись") }) } } // TestTwoLoginHeadersIdentifyNobody: запрос с двумя значениями заголовка не // узнаёт никого. // // Прокси, настроенный **добавлять** заголовок вместо замены, оставляет рядом со // своим значением присланное анонимом. Умолчание «берём первое» отдало бы вход // анониму, а «берём последнее» зависело бы от порядка, которым распоряжается не // сервис. func TestTwoLoginHeadersIdentifyNobody(t *testing.T) { env := setupTestEnv(t, readableMetaViewer()) before := countAccounts(t, env) req := httptest.NewRequest(http.MethodGet, "/app/me", nil) req.Header.Add(LoginHeader, "intruder") req.Header.Add(LoginHeader, env.login) req.RemoteAddr = trustedPeer w := httptest.NewRecorder() env.mux.ServeHTTP(w, req) assert.Equal(t, http.StatusUnauthorized, w.Code) assert.Equal(t, before, countAccounts(t, env)) } // TestStorageTokenBeatsHeader: годный собственный токен хранилища побеждает // заголовок, а протухший узнаванию не мешает. // // Первая половина защищает владельца панели: подмена его учётной записью // пользователя отобрала бы у него панель посреди работы. Вторая — обычного // человека: негодный токен, оставшийся в браузере, не должен запирать его // снаружи. func TestStorageTokenBeatsHeader(t *testing.T) { env := setupTestEnv(t, readableMetaViewer()) stranger, strangerLogin := newSecondAccount(t, env.app) token, err := stranger.NewAuthToken() require.NoError(t, err) t.Run("годный токен побеждает", func(t *testing.T) { req := httptest.NewRequest(http.MethodGet, "/app/me", nil) req.Header.Set("Authorization", token) w := httptest.NewRecorder() env.mux.ServeHTTP(w, asUser(req, env.login)) require.Equal(t, http.StatusOK, w.Code) assert.Contains(t, w.Body.String(), stranger.Id, "заголовок победил предъявленный токен") _ = strangerLogin }) t.Run("протухший токен узнаванию не мешает", func(t *testing.T) { req := httptest.NewRequest(http.MethodGet, "/app/me", nil) req.Header.Set("Authorization", "not-a-token") w := httptest.NewRecorder() env.mux.ServeHTTP(w, asUser(req, env.login)) require.Equal(t, http.StatusOK, w.Code) assert.Contains(t, w.Body.String(), env.account.Id) }) } // TestOpenAddressesDoNotIdentify: проба здоровья и метрики открыты // неузнанному, а заголовок на них учётной записи не заводит. // // Вторая половина важнее первой: узнавание сужено до области приложения именно // затем, чтобы запрос за каждой картинкой не стоил обращения к базе, а первый // такой запрос с новым именем — записи в неё. func TestOpenAddressesDoNotIdentify(t *testing.T) { app := newTestStorage(t) r, err := apis.NewRouter(app) require.NoError(t, err) appHandler := NewAppHandler(nil, nil, nil, nil, nil) mounts := ServiceMounts(appHandler, http.NotFoundHandler()) r.Bind(TrustedHeaderIdentity(app, mounts, testTrustedNetworks(t), nil)) r.GET(HealthPath, func(e *core.RequestEvent) error { return e.JSON(http.StatusOK, map[string]string{"status": "ok"}) }) r.GET(MetricsPath, func(e *core.RequestEvent) error { return e.String(http.StatusOK, "# metrics") }) mux, err := r.BuildMux() require.NoError(t, err) for _, path := range []string{HealthPath, MetricsPath} { anonymous := httptest.NewRecorder() mux.ServeHTTP(anonymous, httptest.NewRequest(http.MethodGet, path, nil)) assert.Equal(t, http.StatusOK, anonymous.Code, "адрес %s обязан отвечать неузнанному", path) withHeader := httptest.NewRecorder() mux.ServeHTTP(withHeader, asUser(httptest.NewRequest(http.MethodGet, path, nil), "passerby")) assert.Equal(t, http.StatusOK, withHeader.Code, "чужой заголовок изменил ответ адреса %s: наблюдение гасится строкой в запросе", path) } records, err := app.FindAllRecords(migrations.UsersCollection) require.NoError(t, err) assert.Empty(t, records, "обращение к открытому адресу завело учётную запись") } // TestServiceIssuesNothingThatOutlivesRequest: сервис не ставит браузеру куки. // // Проверка судит именно **отсутствие**: пока сервис выдавал значение на семь // суток, отозванный у провайдера человек работал до его истечения. Вернувшаяся // кука вернула бы и это. func TestServiceIssuesNothingThatOutlivesRequest(t *testing.T) { env := setupTestEnv(t, readableMetaViewer()) w := httptest.NewRecorder() env.serve(w, httptest.NewRequest(http.MethodGet, "/app/me", nil)) require.Equal(t, http.StatusOK, w.Code) assert.Empty(t, w.Result().Cookies(), "ответ поставил куку: значение переживёт запрос") } // TestIdentityValuesAreNotLogged — **четвёртый критерий приёмки**: не // печатается ничто, что даёт доступ. // // Проверка ищет в журнале **значения**, а не имена полей: значение, уехавшее под // другим ключом, поиск по ключу не разбудил бы. Логин здесь наравне с почтой: им // довольно назваться, чтобы стать этим человеком. func TestIdentityValuesAreNotLogged(t *testing.T) { env := setupTestEnv(t, readableMetaViewer()) created := httptest.NewRecorder() env.serve(created, createMultipartRequest(t, "test.mp3", []byte("audio"))) require.Equal(t, http.StatusCreated, created.Code) journal := env.journal.String() require.NotEmpty(t, journal, "журнал пуст — проверке не на чем сработать") assert.NotContains(t, journal, env.login, "логин в журнале: строкой довольно назваться, чтобы стать этим человеком") assert.NotContains(t, journal, env.account.Email(), "адрес почты в журнале: он приходит от провайдера и принадлежит человеку") } // TestUntrustedPeerIsLogged: недоверенный источник виден владельцу журналом, и // виден **адресом пира**, а не значением заголовка. // // Без этой строки владелец, у которого никто не может войти, не отличит своей // поломки (перечень доверенных адресов) от поломки контура (прокси заголовка не // ставит) — а это разные поломки в разных местах. func TestUntrustedPeerIsLogged(t *testing.T) { env := setupTestEnv(t, readableMetaViewer()) const intruder = "intruder-login-value" req := httptest.NewRequest(http.MethodGet, "/app/me", nil) req.Header.Set(LoginHeader, intruder) req.RemoteAddr = untrustedPeer w := httptest.NewRecorder() env.mux.ServeHTTP(w, req) require.Equal(t, http.StatusUnauthorized, w.Code) journal := env.journal.String() assert.Contains(t, journal, "203.0.113.9", "адреса пира в журнале нет: поломку не отличить") assert.NotContains(t, journal, intruder, "значение заголовка уехало в журнал") } // TestRecordFileIsProtected: ссылка на файл перестала быть правом пройти по ней. func TestRecordFileIsProtected(t *testing.T) { app := newTestStorage(t) files, err := app.FindCollectionByNameOrId(migrations.FilesCollection) require.NoError(t, err) field, ok := files.Fields.GetByName("file").(*core.FileField) require.True(t, ok) assert.True(t, field.Protected, "поле файла не защищено: знание ссылки снова стало бы доступом, а отзыва у неё нет") } // TestRecordFileNeedsToken: ссылка на файл записи без токена отказывает, а // конвейер тот же файл по-прежнему читает — он ходит в файловую систему, а не по // ссылке. func TestRecordFileNeedsToken(t *testing.T) { env := setupTestEnv(t, readableMetaViewer()) created := httptest.NewRecorder() env.serve(created, createMultipartRequest(t, "test.mp3", []byte("audio content"))) require.Equal(t, http.StatusCreated, created.Code) files, err := env.app.FindAllRecords(migrations.FilesCollection) require.NoError(t, err) require.Len(t, files, 1) names := files[0].GetStringSlice("file") require.Len(t, names, 1) link := "/api/files/" + migrations.FilesCollection + "/" + files[0].Id + "/" + names[0] anonymous := httptest.NewRecorder() env.mux.ServeHTTP(anonymous, httptest.NewRequest(http.MethodGet, link, nil)) // Отказ приходит кодом «не найдено»: защищённый файл не раскрывает даже // своего существования. До пометки поля защищённым эта же ссылка отдавала // содержимое кому угодно — знание ссылки и было доступом. assert.Equal(t, http.StatusNotFound, anonymous.Code, "ссылка отдала файл без токена: знание ссылки снова стало доступом") assert.NotContains(t, anonymous.Body.String(), "audio content") // А узнанный по заголовку берёт токен и проходит: путь «узнавание → токен // файла → ссылка» обязан работать целиком, иначе файл записи недостижим для // браузера вовсе. withToken := httptest.NewRecorder() env.mux.ServeHTTP(withToken, httptest.NewRequest(http.MethodGet, link+"?token="+fileToken(t, env, env.login), nil)) require.Equal(t, http.StatusOK, withToken.Code) assert.Equal(t, "audio content", withToken.Body.String()) // Конвейер читает тот же файл своим путём — из файловой системы хранилища. fileRepo := pbrepo.NewFileRepository(env.app) reader, err := fileRepo.Open(files[0].Id) require.NoError(t, err) defer func() { assert.NoError(t, reader.Close()) }() content := make([]byte, len("audio content")) _, err = reader.Read(content) require.NoError(t, err) assert.Equal(t, "audio content", string(content)) } // countAccounts — сколько учётных записей лежит в хранилище. Проверки судят // заведение по числу строк: «запись одна» и «записи две» — разные исходы, а по // ответу обработчика они неразличимы. func countAccounts(t *testing.T, env *testEnv) int { t.Helper() records, err := env.app.FindAllRecords(migrations.UsersCollection) require.NoError(t, err) return len(records) } // TestRejectedByRateLimitCreatesNoAccount — отвергнутый ограничителем частоты // запрос не заводит учётной записи. // // Слой узнавания читает базу, а на новом имени ещё и пишет в неё. Стоя раньше // ограничителя, он работал на запросах, которые тот уже отверг: сто двадцать // запросов выбирали бюджет, следующие пятьдесят получали отказ — и заводили // пятьдесят учётных записей. Убрать их потом нечем: учётная запись с записями // не удаляется, а мусорная растёт в той же единственной базе. func TestRejectedByRateLimitCreatesNoAccount(t *testing.T) { env := setupTestEnv(t, readableMetaViewer()) require.NoError(t, ApplyAppRateLimit(env.app)) before := countAccounts(t, env) // Бюджет выбирается запросами одного имени, чтобы счётчик успел упереться в // потолок раньше, чем начнутся новые имена. for range appRateMaxRequests + 5 { w := httptest.NewRecorder() env.serve(w, httptest.NewRequest(http.MethodGet, "/app/me", nil)) } rejected := 0 for i := range 20 { w := httptest.NewRecorder() env.mux.ServeHTTP(w, asUser( httptest.NewRequest(http.MethodGet, "/app/me", nil), fmt.Sprintf("newcomer-%d", i))) if w.Code == http.StatusTooManyRequests { rejected++ } } require.Positive(t, rejected, "ограничитель не сработал — проверке не на чем сработать") assert.Equal(t, before, countAccounts(t, env), "отвергнутый ограничителем запрос завёл учётную запись: узнавание стоит раньше ограничителя") } // TestAccountCreationIsLogged — заведение учётной записи видно владельцу. // // Без строки журнала «никто не заходил» неотличимо от «завелось двадцать», а // прокси, пропустивший чужой заголовок, не оставляет следа вовсе. Значение // заголовка при этом в строку не идёт: им довольно назваться, чтобы стать этим // человеком. func TestAccountCreationIsLogged(t *testing.T) { env := setupTestEnv(t, readableMetaViewer()) const login = "brand-new-person" w := httptest.NewRecorder() env.mux.ServeHTTP(w, asUser(httptest.NewRequest(http.MethodGet, "/app/me", nil), login)) require.Equal(t, http.StatusOK, w.Code) journal := env.journal.String() assert.Contains(t, journal, "Account created from login header", "заведение не оставило строки") assert.NotContains(t, journal, login, "значение заголовка уехало в журнал") // Второе обращение новой строки не прибавляет: заводится запись однажды. before := strings.Count(journal, "Account created from login header") again := httptest.NewRecorder() env.mux.ServeHTTP(again, asUser(httptest.NewRequest(http.MethodGet, "/app/me", nil), login)) require.Equal(t, http.StatusOK, again.Code) assert.Equal(t, before, strings.Count(env.journal.String(), "Account created from login header"), "повторное обращение отчиталось заведением") } // TestDuplicateLoginHeaderIsVisibleToOwner — поломка контура видна в бою. // // Два значения заголовка означают прокси, который его добавляет вместо замены, // — модель угроз называет это главным барьером. Отладочным уровнем такая // поломка в бою не видна вовсе: боевой уровень журнала информационный. func TestDuplicateLoginHeaderIsVisibleToOwner(t *testing.T) { env := setupTestEnv(t, readableMetaViewer()) req := httptest.NewRequest(http.MethodGet, "/app/me", nil) req.Header.Add(LoginHeader, "intruder") req.Header.Add(LoginHeader, env.login) req.RemoteAddr = trustedPeer w := httptest.NewRecorder() env.mux.ServeHTTP(w, req) require.Equal(t, http.StatusUnauthorized, w.Code) journal := env.journal.String() assert.Contains(t, journal, "level=WARN", "поломка контура записана уровнем, невидимым в бою") assert.Contains(t, journal, "more than one login header") assert.NotContains(t, journal, "intruder", "значение заголовка уехало в журнал") } // TestStorageFailureOnIdentityIsServiceFailure — отказ хранилища на пути // узнавания кончается отказом сервиса, а не молчаливым проходом неузнанным. // // Иначе человек увидел бы отказ входа там, где легла база, и чинил бы у себя // то, что сломано не у него. func TestStorageFailureOnIdentityIsServiceFailure(t *testing.T) { env := setupTestEnv(t, readableMetaViewer()) // Колонка ключа убирается из схемы: выборка по ней перестаёт работать — так // же, как она перестанет работать при отказе хранилища. users, err := env.app.FindCollectionByNameOrId(migrations.UsersCollection) require.NoError(t, err) users.RemoveIndex("idx_users_provider_login") users.Fields.RemoveByName(migrations.ProviderLoginField) require.NoError(t, env.app.Save(users)) w := httptest.NewRecorder() env.mux.ServeHTTP(w, asUser(httptest.NewRequest(http.MethodGet, "/app/me", nil), "somebody")) assert.GreaterOrEqual(t, w.Code, http.StatusInternalServerError, "отказ хранилища выдан за «вас не узнали»") assert.Contains(t, env.journal.String(), "Failed to resolve account by login header") } // TestFormerAuthRootServesMarkup — прежние адреса входа отдают разметку. // // Корень `/auth` снят из перечня адресного пространства, и путь под ним стал // обычным путём вне корней. Проверка сторожит именно это: вернувшийся корень // начал бы отвечать отказом контракта, и старая закладка молча сменила бы // поведение. func TestFormerAuthRootServesMarkup(t *testing.T) { env := setupWebappEnv(t, builtDist(), true) res := env.get("/auth/login") assert.Equal(t, http.StatusOK, res.Code) assert.Contains(t, res.Body.String(), "приложение") }