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()) }) } }