Files
transcriber/internal/controller/http/login_test.go
T
av 3a2da3004b приём и чтение записей сведены к одному контракту приложения
- адреса приложения переехали в своё пространство `/app/`, опрос готовности
  убран целиком: рубеж и причину остановки владелец узнаёт карточкой записи,
  текст — отдельным адресом названного вида
- заведена единая точка отображения доменной ошибки и слой, приводящий к той же
  форме отказы библиотеки: тело несёт машиночитаемый код рядом с сообщением
- у записи появились имя файла отправителя, длительность и размер своими
  колонками, а у ленты владельца — свой индекс: без него страница сканировала
  весь архив сервиса
2026-08-15 13:51:23 +03:00

359 lines
14 KiB
Go

package http
import (
"encoding/json"
"net/http"
"net/http/httptest"
"net/url"
"reflect"
"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"
)
// Проверки этого файла проходят вход целиком — от увода к провайдеру до куки
// сессии. Без них сердцевина изменения не исполнялась ни разу: прочие проверки
// заводят учётную запись прямым сохранением и останавливаются раньше обмена.
// fakeProvider — подставной провайдер OIDC. Отдаёт токен и сведения о человеке,
// считая обращения: по счётчику видно, дошло ли до сети вообще.
type fakeProvider struct {
server *httptest.Server
tokenHits int
failToken bool
subject string
emailValue string
}
func newFakeProvider(t *testing.T) *fakeProvider {
t.Helper()
provider := &fakeProvider{subject: "person-sub-1", emailValue: "person@example.com"}
write := func(w http.ResponseWriter, body string) {
if _, err := w.Write([]byte(body)); err != nil {
t.Errorf("подставной провайдер не ответил: %v", err)
}
}
mux := http.NewServeMux()
mux.HandleFunc("/token", func(w http.ResponseWriter, r *http.Request) {
provider.tokenHits++
if provider.failToken {
w.WriteHeader(http.StatusBadRequest)
write(w, `{"error":"invalid_grant"}`)
return
}
w.Header().Set("Content-Type", "application/json")
write(w, `{"access_token":"provider-access-token","token_type":"bearer","expires_in":3600}`)
})
mux.HandleFunc("/userinfo", func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
write(w, `{"sub":"`+provider.subject+`","email":"`+provider.emailValue+`","name":"Person","email_verified":true}`)
})
provider.server = httptest.NewServer(mux)
t.Cleanup(provider.server.Close)
return provider
}
// loginEnv — окружение проверки входа: хранилище с настроенным подставным
// провайдером и собранный роутер со всеми слоями.
type loginEnv struct {
app core.App
mux http.Handler
handler *AuthHandler
provider *fakeProvider
}
func setupLoginEnv(t *testing.T) *loginEnv {
t.Helper()
app := newTestStorage(t)
provider := newFakeProvider(t)
require.NoError(t, pbrepo.ApplyProviderSettings(app, pbrepo.ProviderSettings{
AuthURL: provider.server.URL + "/authorize",
TokenURL: provider.server.URL + "/token",
UserInfoURL: provider.server.URL + "/userinfo",
ClientID: "transcriber",
ClientSecret: "local-test-secret",
}))
handler := NewAuthHandler(app, AuthHandlerConfig{
AuthURL: provider.server.URL + "/authorize",
RedirectURL: "https://transcriber.example.com/auth/callback",
ClientID: "transcriber",
SecureCookie: true,
}, nil)
r, err := apis.NewRouter(app)
require.NoError(t, err)
handler.Register(r)
mux, err := r.BuildMux()
require.NoError(t, err)
return &loginEnv{app: app, mux: mux, handler: handler, provider: provider}
}
// startLogin проходит первый шаг входа и отдаёт носитель состояния вместе с
// выданным состоянием — тем, что сервис ждёт обратно.
func (e *loginEnv) startLogin(t *testing.T) (cookie *http.Cookie, state string) {
t.Helper()
w := httptest.NewRecorder()
e.mux.ServeHTTP(w, httptest.NewRequest(http.MethodGet, "/auth/login", nil))
require.Equal(t, http.StatusFound, w.Code)
for _, c := range w.Result().Cookies() {
if c.Name == stateCookieName {
cookie = c
}
}
require.NotNil(t, cookie, "носитель состояния не поставлен")
location, err := url.Parse(w.Result().Header.Get("Location"))
require.NoError(t, err)
state = location.Query().Get("state")
require.NotEmpty(t, state)
return cookie, state
}
// TestLoginCreatesAccountAndSession — вход целиком: человека заводят по слову
// провайдера, и он получает сессию.
//
// Без этой проверки закрытое создание записи в коллекции пользователей выглядит
// работающим: прочие проверки заводят запись мимо входа.
func TestLoginCreatesAccountAndSession(t *testing.T) {
env := setupLoginEnv(t)
before, err := env.app.FindAllRecords("users")
require.NoError(t, err)
require.Empty(t, before, "учётных записей быть не должно: шаг схемы их не заводит")
cookie, state := env.startLogin(t)
req := httptest.NewRequest(http.MethodGet, "/auth/callback?code=provider-code&state="+state, nil)
req.AddCookie(cookie)
w := httptest.NewRecorder()
env.mux.ServeHTTP(w, req)
require.Equal(t, http.StatusFound, w.Code, "вход не прошёл: тело %s", w.Body.String())
assert.Equal(t, 1, env.provider.tokenHits, "обмен до провайдера не дошёл")
after, err := env.app.FindAllRecords("users")
require.NoError(t, err)
require.Len(t, after, 1, "учётная запись не заведена — войти не может никто")
var session *http.Cookie
for _, c := range w.Result().Cookies() {
if c.Name == SessionCookieName {
session = c
}
}
require.NotNil(t, session, "кука сессии не поставлена")
// Признаки куки нормативны: их потеря делает сессию доступной скриптам либо
// уносит её по незашифрованному соединению.
assert.True(t, session.HttpOnly)
assert.True(t, session.Secure)
assert.Equal(t, http.SameSiteLaxMode, session.SameSite)
assert.Equal(t, pbrepo.SessionDuration, session.MaxAge)
assert.NotEmpty(t, session.Value)
// Носитель состояния убран — и убран так, что это видно готовому ответу, а
// не только живой карте заголовков.
var cleared bool
for _, c := range w.Result().Cookies() {
if c.Name == stateCookieName && c.MaxAge < 0 {
cleared = true
}
}
assert.True(t, cleared, "носитель состояния пережил возврат")
// Выданная сессия открывает доступ к закрытым адресам.
check := httptest.NewRequest(http.MethodGet, "/app/audiorecords/nosuchjobid", nil)
check.AddCookie(session)
checkResponse := httptest.NewRecorder()
r, err := apis.NewRouter(env.app)
require.NoError(t, err)
NewAppHandler(
pbrepo.NewAudioRecordRepository(env.app),
pbrepo.NewTextRepository(env.app),
pbrepo.NewStructureRepository(env.app),
nil, nil,
).Register(r)
checkMux, err := r.BuildMux()
require.NoError(t, err)
checkMux.ServeHTTP(checkResponse, check)
assert.Equal(t, http.StatusNotFound, checkResponse.Code,
"сессия не открыла доступ: получен %d", checkResponse.Code)
}
// TestSelfServiceRegistrationStaysClosed: правило создания пускает обмен и не
// пускает постороннего.
func TestSelfServiceRegistrationStaysClosed(t *testing.T) {
env := setupLoginEnv(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.GreaterOrEqual(t, w.Code, http.StatusBadRequest,
"посторонний завёл себе учётную запись: %s", w.Body.String())
accounts, err := env.app.FindAllRecords("users")
require.NoError(t, err)
assert.Empty(t, accounts)
}
// TestCallbackDoesNotLeakHooks: обмен не копит обработчики приложения.
//
// Сборка роутера хранилища вешает обработчики на само приложение и без
// идентификатора, поэтому повторная не заменяет прежние. Собранный на каждый
// вход, роутер копил бы их без предела — и копил бы по запросу анонима, потому
// что обмен исполняется раньше обращения к провайдеру.
func TestCallbackDoesNotLeakHooks(t *testing.T) {
env := setupLoginEnv(t)
count := func() int {
hook := reflect.ValueOf(env.app.OnModelAfterCreateSuccess()).Elem().FieldByName("handlers")
return hook.Len()
}
// Первый вход собирает роутер — с него и считаем.
cookie, state := env.startLogin(t)
first := httptest.NewRequest(http.MethodGet, "/auth/callback?code=c&state="+state, nil)
first.AddCookie(cookie)
env.mux.ServeHTTP(httptest.NewRecorder(), first)
before := count()
for range 20 {
cookie, state := env.startLogin(t)
req := httptest.NewRequest(http.MethodGet, "/auth/callback?code=c&state="+state, nil)
req.AddCookie(cookie)
env.mux.ServeHTTP(httptest.NewRecorder(), req)
}
assert.Equal(t, before, count(),
"обработчики копятся: 20 входов добавили %d", count()-before)
}
// TestSessionRefreshIsClosed: сессия не продлевает саму себя.
//
// При живом продлении срок её жизни ничего не значит, а вместе с ним перестаёт
// работать единственный канал, которым отзыв доступа у провайдера доходит до
// сервиса.
func TestSessionRefreshIsClosed(t *testing.T) {
env := setupLoginEnv(t)
cookie, state := env.startLogin(t)
req := httptest.NewRequest(http.MethodGet, "/auth/callback?code=provider-code&state="+state, nil)
req.AddCookie(cookie)
w := httptest.NewRecorder()
env.mux.ServeHTTP(w, req)
require.Equal(t, http.StatusFound, w.Code)
var session *http.Cookie
for _, c := range w.Result().Cookies() {
if c.Name == SessionCookieName {
session = c
}
}
require.NotNil(t, session)
refresh := httptest.NewRequest(http.MethodPost, RefreshPath, nil)
refresh.Header.Set("Authorization", session.Value)
refreshResponse := httptest.NewRecorder()
env.mux.ServeHTTP(refreshResponse, refresh)
assert.Equal(t, http.StatusNotFound, refreshResponse.Code,
"сессия продлилась: %s", refreshResponse.Body.String())
// И нового значения в ответе нет — продлевать нечем.
assert.NotContains(t, refreshResponse.Body.String(), `"token"`)
}
// TestCallbackRejectsProviderFailure: отказ обмена не открывает сессию.
func TestCallbackRejectsProviderFailure(t *testing.T) {
env := setupLoginEnv(t)
env.provider.failToken = true
cookie, state := env.startLogin(t)
req := httptest.NewRequest(http.MethodGet, "/auth/callback?code=provider-code&state="+state, nil)
req.AddCookie(cookie)
w := httptest.NewRecorder()
env.mux.ServeHTTP(w, req)
assert.Equal(t, http.StatusUnauthorized, w.Code)
for _, c := range w.Result().Cookies() {
assert.NotEqual(t, SessionCookieName, c.Name, "сессия открыта на отказе обмена")
}
accounts, err := env.app.FindAllRecords("users")
require.NoError(t, err)
assert.Empty(t, accounts)
}
// TestRecordFileNeedsSessionAndToken: файл записи отдаётся вошедшему и не
// отдаётся анониму.
func TestRecordFileNeedsSessionAndToken(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.GreaterOrEqual(t, anonymous.Code, http.StatusBadRequest)
assert.NotContains(t, anonymous.Body.String(), "audio content")
// Вошедший берёт короткоживущий токен файла и проходит по ссылке с ним:
// защищённый файл судится этим токеном, а не сессионной кукой.
tokenRequest := httptest.NewRequest(http.MethodPost, "/api/files/token", nil)
tokenRequest.Header.Set("Authorization", env.session)
tokenResponse := httptest.NewRecorder()
env.mux.ServeHTTP(tokenResponse, tokenRequest)
require.Equal(t, http.StatusOK, tokenResponse.Code, "токен файла не выдан: %s", tokenResponse.Body.String())
var payload struct {
Token string `json:"token"`
}
require.NoError(t, json.Unmarshal(tokenResponse.Body.Bytes(), &payload))
require.NotEmpty(t, payload.Token)
withToken := httptest.NewRecorder()
env.mux.ServeHTTP(withToken, httptest.NewRequest(http.MethodGet, link+"?token="+payload.Token, nil))
assert.Equal(t, http.StatusOK, withToken.Code,
"вошедший не получил файл: %d", withToken.Code)
assert.Contains(t, withToken.Body.String(), "audio content")
}