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) }