Files
transcriber/internal/controller/http/auth_test.go
T
av 1576d06735 внутренняя модель перестроена вокруг аудиозаписи
- audiorecords вместо transcribe_jobs: приложения (texts, structures,
  recognitions, record_events, topics) живут своими коллекциями, ссылки на
  исходник и на приведённую копию перестали переставляться
- рубеж называет достигнутое, отказ стал признаком остановки с причиной, а
  сторожей стало двое: число отказов и время в рубеже
- воркеры потеряли специализацию, их число задаётся [pipeline] workers, шаг
  выбирается по рубежу, а захват отдаёт идентификатор и признак захвата
2026-08-14 20:20:33 +03:00

540 lines
22 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package http
import (
"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"
)
// Проверки этого файла судят допуск: кого пускают к приёму и опросу, чем
// предъявляется сессия, что её прекращает и какие адреса остаются открытыми.
// TestApiRequiresSession — первый критерий приёмки. Запрос без сессии получает
// отказ и ничего не заводит, а проба здоровья и метрики остаются открытыми.
func TestApiRequiresSession(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, "/api/status/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")
})
}
// TestUnknownJobIsIndistinguishableWithoutSession: по кодам ответа без сессии не
// перебирается список заведённых задач.
func TestUnknownJobIsIndistinguishableWithoutSession(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, "/api/status/"+jobs[0].Id, nil))
missing := httptest.NewRecorder()
env.mux.ServeHTTP(missing, httptest.NewRequest(http.MethodGet, "/api/status/nosuchjobid", nil))
assert.Equal(t, http.StatusUnauthorized, existing.Code)
assert.Equal(t, missing.Code, existing.Code)
}
// TestOpenEndpointsStayOpen — вторая сторона границы: проба здоровья и метрики
// сессии не требуют. Маршруты вешает `main`, поэтому здесь собирается такой же
// роутер с теми же двумя адресами.
func TestOpenEndpointsStayOpen(t *testing.T) {
app := newTestStorage(t)
r, err := apis.NewRouter(app)
require.NoError(t, err)
r.GET("/health", func(e *core.RequestEvent) error {
return e.JSON(http.StatusOK, map[string]string{"status": "ok"})
})
r.GET("/metrics", func(e *core.RequestEvent) error {
return e.String(http.StatusOK, "# metrics")
})
mux, err := r.BuildMux()
require.NoError(t, err)
for _, path := range []string{"/health", "/metrics"} {
w := httptest.NewRecorder()
mux.ServeHTTP(w, httptest.NewRequest(http.MethodGet, path, nil))
assert.Equal(t, http.StatusOK, w.Code, "адрес %s обязан отвечать без сессии", path)
}
}
// TestSessionSurvivesRestart — второй критерий приёмки. Подпись сессии считается
// от секрета коллекции и ключа записи, оба лежат в базе, поэтому выкладка
// вошедших не выкидывает.
func TestSessionSurvivesRestart(t *testing.T) {
env := setupTestEnv(t, readableMetaViewer())
before := httptest.NewRecorder()
env.serve(before, createMultipartRequest(t, "test.mp3", []byte("audio")))
require.Equal(t, http.StatusCreated, before.Code)
// Сервер пересоздаётся на том же хранилище — то же, что перезапуск процесса
// поверх прежнего каталога данных.
r, err := apis.NewRouter(env.app)
require.NoError(t, err)
env.handler.Register(r)
mux, err := r.BuildMux()
require.NoError(t, err)
req := httptest.NewRequest(http.MethodGet, "/api/status/nosuchjobid", nil)
req.AddCookie(&http.Cookie{Name: SessionCookieName, Value: env.session})
w := httptest.NewRecorder()
mux.ServeHTTP(w, req)
// Прежняя кука прошла проверку: до обработчика дошло, и он ответил про
// ненайденную задачу, а не про отсутствующую сессию.
assert.Equal(t, http.StatusNotFound, w.Code)
}
// TestLogoutClosesAccess — третий критерий приёмки. Выход обесценивает выданные
// сессии, а не только убирает куку.
func TestLogoutClosesAccess(t *testing.T) {
env := setupTestEnv(t, readableMetaViewer())
authHandler := NewAuthHandler(env.app, AuthHandlerConfig{
AuthURL: "https://auth.example.com/api/oidc/authorization",
RedirectURL: "https://transcriber.example.com/auth/callback",
ClientID: "transcriber",
SecureCookie: true,
}, nil)
r, err := apis.NewRouter(env.app)
require.NoError(t, err)
authHandler.Register(r)
env.handler.Register(r)
mux, err := r.BuildMux()
require.NoError(t, err)
logout := httptest.NewRequest(http.MethodPost, "/auth/logout", nil)
logout.AddCookie(&http.Cookie{Name: SessionCookieName, Value: env.session})
logoutResponse := httptest.NewRecorder()
mux.ServeHTTP(logoutResponse, logout)
require.Equal(t, http.StatusOK, logoutResponse.Code)
// Куку выход убирает.
assert.Contains(t, logoutResponse.Result().Header.Get("Set-Cookie"), SessionCookieName+"=;")
// И прежнее значение больше не открывает доступ — этого уборка куки сама по
// себе не даёт: унесённое значение работало бы до истечения срока.
after := httptest.NewRequest(http.MethodGet, "/api/status/nosuchjobid", nil)
after.AddCookie(&http.Cookie{Name: SessionCookieName, Value: env.session})
afterResponse := httptest.NewRecorder()
mux.ServeHTTP(afterResponse, after)
assert.Equal(t, http.StatusUnauthorized, afterResponse.Code)
}
// TestLogoutWhenAccountIsGone: учётной записи, которой предъявлена сессия, уже
// нет — выход отвечает отказом и не делает вид, что закрыл доступ.
func TestLogoutWhenAccountIsGone(t *testing.T) {
env := setupTestEnv(t, readableMetaViewer())
authHandler := NewAuthHandler(env.app, AuthHandlerConfig{
AuthURL: "https://auth.example.com/api/oidc/authorization",
RedirectURL: "https://transcriber.example.com/auth/callback",
ClientID: "transcriber",
SecureCookie: true,
}, nil)
r, err := apis.NewRouter(env.app)
require.NoError(t, err)
authHandler.Register(r)
mux, err := r.BuildMux()
require.NoError(t, err)
// Сессия выдана, а запись удалена — так выглядит гонка выхода с удалением
// учётной записи в панели.
require.NoError(t, env.app.Delete(env.account))
logout := httptest.NewRequest(http.MethodPost, "/auth/logout", nil)
logout.AddCookie(&http.Cookie{Name: SessionCookieName, Value: env.session})
w := httptest.NewRecorder()
mux.ServeHTTP(w, logout)
// Записи нет — проверка сессии её не находит, и до обесценивания дело не
// доходит: выход отвечает успехом, убрав куку. Доступа при этом всё равно
// не осталось, потому что не осталось учётной записи.
assert.Equal(t, http.StatusOK, w.Code)
assert.Contains(t, w.Result().Header.Get("Set-Cookie"), SessionCookieName+"=;")
after := httptest.NewRequest(http.MethodGet, "/api/status/nosuchjobid", nil)
after.AddCookie(&http.Cookie{Name: SessionCookieName, Value: env.session})
afterResponse := httptest.NewRecorder()
env.mux.ServeHTTP(afterResponse, after)
assert.Equal(t, http.StatusUnauthorized, afterResponse.Code)
}
// TestLogoutWithoutSession: выход без сессии убирает куку и молчит.
func TestLogoutWithoutSession(t *testing.T) {
env := setupTestEnv(t, readableMetaViewer())
authHandler := NewAuthHandler(env.app, AuthHandlerConfig{
AuthURL: "https://auth.example.com/api/oidc/authorization",
RedirectURL: "https://transcriber.example.com/auth/callback",
ClientID: "transcriber",
SecureCookie: true,
}, nil)
r, err := apis.NewRouter(env.app)
require.NoError(t, err)
authHandler.Register(r)
mux, err := r.BuildMux()
require.NoError(t, err)
w := httptest.NewRecorder()
mux.ServeHTTP(w, httptest.NewRequest(http.MethodPost, "/auth/logout", nil))
assert.Equal(t, http.StatusOK, w.Code)
assert.Contains(t, w.Result().Header.Get("Set-Cookie"), SessionCookieName+"=;")
}
// TestLoginRedirectsToProvider: вход уводит к провайдеру и запоминает состояние
// у браузера.
func TestLoginRedirectsToProvider(t *testing.T) {
env := setupTestEnv(t, readableMetaViewer())
authHandler := NewAuthHandler(env.app, AuthHandlerConfig{
AuthURL: "https://auth.example.com/api/oidc/authorization",
RedirectURL: "https://transcriber.example.com/auth/callback",
ClientID: "transcriber",
SecureCookie: true,
}, nil)
r, err := apis.NewRouter(env.app)
require.NoError(t, err)
authHandler.Register(r)
mux, err := r.BuildMux()
require.NoError(t, err)
w := httptest.NewRecorder()
mux.ServeHTTP(w, httptest.NewRequest(http.MethodGet, "/auth/login", nil))
require.Equal(t, http.StatusFound, w.Code)
location := w.Result().Header.Get("Location")
assert.Contains(t, location, "https://auth.example.com/api/oidc/authorization")
assert.Contains(t, location, "code_challenge_method=S256")
assert.Contains(t, location, "client_id=transcriber")
// Носитель состояния несёт те же признаки защиты, что и кука сессии.
stateCookie := w.Result().Header.Get("Set-Cookie")
assert.Contains(t, stateCookie, stateCookieName)
assert.Contains(t, stateCookie, "HttpOnly")
assert.Contains(t, stateCookie, "Secure")
assert.Contains(t, stateCookie, "SameSite=Lax")
}
// TestCallbackRejectsForeignState — возврат с невыданным состоянием сессии не
// открывает и учётной записи не заводит.
func TestCallbackRejectsForeignState(t *testing.T) {
env := setupTestEnv(t, readableMetaViewer())
authHandler := NewAuthHandler(env.app, AuthHandlerConfig{
AuthURL: "https://auth.example.com/api/oidc/authorization",
RedirectURL: "https://transcriber.example.com/auth/callback",
ClientID: "transcriber",
SecureCookie: true,
}, nil)
r, err := apis.NewRouter(env.app)
require.NoError(t, err)
authHandler.Register(r)
mux, err := r.BuildMux()
require.NoError(t, err)
accountsBefore, err := env.app.FindAllRecords("users")
require.NoError(t, err)
cases := []struct {
name string
cookie *http.Cookie
query string
}{
{
name: "состояния не выдавали вовсе",
cookie: nil,
query: "?code=whatever&state=foreign",
},
{
name: "состояние не совпало с выданным",
cookie: &http.Cookie{Name: stateCookieName, Value: "issued:verifier"},
query: "?code=whatever&state=foreign",
},
{
name: "провайдер вернул отказ",
cookie: &http.Cookie{Name: stateCookieName, Value: "issued:verifier"},
query: "?error=access_denied&state=issued",
},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
req := httptest.NewRequest(http.MethodGet, "/auth/callback"+tc.query, nil)
if tc.cookie != nil {
req.AddCookie(tc.cookie)
}
w := httptest.NewRecorder()
mux.ServeHTTP(w, req)
assert.Equal(t, http.StatusUnauthorized, w.Code)
accountsAfter, err := env.app.FindAllRecords("users")
require.NoError(t, err)
assert.Len(t, accountsAfter, len(accountsBefore))
// Носитель убирается и на отказном возврате: иначе состояние
// осталось бы годным для новой попытки.
assert.Contains(t, w.Result().Header.Get("Set-Cookie"), stateCookieName+"=;")
})
}
}
// TestHeaderBeatsCookie: предъявленный заголовок побеждает куку.
func TestHeaderBeatsCookie(t *testing.T) {
env := setupTestEnv(t, readableMetaViewer())
req := httptest.NewRequest(http.MethodGet, "/api/status/nosuchjobid", nil)
req.AddCookie(&http.Cookie{Name: SessionCookieName, Value: "totally-invalid-session"})
req.Header.Set("Authorization", env.session)
w := httptest.NewRecorder()
env.mux.ServeHTTP(w, req)
// Прошёл заголовок: иначе негодная кука дала бы отказ.
assert.Equal(t, http.StatusNotFound, w.Code)
}
// TestSelfServiceAccountsAreClosed — то, ради чего задача вообще имеет смысл.
// Пока создание записи и вход по паролю открыты, закрытие приёма обходится
// двумя запросами.
func TestSelfServiceAccountsAreClosed(t *testing.T) {
env := setupTestEnv(t, readableMetaViewer())
t.Run("завести учётную запись самому нельзя", func(t *testing.T) {
body := strings.NewReader(`{"email":"intruder@example.com","password":"12345678901","passwordConfirm":"12345678901"}`)
req := httptest.NewRequest(http.MethodPost, "/api/collections/users/records", body)
req.Header.Set("Content-Type", "application/json")
w := httptest.NewRecorder()
env.mux.ServeHTTP(w, req)
assert.NotEqual(t, http.StatusOK, w.Code)
assert.GreaterOrEqual(t, w.Code, http.StatusBadRequest)
})
t.Run("вход паролем недоступен", func(t *testing.T) {
body := strings.NewReader(`{"identity":"person@example.com","password":"whatever"}`)
req := httptest.NewRequest(http.MethodPost, "/api/collections/users/auth-with-password", body)
req.Header.Set("Content-Type", "application/json")
w := httptest.NewRecorder()
env.mux.ServeHTTP(w, req)
assert.GreaterOrEqual(t, w.Code, http.StatusBadRequest)
})
}
// TestAccessGrantingValuesAreNotLogged — четвёртый критерий приёмки, расширенный
// ревью дизайна: не печатается ничто, что даёт доступ.
//
// Проверка ищет в журнале **значения**, а не имена полей: значение, уехавшее под
// другим ключом, поиск по ключу не разбудил бы.
func TestAccessGrantingValuesAreNotLogged(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.session,
"значение сессии в журнале: строка стала бы ключом к чужому доступу")
assert.NotContains(t, journal, env.account.Email(),
"адрес почты в журнале: он приходит от провайдера и принадлежит человеку")
}
// TestProviderSecretIsNotLogged: секрет клиента не появляется в журнале при
// приведении настроек провайдера к конфигу.
func TestProviderSecretIsNotLogged(t *testing.T) {
app := newTestStorage(t)
const secret = "super-secret-client-value"
require.NoError(t, pbrepo.ApplyProviderSettings(app, pbrepo.ProviderSettings{
AuthURL: "https://auth.example.com/api/oidc/authorization",
TokenURL: "https://auth.example.com/api/oidc/token",
UserInfoURL: "https://auth.example.com/api/oidc/userinfo",
ClientID: "transcriber",
ClientSecret: secret,
}))
// Настройка доехала до хранилища — иначе проверка отсутствия секрета в
// журнале прошла бы на невыполненной работе.
users, err := app.FindCollectionByNameOrId("users")
require.NoError(t, err)
provider, found := users.OAuth2.GetProviderConfig(pbrepo.ProviderName)
require.True(t, found)
assert.Equal(t, secret, provider.ClientSecret)
}
// TestProviderSecretRotationReachesStorage: смена секрета в конфиге доезжает до
// хранилища. Положенный однажды шагом схемы, он бы не доехал — применённый шаг
// не переписывается.
func TestProviderSecretRotationReachesStorage(t *testing.T) {
app := newTestStorage(t)
settings := pbrepo.ProviderSettings{
AuthURL: "https://auth.example.com/api/oidc/authorization",
TokenURL: "https://auth.example.com/api/oidc/token",
UserInfoURL: "https://auth.example.com/api/oidc/userinfo",
ClientID: "transcriber",
ClientSecret: "first-secret",
}
require.NoError(t, pbrepo.ApplyProviderSettings(app, settings))
settings.ClientSecret = "rotated-secret"
require.NoError(t, pbrepo.ApplyProviderSettings(app, settings))
users, err := app.FindCollectionByNameOrId("users")
require.NoError(t, err)
provider, found := users.OAuth2.GetProviderConfig(pbrepo.ProviderName)
require.True(t, found)
assert.Equal(t, "rotated-secret", provider.ClientSecret)
}
// 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,
"поле файла не защищено: знание ссылки снова стало бы доступом, а отзыва у неё нет")
}
// TestRecordFileNeedsSession: ссылка на файл записи без сессии отказывает, а
// конвейер тот же файл по-прежнему читает — он ходит в файловую систему, а не по
// ссылке.
func TestRecordFileNeedsSession(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")
// Конвейер читает тот же файл своим путём — из файловой системы хранилища.
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))
}
// TestSessionLifetimeIsAssigned: срок жизни сессии назначен нами, а не достался
// умолчанием библиотеки в пять суток.
//
// Назначается он приведением настроек при подъёме, а не шагом схемы: применённый
// шаг не переписывается, и число, положенное туда, разошлось бы со сроком жизни
// куки при первой же правке.
func TestSessionLifetimeIsAssigned(t *testing.T) {
app := newTestStorage(t)
users, err := app.FindCollectionByNameOrId("users")
require.NoError(t, err)
require.NotEqual(t, int64(pbrepo.SessionDuration), users.AuthToken.Duration,
"шаг схемы назначил срок сам — тогда правка числа до хранилища не доедет")
require.NoError(t, pbrepo.ApplyProviderSettings(app, pbrepo.ProviderSettings{
AuthURL: "https://auth.example.com/api/oidc/authorization",
TokenURL: "https://auth.example.com/api/oidc/token",
UserInfoURL: "https://auth.example.com/api/oidc/userinfo",
ClientID: "transcriber",
ClientSecret: "local-test-secret",
}))
users, err = app.FindCollectionByNameOrId("users")
require.NoError(t, err)
assert.Equal(t, int64(pbrepo.SessionDuration), users.AuthToken.Duration)
}