package http import ( "fmt" "net/http" "net/http/httptest" "strings" "testing" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "git.vakhrushev.me/av/transcriber/internal/entity" ) // Проверки этого файла судят допуск: кого пускают к адресам приложения, чем // называется пришедший, кому верят и какие адреса остаются открытыми. // 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") // Ни файла, ни записи: отказ наступает раньше, чем запись попадает в // каталог данных. assert.Equal(t, 0, countFiles(t, env)) assert.Equal(t, 0, countJobs(t, env)) }) t.Run("карточка записи неузнанному", func(t *testing.T) { w := env.get("/app/audiorecords/" + strings.Repeat("0", 26)) assert.Equal(t, http.StatusUnauthorized, w.Code) 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) 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 — учётная запись заводится // первым обращением и находится вторым, а строка её в базе одна. func TestFirstRequestCreatesAccountAndSecondReuses(t *testing.T) { env := setupTestEnv(t, readableMetaViewer()) const login = "newcomer" before := countAccounts(t, env) first := env.getAs(login, "/app/me") require.Equal(t, http.StatusOK, first.Code, "первое обращение узнано") second := env.getAs(login, "/app/me") 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), "заголовок с недоверенного адреса завёл учётную запись") } // TestStorageAddressSpaceIsGone — **критерий приёмки**: пространства хранилища // не существует. // // Прежде под корнем `/api/` жила собственная поверхность встроенного хранилища: // собственные входы, перечисление коллекции пользователей, правка своей строки — // то есть путь захвата чужого имени. Хранилище ушло целиком, и адресов этих нет: // они отвечают тем же, чем отвечает всякий путь вне корней сервиса. // // Проверка судит **и то, что ответ прежний, и то, что ничего не произошло**: // число учётных записей после обхода то же самое. func TestStorageAddressSpaceIsGone(t *testing.T) { env := setupTestEnv(t, readableMetaViewer()) before := countAccounts(t, env) // Эталон: путь вне корней сервиса, за которым не стояло ничего никогда. 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 name, path := range cases { t.Run(name, func(t *testing.T) { w := env.getOwn(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 выдал значение доступа", path) }) } assert.Equal(t, before, countAccounts(t, env), "обход прежнего пространства хранилища изменил число учётных записей") } // TestAccountKeyHasNoEditAddress: ключ учётной записи не правится ничем, кроме // заведения самим сервисом. // // Переписанный ключ отдаёт архив следующему, кто придёт с этим именем, а вернуть // его будет нечем. Держится это тем, что адреса правки учётной записи у сервиса // нет вовсе — своих экранов профиля он не заводит, а поверхности хранилища, // правившей запись библиотечным правилом, не осталось. func TestAccountKeyHasNoEditAddress(t *testing.T) { env := setupTestEnv(t, readableMetaViewer()) 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)) // Метод правки до раздачи приложения не доходит: она открывает страницу // только на `GET` и `HEAD`. assert.Equal(t, http.StatusMethodNotAllowed, w.Code) assert.Equal(t, env.login, accountLogin(t, env, env.account.ID), "ключ учётной записи переписан снаружи") } // TestDegenerateHeaderIdentifiesNobody: вырожденное значение никого не узнаёт и // ничего не заводит. // // Пустое значение здесь не крайний случай, а штатное поведение прокси: там, где // он никого не назвал, заголовок приходит пустым. Без этой проверки все // неназванные собрались бы в одну учётную запись с общим архивом. func TestDegenerateHeaderIdentifiesNobody(t *testing.T) { env := setupTestEnv(t, readableMetaViewer()) cases := map[string]string{ "пустое значение": "", "одни пробелы": " ", "управляющий знак": "ali\x00ce", "длиннее предела": strings.Repeat("a", entity.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)) } // TestPresentedValueIsNotAccepted: предъявленного значения сервис не признаёт. // // Собственных токенов у него не существует — ни выдаваемых, ни принимаемых, — и // пришедшим считается названный заголовком. Прежде такое значение било заголовок: // им пользовался владелец панели, а панели больше нет. func TestPresentedValueIsNotAccepted(t *testing.T) { env := setupTestEnv(t, readableMetaViewer()) stranger := newSecondAccount(t, env) 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)) 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: проба здоровья и метрики открыты неузнанному, // а заголовок на них учётной записи не заводит. // // Вторая половина важнее первой: узнавание сужено до области приложения именно // затем, чтобы запрос за каждой картинкой не стоил обращения к базе, а первый // такой запрос с новым именем — записи в неё. func TestOpenAddressesDoNotIdentify(t *testing.T) { env := setupTestEnv(t, readableMetaViewer()) before := countAccounts(t, env) for _, path := range []string{HealthPath, "/", "/assets/index-abc123.js"} { anonymous := env.get(path) assert.Equal(t, http.StatusOK, anonymous.Code, "адрес %s обязан отвечать неузнанному", path) withHeader := env.getAs("passerby", path) assert.Equal(t, http.StatusOK, withHeader.Code, "чужой заголовок изменил ответ адреса %s: наблюдение гасится строкой в запросе", path) } assert.Equal(t, before, countAccounts(t, env), "обращение к открытому адресу завело учётную запись") } // TestServiceIssuesNothingThatOutlivesRequest: сервис не ставит браузеру куки. // // Проверка судит именно **отсутствие**: пока сервис выдавал значение на семь // суток, отозванный у провайдера человек работал до его истечения. func TestServiceIssuesNothingThatOutlivesRequest(t *testing.T) { env := setupTestEnv(t, readableMetaViewer()) w := env.getOwn("/app/me") 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, "person@example.com", "адрес почты в журнале: он приходит от провайдера и принадлежит человеку") } // 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, "значение заголовка уехало в журнал") } // countAccounts — сколько учётных записей лежит в базе. Проверки судят заведение // по числу строк: «запись одна» и «записи две» — разные исходы, а по ответу // обработчика они неразличимы. func countAccounts(t *testing.T, env *testEnv) int { t.Helper() return countRows(t, env, "users") } // 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()) before := countAccounts(t, env) // Бюджет выбирается запросами одного имени, чтобы счётчик успел упереться в // потолок раньше, чем начнутся новые имена. for range appRateMaxRequests + 5 { env.getOwn("/app/me") } rejected := 0 for i := range 20 { w := env.getAs(fmt.Sprintf("newcomer-%d", i), "/app/me") 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 := env.getAs(login, "/app/me") 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 := 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"), "повторное обращение отчиталось заведением") } // 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()) // Колонка ключа переименовывается: выборка по ней перестаёт работать — так // же, как она перестанет работать при отказе базы. _, err := env.db.Writer().Exec("ALTER TABLE users RENAME COLUMN provider_login TO provider_login_gone") require.NoError(t, err) 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") } // TestFormerAuthRootServesMarkup — прежние адреса входа отдают разметку. // // Корень `/auth` снят из перечня адресного пространства, и путь под ним стал // обычным путём вне корней. Проверка сторожит именно это: вернувшийся корень // начал бы отвечать отказом контракта, и старая закладка молча сменила бы // поведение. func TestFormerAuthRootServesMarkup(t *testing.T) { env := setupTestEnv(t, readableMetaViewer()) res := env.get("/auth/login") assert.Equal(t, http.StatusOK, res.Code) assert.Contains(t, res.Body.String(), "приложение") }