- в конфиг добавлены секция [auth.test_headers] и предохранитель [server] debug: заголовки входа подставляет слой транспорта, второго процесса локальный запуск больше не требует - подкоманда devtools proxy удалена целиком: всё, ради чего её поднимали, делает сам сервис - адресного предохранителя нет по решению владельца — цена названа в ADR и в модели угроз
298 lines
13 KiB
Go
298 lines
13 KiB
Go
package http
|
|
|
|
import (
|
|
"log/slog"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"strings"
|
|
"testing"
|
|
|
|
"github.com/stretchr/testify/assert"
|
|
"github.com/stretchr/testify/require"
|
|
)
|
|
|
|
// Проверки этого файла судят отладочный запуск: чем сервис называет пришедшего,
|
|
// когда обратного прокси рядом нет, и чего подстановке при этом не позволено.
|
|
|
|
// substituted — имитация заголовков, которой пользуются проверки. Значения
|
|
// приметные: по ним же судится запрет на печать подставленного в журнал.
|
|
var substituted = map[string]string{
|
|
LoginHeader: "local-dev-login",
|
|
EmailHeader: "local-dev@example.com",
|
|
}
|
|
|
|
// substitutingLayer собирает слой с журналом боевого уровня и обработчиком,
|
|
// запоминающим дошедший запрос.
|
|
//
|
|
// Уровень именно боевой — умолчание обработчика, то есть `INFO`: строки слоя
|
|
// судятся такими, какими их увидит настоящий прогон, а поднятый ради проверки
|
|
// `DEBUG` показал бы строку, которой в жизни не видит никто.
|
|
func substitutingLayer(t *testing.T, headers map[string]string) (http.Handler, *journalBuffer, *http.Request) {
|
|
t.Helper()
|
|
|
|
journal := &journalBuffer{}
|
|
logger := slog.New(slog.NewTextHandler(journal, nil))
|
|
|
|
var seen http.Request
|
|
next := http.HandlerFunc(func(_ http.ResponseWriter, r *http.Request) {
|
|
seen = *r.Clone(r.Context())
|
|
})
|
|
|
|
return SubstituteIdentityHeaders(headers, testTrustedNetworks(t), logger)(next), journal, &seen
|
|
}
|
|
|
|
// serveSubstituted гоняет запрос через слой и отдаёт то, что дошло до узнавания.
|
|
func serveSubstituted(
|
|
t *testing.T, headers map[string]string, build func(*http.Request),
|
|
) (*http.Request, string) {
|
|
t.Helper()
|
|
|
|
layer, journal, seen := substitutingLayer(t, headers)
|
|
|
|
req := httptest.NewRequest(http.MethodGet, "/app/me", nil)
|
|
req.RemoteAddr = trustedPeer
|
|
build(req)
|
|
|
|
layer.ServeHTTP(httptest.NewRecorder(), req)
|
|
|
|
return seen, journal.String()
|
|
}
|
|
|
|
// Запрос без единого заголовка входа получает имя из настроек, и получает
|
|
// именно то, что в них названо.
|
|
func TestSubstitutionNamesRequestWithoutHeaders(t *testing.T) {
|
|
seen, journal := serveSubstituted(t, substituted, func(*http.Request) {})
|
|
|
|
assert.Equal(t, []string{"local-dev-login"}, seen.Header.Values(LoginHeader))
|
|
assert.Equal(t, "local-dev@example.com", seen.Header.Get(EmailHeader))
|
|
|
|
// Строка о подстановке идёт на `INFO` и видна журналу боевой настройки: ею
|
|
// «узнан подстановкой» отличается от «узнан прокси».
|
|
assert.Contains(t, journal, "level=INFO")
|
|
assert.Contains(t, journal, "Identity headers substituted")
|
|
for _, value := range substituted {
|
|
assert.NotContains(t, journal, value,
|
|
"подставленное значение уехало в журнал: логин — ключ к чужому архиву")
|
|
}
|
|
}
|
|
|
|
// Пришедший заголовок не подменяется: решает **наличие**, а не значение.
|
|
func TestSubstitutionKeepsIncomingLoginHeader(t *testing.T) {
|
|
seen, _ := serveSubstituted(t, substituted, func(r *http.Request) {
|
|
r.Header.Set(LoginHeader, "came-from-proxy")
|
|
})
|
|
|
|
assert.Equal(t, []string{"came-from-proxy"}, seen.Header.Values(LoginHeader))
|
|
}
|
|
|
|
// Два значения остаются двумя: узнавание отвергает такой запрос, и слой,
|
|
// дописавший третье или заменивший оба, превратил бы законный отказ в проход.
|
|
func TestSubstitutionLeavesDoubledLoginHeaderAlone(t *testing.T) {
|
|
seen, _ := serveSubstituted(t, substituted, func(r *http.Request) {
|
|
r.Header.Add(LoginHeader, "first")
|
|
r.Header.Add(LoginHeader, "second")
|
|
})
|
|
|
|
assert.Equal(t, []string{"first", "second"}, seen.Header.Values(LoginHeader))
|
|
}
|
|
|
|
// Недоверенный адрес имени не получает, и отказ этот оставляет **свою** строку:
|
|
// узнавание здесь молчит предупреждением, потому что заголовка нет вовсе.
|
|
func TestSubstitutionRefusesUntrustedPeer(t *testing.T) {
|
|
layer, journal, seen := substitutingLayer(t, substituted)
|
|
|
|
req := httptest.NewRequest(http.MethodGet, "/app/me", nil)
|
|
req.RemoteAddr = untrustedPeer
|
|
layer.ServeHTTP(httptest.NewRecorder(), req)
|
|
|
|
assert.Empty(t, seen.Header.Values(LoginHeader),
|
|
"запрос с недоверенного адреса получил имя из настроек")
|
|
|
|
text := journal.String()
|
|
assert.Contains(t, text, "level=WARN")
|
|
assert.Contains(t, text, "203.0.113.9", "адреса пира в строке нет: поломку не отличить")
|
|
for _, value := range substituted {
|
|
assert.NotContains(t, text, value, "подставляемое значение уехало в журнал")
|
|
}
|
|
}
|
|
|
|
// Слой владеет тройкой целиком: чужая почта при своём логине собрала бы личность
|
|
// из двух источников.
|
|
func TestSubstitutionOwnsWholeHeaderSet(t *testing.T) {
|
|
seen, _ := serveSubstituted(t, map[string]string{LoginHeader: "local-dev-login"},
|
|
func(r *http.Request) {
|
|
r.Header.Set(EmailHeader, "someone-else@example.com")
|
|
r.Header.Set(NameHeader, "Чужое имя")
|
|
})
|
|
|
|
assert.Equal(t, "local-dev-login", seen.Header.Get(LoginHeader))
|
|
assert.Empty(t, seen.Header.Get(EmailHeader),
|
|
"чужой адрес почты дошёл до узнавания вместе со своим логином")
|
|
assert.Empty(t, seen.Header.Get(NameHeader),
|
|
"чужое имя дошло до узнавания вместе со своим логином")
|
|
}
|
|
|
|
// setupSubstitutingEnv собирает поверхность сервиса с включённым отладочным
|
|
// входом — тем же способом, каким её собирает точка входа.
|
|
func setupSubstitutingEnv(t *testing.T, headers map[string]string) *testEnv {
|
|
t.Helper()
|
|
|
|
return setupEnv(t, envOptions{
|
|
metaviewer: readableMetaViewer(),
|
|
dist: builtDist(),
|
|
built: true,
|
|
substitution: headers,
|
|
})
|
|
}
|
|
|
|
// Сквозной сценарий: запрос без заголовков узнаётся, и учётная запись заводится
|
|
// под именем из настроек.
|
|
func TestSubstitutedRequestIsIdentifiedEndToEnd(t *testing.T) {
|
|
env := setupSubstitutingEnv(t, substituted)
|
|
|
|
before := countAccounts(t, env)
|
|
|
|
req := httptest.NewRequest(http.MethodGet, "/app/me", nil)
|
|
req.RemoteAddr = trustedPeer
|
|
w := httptest.NewRecorder()
|
|
env.mux.ServeHTTP(w, req)
|
|
|
|
require.Equal(t, http.StatusOK, w.Code, "запрос без заголовков остался неузнанным")
|
|
assert.Equal(t, before+1, countAccounts(t, env),
|
|
"учётная запись подставленного логина не завелась")
|
|
}
|
|
|
|
// Подставленное значение не встречается ни в одной журнальной записи: заведение
|
|
// учётной записи идёт на INFO, и логин в эту строку попасть не вправе.
|
|
func TestSubstitutedValueNeverReachesJournal(t *testing.T) {
|
|
env := setupSubstitutingEnv(t, substituted)
|
|
|
|
req := httptest.NewRequest(http.MethodGet, "/app/me", nil)
|
|
req.RemoteAddr = trustedPeer
|
|
w := httptest.NewRecorder()
|
|
env.mux.ServeHTTP(w, req)
|
|
require.Equal(t, http.StatusOK, w.Code)
|
|
|
|
journal := env.journal.String()
|
|
require.NotEmpty(t, journal, "журнал пуст — проверке не на чем сработать")
|
|
for _, value := range substituted {
|
|
assert.NotContains(t, journal, value, "подставленное значение уехало в журнал")
|
|
}
|
|
}
|
|
|
|
// Наблюдение подстановки не видит: область слоя равна области узнавания и
|
|
// выводится из неё, а не перечисляется вторым списком.
|
|
func TestObservationAddressesIgnoreSubstitution(t *testing.T) {
|
|
plain := setupTestEnv(t, readableMetaViewer())
|
|
debug := setupSubstitutingEnv(t, substituted)
|
|
|
|
before := countAccounts(t, debug)
|
|
|
|
for _, path := range []string{HealthPath, MetricsPath} {
|
|
t.Run(path, func(t *testing.T) {
|
|
request := func(env *testEnv) *httptest.ResponseRecorder {
|
|
req := httptest.NewRequest(http.MethodGet, path, nil)
|
|
req.RemoteAddr = trustedPeer
|
|
w := httptest.NewRecorder()
|
|
env.mux.ServeHTTP(w, req)
|
|
return w
|
|
}
|
|
|
|
plainResponse := request(plain)
|
|
debugResponse := request(debug)
|
|
|
|
assert.Equal(t, plainResponse.Code, debugResponse.Code)
|
|
assert.Equal(t, plainResponse.Body.String(), debugResponse.Body.String())
|
|
})
|
|
}
|
|
|
|
assert.Equal(t, before, countAccounts(t, debug),
|
|
"проба здоровья завела учётную запись подставленного логина")
|
|
}
|
|
|
|
// Ограничитель частоты работает и в отладочном запуске: предохранитель — это
|
|
// закрытый перечень следствий, а не режим.
|
|
func TestRateLimitStillCutsInDebugRun(t *testing.T) {
|
|
env := setupSubstitutingEnv(t, substituted)
|
|
|
|
refused := 0
|
|
for range appRateMaxRequests + 10 {
|
|
req := httptest.NewRequest(http.MethodGet, "/app/me", nil)
|
|
req.RemoteAddr = trustedPeer
|
|
w := httptest.NewRecorder()
|
|
env.mux.ServeHTTP(w, req)
|
|
if w.Code == http.StatusTooManyRequests {
|
|
refused++
|
|
}
|
|
}
|
|
|
|
assert.Positive(t, refused, "ограничитель частоты в отладочном запуске не сработал")
|
|
}
|
|
|
|
// Пустая имитация заголовков не трогает: слой отдаёт цепочку нетронутой, и до
|
|
// узнавания доходит ровно тот запрос, который пришёл.
|
|
//
|
|
// Судится дошедшее до узнавания, а не строка журнала: запрос идёт с доверенного
|
|
// адреса и без `Remote-User` — ровно тот случай, в котором слой, встань он в
|
|
// цепочку, срезал бы чужие `Remote-Email` и `Remote-Name`.
|
|
func TestEmptySectionLeavesHeadersUntouched(t *testing.T) {
|
|
seen, _ := serveSubstituted(t, nil, func(r *http.Request) {
|
|
r.Header.Set(EmailHeader, "came-from-proxy@example.com")
|
|
r.Header.Set(NameHeader, "Пришедшее имя")
|
|
})
|
|
|
|
assert.Equal(t, "came-from-proxy@example.com", seen.Header.Get(EmailHeader),
|
|
"пустая имитация срезала пришедший адрес почты")
|
|
assert.Equal(t, "Пришедшее имя", seen.Header.Get(NameHeader),
|
|
"пустая имитация срезала пришедшее имя")
|
|
assert.Empty(t, seen.Header.Values(LoginHeader),
|
|
"пустая имитация назвала пришедшего")
|
|
}
|
|
|
|
// Запрос без заголовка с недоверенного адреса оставляет предупреждение с
|
|
// адресом пира: без него самый частый локальный отказ — браузер пришёл с `::1`,
|
|
// а перечень называет `127.0.0.1` — не отличим от поломки узнавания.
|
|
func TestUntrustedPeerWithoutHeaderIsLoggedInDebugRun(t *testing.T) {
|
|
env := setupSubstitutingEnv(t, substituted)
|
|
|
|
req := httptest.NewRequest(http.MethodGet, "/app/me", nil)
|
|
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, "Identity headers are not substituted")
|
|
assert.Contains(t, journal, "203.0.113.9")
|
|
for _, value := range substituted {
|
|
assert.NotContains(t, journal, value)
|
|
}
|
|
}
|
|
|
|
// Конфиг без новых ключей даёт ту же цепочку слоёв и те же ответы: отсутствие
|
|
// предохранителя не меняет ни одного байта поведения.
|
|
func TestChainWithoutSubstitutionAnswersAsBefore(t *testing.T) {
|
|
plain := setupTestEnv(t, readableMetaViewer())
|
|
debug := setupSubstitutingEnv(t, nil)
|
|
|
|
paths := []string{"/app/me", "/app/audiorecords", HealthPath, "/nothing-was-ever-here"}
|
|
for _, path := range paths {
|
|
t.Run(strings.TrimPrefix(path, "/"), func(t *testing.T) {
|
|
request := func(env *testEnv) *httptest.ResponseRecorder {
|
|
req := httptest.NewRequest(http.MethodGet, path, nil)
|
|
req.RemoteAddr = trustedPeer
|
|
w := httptest.NewRecorder()
|
|
env.mux.ServeHTTP(w, req)
|
|
return w
|
|
}
|
|
|
|
plainResponse := request(plain)
|
|
debugResponse := request(debug)
|
|
|
|
assert.Equal(t, plainResponse.Code, debugResponse.Code)
|
|
assert.Equal(t, plainResponse.Body.String(), debugResponse.Body.String())
|
|
})
|
|
}
|
|
}
|