Files
transcriber/internal/controller/http/auth_test.go
T
av 7f33c957e5 вход переехал на доверенный заголовок Authelia вместо собственного OIDC
- пришедшего называет заголовок Remote-User от прокси, и верят ему только с
  адреса из перечня trusted_proxies; своего входа у сервиса не осталось — ни
  корня /auth, ни кук, ни срока сессии, ни секрета клиента в конфиге и в базе
- учётная запись заводится первым обращением: EnsureUser в пакете хранилища,
  шаг схемы 202608220001 с колонкой provider_login и снятыми правилами users
- cmd/oidcstub заменён на cmd/devtools с подкомандой proxy; заодно закрыт
  унаследованный DL3066 — пользователь образа назван числом
2026-08-22 20:24:22 +03:00

642 lines
30 KiB
Go

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(), "приложение")
}