хранилище переехало с PocketBase на SQLite со своим каталогом файлов
- база своя: два пула, захват одним UPDATE ... RETURNING, шаги схемы на goose под файловым замком, одна миграция начальной схемы вместо семи прежних - транспорт переписан на net/http: свои слои, свой ограничитель частоты, отдача файла с проверкой владельца; панель /_/ и пространство /api/ исчезли - по находкам ревью: журнал не пишет путь под корнем приложения, ключ бюджета читается справа налево, узнавание известного идёт читающим пулом
This commit is contained in:
@@ -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")
|
||||
|
||||
|
||||
Reference in New Issue
Block a user