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" ) // Проверки этого файла проходят вход целиком — от увода к провайдеру до куки // сессии. Без них сердцевина изменения не исполнялась ни разу: прочие проверки // заводят учётную запись прямым сохранением и останавливаются раньше обмена. // 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(pbrepo.FilesCollection) require.NoError(t, err) require.Len(t, files, 1) names := files[0].GetStringSlice("file") require.Len(t, names, 1) link := "/api/files/" + pbrepo.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") }