- пришедшего называет заголовок Remote-User от прокси, и верят ему только с адреса из перечня trusted_proxies; своего входа у сервиса не осталось — ни корня /auth, ни кук, ни срока сессии, ни секрета клиента в конфиге и в базе - учётная запись заводится первым обращением: EnsureUser в пакете хранилища, шаг схемы 202608220001 с колонкой provider_login и снятыми правилами users - cmd/oidcstub заменён на cmd/devtools с подкомандой proxy; заодно закрыт унаследованный DL3066 — пользователь образа назван числом
642 lines
30 KiB
Go
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(), "приложение")
|
|
}
|