- шаги PocketBase переехали из файла в пакет internal/adapter/repo/pocketbase/migrations, файл на шаг с именем зарегистрированного шага; туда же имена коллекций, срок сессии — в provider.go - ключ migrations в docs/.docs.json наведён на этот каталог: прежнее значение указывало на несуществующий migrations/, и шаг гейта проходил зелёным при всякой правке схемы - app.go подключает пакет шагов явным пустым импортом: пропавшая ссылка на константы унесла бы регистрацию, и хранилище поднялось бы без коллекций
354 lines
14 KiB
Go
354 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, "/api/status/nosuchjobid", nil)
|
|
check.AddCookie(session)
|
|
checkResponse := httptest.NewRecorder()
|
|
|
|
r, err := apis.NewRouter(env.app)
|
|
require.NoError(t, err)
|
|
NewTranscribeHandler(pbrepo.NewTranscriptJobRepository(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")
|
|
}
|