хранилище переехало с PocketBase на SQLite со своим каталогом файлов

- база своя: два пула, захват одним UPDATE ... RETURNING, шаги схемы на goose
  под файловым замком, одна миграция начальной схемы вместо семи прежних
- транспорт переписан на net/http: свои слои, свой ограничитель частоты,
  отдача файла с проверкой владельца; панель /_/ и пространство /api/ исчезли
- по находкам ревью: журнал не пишет путь под корнем приложения, ключ бюджета
  читается справа налево, узнавание известного идёт читающим пулом
This commit is contained in:
av
2026-08-23 08:06:04 +03:00
parent 1edf8cb225
commit c9b7765646
118 changed files with 11668 additions and 6679 deletions
+121 -307
View File
@@ -7,16 +7,13 @@ import (
"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"
"git.vakhrushev.me/av/transcriber/internal/entity"
)
// Проверки этого файла судят допуск: кого пускают к приёму и опросу, чем
// Проверки этого файла судят допуск: кого пускают к адресам приложения, чем
// называется пришедший, кому верят и какие адреса остаются открытыми.
// TestApiRequiresIdentity — первый критерий приёмки. Запрос неузнанного получает
@@ -33,31 +30,22 @@ func TestApiRequiresIdentity(t *testing.T) {
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)
// Ни файла, ни записи: отказ наступает раньше, чем запись попадает в
// каталог данных.
assert.Equal(t, 0, countFiles(t, env))
assert.Equal(t, 0, countJobs(t, env))
})
t.Run("карточка записи неузнанному", func(t *testing.T) {
req := httptest.NewRequest(http.MethodGet, "/app/audiorecords/anything", nil)
w := httptest.NewRecorder()
env.mux.ServeHTTP(w, req)
w := env.get("/app/audiorecords/" + strings.Repeat("0", 26))
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())
@@ -65,24 +53,16 @@ func TestUnknownJobIsIndistinguishableWithoutIdentity(t *testing.T) {
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))
existing := env.get("/app/audiorecords/" + intakeItemOf(t, created).ID)
missing := env.get("/app/audiorecords/" + strings.Repeat("0", 26))
assert.Equal(t, http.StatusUnauthorized, existing.Code)
assert.Equal(t, missing.Code, existing.Code)
assert.Equal(t, missing.Body.String(), existing.Body.String())
}
// TestFirstRequestCreatesAccountAndSecondReuses — **первый критерий приёмки**.
//
// Два запроса подряд с одним значением заголовка: учётная запись заводится
// первым и находится вторым, а в хранилище её строка одна.
// TestFirstRequestCreatesAccountAndSecondReuses — учётная запись заводится
// первым обращением и находится вторым, а строка её в базе одна.
func TestFirstRequestCreatesAccountAndSecondReuses(t *testing.T) {
env := setupTestEnv(t, readableMetaViewer())
@@ -90,12 +70,10 @@ func TestFirstRequestCreatesAccountAndSecondReuses(t *testing.T) {
before := countAccounts(t, env)
first := httptest.NewRecorder()
env.mux.ServeHTTP(first, asUser(httptest.NewRequest(http.MethodGet, "/app/me", nil), login))
first := env.getAs(login, "/app/me")
require.Equal(t, http.StatusOK, first.Code, "первое обращение узнано")
second := httptest.NewRecorder()
env.mux.ServeHTTP(second, asUser(httptest.NewRequest(http.MethodGet, "/app/me", nil), login))
second := env.getAs(login, "/app/me")
require.Equal(t, http.StatusOK, second.Code)
assert.Equal(t, before+1, countAccounts(t, env),
@@ -104,8 +82,8 @@ func TestFirstRequestCreatesAccountAndSecondReuses(t *testing.T) {
"второе обращение попало в другую учётную запись")
}
// TestUntrustedPeerIsNotIdentified — **второй критерий приёмки**. Тот же
// заголовок с недоверенного адреса даёт отказ, а не вход под названным именем.
// TestUntrustedPeerIsNotIdentified — тот же заголовок с недоверенного адреса
// даёт отказ, а не вход под названным именем.
func TestUntrustedPeerIsNotIdentified(t *testing.T) {
env := setupTestEnv(t, readableMetaViewer())
@@ -123,118 +101,75 @@ func TestUntrustedPeerIsNotIdentified(t *testing.T) {
"заголовок с недоверенного адреса завёл учётную запись")
}
// TestStorageOwnLoginAddressesGiveNothing — **третий критерий приёмки**.
// TestStorageAddressSpaceIsGone — **критерий приёмки**: пространства хранилища
// не существует.
//
// Перечень собственных адресов входа хранилища: каждый отвечает отказом и
// учётной записи не меняет. Перечень закрыт и назван поимённо — пока хоть один
// из них работает, узнавание по заголовку обходится двумя запросами.
func TestStorageOwnLoginAddressesGiveNothing(t *testing.T) {
// Прежде под корнем `/api/` жила собственная поверхность встроенного хранилища:
// собственные входы, перечисление коллекции пользователей, правка своей строки —
// то есть путь захвата чужого имени. Хранилище ушло целиком, и адресов этих нет:
// они отвечают тем же, чем отвечает всякий путь вне корней сервиса.
//
// Проверка судит **и то, что ответ прежний, и то, что ничего не произошло**:
// число учётных записей после обхода то же самое.
func TestStorageAddressSpaceIsGone(t *testing.T) {
env := setupTestEnv(t, readableMetaViewer())
const usersRoot = "/api/collections/users"
before := countAccounts(t, env)
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: `{}`,
},
// Эталон: путь вне корней сервиса, за которым не стояло ничего никогда.
reference := env.get("/nothing-was-ever-here")
require.Equal(t, http.StatusOK, reference.Code)
cases := map[string]string{
"перечисление учётных записей": "/api/collections/users/records",
"вход по паролю": "/api/collections/users/auth-with-password",
"обмен кода у провайдера": "/api/collections/users/auth-with-oauth2",
"выдача токена файла": "/api/files/token",
"адрес панели": "/_/",
"адрес панели знаком кода": "/%5f/",
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
before := countAccounts(t, env)
for name, path := range cases {
t.Run(name, func(t *testing.T) {
w := env.getOwn(path)
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.Equal(t, reference.Code, w.Code,
"адрес %s отвечает не как всякий неизвестный путь", path)
assert.Equal(t, reference.Body.String(), w.Body.String(),
"адрес %s отвечает не тем же телом, что всякий неизвестный путь", path)
assert.NotContains(t, w.Body.String(), `"token"`,
"адрес %s выдал значение доступа", c.path)
assert.Equal(t, before, countAccounts(t, env),
"адрес %s изменил число учётных записей", c.path)
"адрес %s выдал значение доступа", path)
})
}
assert.Equal(t, before, countAccounts(t, env),
"обход прежнего пространства хранилища изменил число учётных записей")
}
// TestUserRecordCannotBeEditedFromOutside — путь захвата чужого имени закрыт.
// TestAccountKeyHasNoEditAddress: ключ учётной записи не правится ничем, кроме
// заведения самим сервисом.
//
// Ключ учётной записи лежит обычной колонкой, а умолчание библиотеки открывает
// владельцу записи правку собственной строки. Пока узнавание жило под корнем
// приложения, до этой поверхности браузер не дотягивался вовсе; теперь она
// достижима, и правка своей записи была бы захватом чужого имени: поставил себе
// чужой логин — и первое обращение настоящего его владельца попало бы в твою
// запись вместе со всем архивом.
func TestUserRecordCannotBeEditedFromOutside(t *testing.T) {
// Переписанный ключ отдаёт архив следующему, кто придёт с этим именем, а вернуть
// его будет нечем. Держится это тем, что адреса правки учётной записи у сервиса
// нет вовсе — своих экранов профиля он не заводит, а поверхности хранилища,
// правившей запись библиотечным правилом, не осталось.
func TestAccountKeyHasNoEditAddress(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)
body := strings.NewReader(`{"provider_login":"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),
// Метод правки до раздачи приложения не доходит: она открывает страницу
// только на `GET` и `HEAD`.
assert.Equal(t, http.StatusMethodNotAllowed, w.Code)
assert.Equal(t, env.login, accountLogin(t, env, env.account.ID),
"ключ учётной записи переписан снаружи")
}
// 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: вырожденное значение никого не узнаёт и
// ничего не заводит.
//
@@ -248,7 +183,7 @@ func TestDegenerateHeaderIdentifiesNobody(t *testing.T) {
"пустое значение": "",
"одни пробелы": " ",
"управляющий знак": "ali\x00ce",
"длиннее предела": strings.Repeat("a", pbrepo.MaxProviderLoginLength+1),
"длиннее предела": strings.Repeat("a", entity.MaxProviderLoginLength+1),
}
for name, value := range cases {
@@ -292,104 +227,66 @@ func TestTwoLoginHeadersIdentifyNobody(t *testing.T) {
assert.Equal(t, before, countAccounts(t, env))
}
// TestStorageTokenBeatsHeader: годный собственный токен хранилища побеждает
// заголовок, а протухший узнаванию не мешает.
// TestPresentedValueIsNotAccepted: предъявленного значения сервис не признаёт.
//
// Первая половина защищает владельца панели: подмена его учётной записью
// пользователя отобрала бы у него панель посреди работы. Вторая — обычного
// человека: негодный токен, оставшийся в браузере, не должен запирать его
// снаружи.
func TestStorageTokenBeatsHeader(t *testing.T) {
// Собственных токенов у него не существует — ни выдаваемых, ни принимаемых, — и
// пришедшим считается названный заголовком. Прежде такое значение било заголовок:
// им пользовался владелец панели, а панели больше нет.
func TestPresentedValueIsNotAccepted(t *testing.T) {
env := setupTestEnv(t, readableMetaViewer())
stranger, strangerLogin := newSecondAccount(t, env.app)
token, err := stranger.NewAuthToken()
require.NoError(t, err)
stranger := newSecondAccount(t, env)
t.Run("годный токен побеждает", func(t *testing.T) {
req := httptest.NewRequest(http.MethodGet, "/app/me", nil)
req.Header.Set("Authorization", token)
req := httptest.NewRequest(http.MethodGet, "/app/me?token=whatever", nil)
req.Header.Set("Authorization", "Bearer whatever")
w := httptest.NewRecorder()
env.mux.ServeHTTP(w, asUser(req, env.login))
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)
})
require.Equal(t, http.StatusOK, w.Code)
assert.Contains(t, w.Body.String(), env.account.ID,
"предъявленное значение победило заголовок")
assert.NotContains(t, w.Body.String(), stranger.ID)
}
// TestOpenAddressesDoNotIdentify: проба здоровья и метрики открыты
// неузнанному, а заголовок на них учётной записи не заводит.
// TestOpenAddressesDoNotIdentify: проба здоровья и метрики открыты неузнанному,
// а заголовок на них учётной записи не заводит.
//
// Вторая половина важнее первой: узнавание сужено до области приложения именно
// затем, чтобы запрос за каждой картинкой не стоил обращения к базе, а первый
// такой запрос с новым именем — записи в неё.
func TestOpenAddressesDoNotIdentify(t *testing.T) {
app := newTestStorage(t)
env := setupTestEnv(t, readableMetaViewer())
r, err := apis.NewRouter(app)
require.NoError(t, err)
before := countAccounts(t, env)
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))
for _, path := range []string{HealthPath, "/", "/assets/index-abc123.js"} {
anonymous := env.get(path)
assert.Equal(t, http.StatusOK, anonymous.Code, "адрес %s обязан отвечать неузнанному", path)
withHeader := httptest.NewRecorder()
mux.ServeHTTP(withHeader, asUser(httptest.NewRequest(http.MethodGet, path, nil), "passerby"))
withHeader := env.getAs("passerby", path)
assert.Equal(t, http.StatusOK, withHeader.Code,
"чужой заголовок изменил ответ адреса %s: наблюдение гасится строкой в запросе", path)
}
records, err := app.FindAllRecords(migrations.UsersCollection)
require.NoError(t, err)
assert.Empty(t, records, "обращение к открытому адресу завело учётную запись")
assert.Equal(t, before, countAccounts(t, env),
"обращение к открытому адресу завело учётную запись")
}
// TestServiceIssuesNothingThatOutlivesRequest: сервис не ставит браузеру куки.
//
// Проверка судит именно **отсутствие**: пока сервис выдавал значение на семь
// суток, отозванный у провайдера человек работал до его истечения. Вернувшаяся
// кука вернула бы и это.
// суток, отозванный у провайдера человек работал до его истечения.
func TestServiceIssuesNothingThatOutlivesRequest(t *testing.T) {
env := setupTestEnv(t, readableMetaViewer())
w := httptest.NewRecorder()
env.serve(w, httptest.NewRequest(http.MethodGet, "/app/me", nil))
w := env.getOwn("/app/me")
require.Equal(t, http.StatusOK, w.Code)
assert.Empty(t, w.Result().Cookies(), "ответ поставил куку: значение переживёт запрос")
}
// TestIdentityValuesAreNotLogged — **четвёртый критерий приёмки**: не
// печатается ничто, что даёт доступ.
// TestIdentityValuesAreNotLogged — не печатается ничто, что даёт доступ.
//
// Проверка ищет в журнале **значения**, а не имена полей: значение, уехавшее под
// другим ключом, поиск по ключу не разбудил бы. Логин здесь наравне с почтой: им
@@ -406,16 +303,12 @@ func TestIdentityValuesAreNotLogged(t *testing.T) {
assert.NotContains(t, journal, env.login,
"логин в журнале: строкой довольно назваться, чтобы стать этим человеком")
assert.NotContains(t, journal, env.account.Email(),
assert.NotContains(t, journal, "person@example.com",
"адрес почты в журнале: он приходит от провайдера и принадлежит человеку")
}
// TestUntrustedPeerIsLogged: недоверенный источник виден владельцу журналом, и
// виден **адресом пира**, а не значением заголовка.
//
// Без этой строки владелец, у которого никто не может войти, не отличит своей
// поломки (перечень доверенных адресов) от поломки контура (прокси заголовка не
// ставит) — а это разные поломки в разных местах.
func TestUntrustedPeerIsLogged(t *testing.T) {
env := setupTestEnv(t, readableMetaViewer())
@@ -434,110 +327,46 @@ func TestUntrustedPeerIsLogged(t *testing.T) {
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 — сколько учётных записей лежит в хранилище. Проверки судят
// заведение по числу строк: «запись одна» и «записи две» — разные исходы, а по
// ответу обработчика они неразличимы.
// countAccounts — сколько учётных записей лежит в базе. Проверки судят заведение
// по числу строк: «запись одна» и «записи две» — разные исходы, а по ответу
// обработчика они неразличимы.
func countAccounts(t *testing.T, env *testEnv) int {
t.Helper()
records, err := env.app.FindAllRecords(migrations.UsersCollection)
require.NoError(t, err)
return countRows(t, env, "users")
}
return len(records)
// accountLogin читает ключ учётной записи прямо из базы.
func accountLogin(t *testing.T, env *testEnv, accountID string) string {
t.Helper()
var login string
require.NoError(t, env.db.Reader().
QueryRow("SELECT provider_login FROM users WHERE id = ?", accountID).Scan(&login))
return login
}
// 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))
env.getOwn("/app/me")
}
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)))
w := env.getAs(fmt.Sprintf("newcomer-%d", i), "/app/me")
if w.Code == http.StatusTooManyRequests {
rejected++
}
@@ -549,18 +378,12 @@ func TestRejectedByRateLimitCreatesNoAccount(t *testing.T) {
}
// 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))
w := env.getAs(login, "/app/me")
require.Equal(t, http.StatusOK, w.Code)
journal := env.journal.String()
@@ -570,8 +393,7 @@ func TestAccountCreationIsLogged(t *testing.T) {
// Второе обращение новой строки не прибавляет: заводится запись однажды.
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))
again := env.getAs(login, "/app/me")
require.Equal(t, http.StatusOK, again.Code)
assert.Equal(t, before, strings.Count(env.journal.String(), "Account created from login header"),
@@ -579,10 +401,6 @@ func TestAccountCreationIsLogged(t *testing.T) {
}
// TestDuplicateLoginHeaderIsVisibleToOwner — поломка контура видна в бою.
//
// Два значения заголовка означают прокси, который его добавляет вместо замены,
// — модель угроз называет это главным барьером. Отладочным уровнем такая
// поломка в бою не видна вовсе: боевой уровень журнала информационный.
func TestDuplicateLoginHeaderIsVisibleToOwner(t *testing.T) {
env := setupTestEnv(t, readableMetaViewer())
@@ -601,27 +419,23 @@ func TestDuplicateLoginHeaderIsVisibleToOwner(t *testing.T) {
assert.NotContains(t, journal, "intruder", "значение заголовка уехало в журнал")
}
// TestStorageFailureOnIdentityIsServiceFailure — отказ хранилища на пути
// узнавания кончается отказом сервиса, а не молчаливым проходом неузнанным.
// TestStorageFailureOnIdentityIsServiceFailure — отказ базы на пути узнавания
// кончается отказом сервиса, а не молчаливым проходом неузнанным.
//
// Иначе человек увидел бы отказ входа там, где легла база, и чинил бы у себя
// то, что сломано не у него.
// Иначе человек увидел бы отказ входа там, где легла база, и чинил бы у себя то,
// что сломано не у него.
func TestStorageFailureOnIdentityIsServiceFailure(t *testing.T) {
env := setupTestEnv(t, readableMetaViewer())
// Колонка ключа убирается из схемы: выборка по ней перестаёт работать — так
// же, как она перестанет работать при отказе хранилища.
users, err := env.app.FindCollectionByNameOrId(migrations.UsersCollection)
// Колонка ключа переименовывается: выборка по ней перестаёт работать — так
// же, как она перестанет работать при отказе базы.
_, err := env.db.Writer().Exec("ALTER TABLE users RENAME COLUMN provider_login TO provider_login_gone")
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"))
w := env.getAs("somebody", "/app/me")
assert.GreaterOrEqual(t, w.Code, http.StatusInternalServerError,
"отказ хранилища выдан за «вас не узнали»")
"отказ базы выдан за «вас не узнали»")
assert.Contains(t, env.journal.String(), "Failed to resolve account by login header")
}
@@ -632,7 +446,7 @@ func TestStorageFailureOnIdentityIsServiceFailure(t *testing.T) {
// начал бы отвечать отказом контракта, и старая закладка молча сменила бы
// поведение.
func TestFormerAuthRootServesMarkup(t *testing.T) {
env := setupWebappEnv(t, builtDist(), true)
env := setupTestEnv(t, readableMetaViewer())
res := env.get("/auth/login")