Files
transcriber/internal/controller/http/auth_test.go
T
av c9b7765646 хранилище переехало с PocketBase на SQLite со своим каталогом файлов
- база своя: два пула, захват одним UPDATE ... RETURNING, шаги схемы на goose
  под файловым замком, одна миграция начальной схемы вместо семи прежних
- транспорт переписан на net/http: свои слои, свой ограничитель частоты,
  отдача файла с проверкой владельца; панель /_/ и пространство /api/ исчезли
- по находкам ревью: журнал не пишет путь под корнем приложения, ключ бюджета
  читается справа налево, узнавание известного идёт читающим пулом
2026-08-23 08:06:04 +03:00

456 lines
22 KiB
Go

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(), "приложение")
}