package http import ( "encoding/json" "net/http" "net/http/httptest" "strings" "testing" "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" "git.vakhrushev.me/av/transcriber/internal/clock" "git.vakhrushev.me/av/transcriber/internal/contract" "git.vakhrushev.me/av/transcriber/internal/entity" ) // Проверки разграничения записей по владельцу. Все идут через собранный роутер: // сужение живёт в хранилище, но судится по тому, что видит отправитель. // newSecondAccount заводит вторую учётную запись с собственной сессией. // Постоянный адрес почты первой занят, и повторное сохранение отвергается — // адрес здесь свой. func newSecondAccount(t *testing.T, app core.App) (*core.Record, string) { t.Helper() users, err := app.FindCollectionByNameOrId("users") require.NoError(t, err) record := core.NewRecord(users) record.Set("email", "stranger@example.com") record.Set("verified", true) record.SetRandomPassword() require.NoError(t, app.Save(record)) token, err := record.NewAuthToken() require.NoError(t, err) return record, token } // withSessionHeader предъявляет сессию заголовком. Собственная поверхность // хранилища читается только так: слой, перекладывающий куку в заголовок, на неё // намеренно не наведён — часть её защищена ровно тем, что браузер заголовка сам // не шлёт. func withSessionHeader(req *http.Request, session string) *http.Request { req.Header.Set("Authorization", session) return req } // serveAs шлёт запрос от имени названной сессии, а не сессии окружения. func serveAs(env *testEnv, session string, w http.ResponseWriter, req *http.Request) { req.AddCookie(&http.Cookie{Name: SessionCookieName, Value: session}) env.mux.ServeHTTP(w, req) } // Чужая задача неотличима от несуществующей: тот же код и то же тело. Разница // ответов обратила бы опрос в перебор — по ней считывается, какие задачи // заведены. func TestGetTranscribeJobStatus_ForeignJobLooksMissing(t *testing.T) { env := setupTestEnv(t, readableMetaViewer()) job := jobWithFile(t, env) _, stranger := newSecondAccount(t, env.app) foreign := httptest.NewRecorder() serveAs(env, stranger, foreign, httptest.NewRequest("GET", "/api/status/"+job.Id, http.NoBody)) unknown := httptest.NewRecorder() serveAs(env, stranger, unknown, httptest.NewRequest("GET", "/api/status/unknown0000000000", http.NoBody)) require.Equal(t, http.StatusNotFound, foreign.Code, "чужая задача не отдаётся") assert.Equal(t, unknown.Code, foreign.Code, "код тот же, что у неизвестного идентификатора") assert.JSONEq(t, unknown.Body.String(), foreign.Body.String(), "и тело то же") // Ни состояния, ни текста расшифровки в теле нет. assert.NotContains(t, foreign.Body.String(), entity.StateUploaded) assert.NotContains(t, foreign.Body.String(), "transcription_text") } // Задача, принятая ботом, владельца не имеет и не достаётся по API никому. func TestGetTranscribeJobStatus_OwnerlessJobLooksMissing(t *testing.T) { env := setupTestEnv(t, readableMetaViewer()) fileRepo := pbrepo.NewFileRepository(env.app) work, err := fileRepo.Stage(".ogg", strings.NewReader("запись")) require.NoError(t, err) defer func() { require.NoError(t, work.Close()) }() // Файл записи из Telegram владельца тоже не имеет. file, err := fileRepo.Create("voice.ogg", work, contract.FileMeta{Format: "ogg"}, "") require.NoError(t, err) job := &entity.AudioRecord{ State: entity.StateUploaded, StateEnteredAt: clock.Now(), Source: entity.SourceTelegram, OriginalFileID: &file.Id, } require.NoError(t, env.handler.recordRepo.Create(job)) w := httptest.NewRecorder() env.serve(w, httptest.NewRequest("GET", "/api/status/"+job.Id, http.NoBody)) assert.Equal(t, http.StatusNotFound, w.Code) } // Владельцем принятой записи становится предъявитель сессии — и у задачи, и у // её файла. func TestCreateTranscribeJob_OwnerIsSession(t *testing.T) { env := setupTestEnv(t, readableMetaViewer()) w := httptest.NewRecorder() env.serve(w, createMultipartRequest(t, "sample.mp3", []byte("запись"))) require.Equal(t, http.StatusCreated, w.Code) var response CreateTranscribeJobResponse require.NoError(t, json.Unmarshal(w.Body.Bytes(), &response)) record, err := env.app.FindRecordById(migrations.RecordsCollection, response.JobID) require.NoError(t, err) assert.Equal(t, env.account.Id, record.GetString("owner"), "владелец задачи — предъявитель") fileRecord, err := env.app.FindRecordById("files", record.GetString("original_file")) require.NoError(t, err) assert.Equal(t, env.account.Id, fileRecord.GetString("owner"), "владелец файла — он же") } // Владельца не задают запросом: своё значение в форме на результат не влияет. func TestCreateTranscribeJob_OwnerFieldFromRequestIgnored(t *testing.T) { env := setupTestEnv(t, readableMetaViewer()) _, stranger := newSecondAccount(t, env.app) req := createMultipartRequest(t, "sample.mp3", []byte("запись")) query := req.URL.Query() query.Set("owner", stranger) req.URL.RawQuery = query.Encode() w := httptest.NewRecorder() env.serve(w, req) require.Equal(t, http.StatusCreated, w.Code) var response CreateTranscribeJobResponse require.NoError(t, json.Unmarshal(w.Body.Bytes(), &response)) record, err := env.app.FindRecordById(migrations.RecordsCollection, response.JobID) require.NoError(t, err) assert.Equal(t, env.account.Id, record.GetString("owner")) } // Предъявитель, чья сессия не даёт учётной записи пользователя, получает отказ // до чтения тела. Владелец панели — именно такой: узнан он узнан, а записи в // коллекции пользователей у него нет, и владельцем записи он стать не может. // // Отказ **до** укладки обязателен: позже пришлось бы убирать уже сохранённый // файл, а уборки файлов сервис не умеет вовсе. func TestCreateTranscribeJob_SuperuserSessionRejected(t *testing.T) { env := setupTestEnv(t, readableMetaViewer()) superusers, err := env.app.FindCollectionByNameOrId(core.CollectionNameSuperusers) require.NoError(t, err) admin := core.NewRecord(superusers) admin.Set("email", "owner@example.com") admin.SetRandomPassword() require.NoError(t, env.app.Save(admin)) token, err := admin.NewAuthToken() require.NoError(t, err) w := httptest.NewRecorder() serveAs(env, token, w, createMultipartRequest(t, "sample.mp3", []byte("запись"))) assert.Equal(t, http.StatusForbidden, w.Code, "узнан, но не запись коллекции пользователей") assert.Equal(t, 0, countJobs(t, env), "задачи не заведено") assert.Equal(t, 0, countFiles(t, env), "и файла тоже") } // Чужой файл не отдаётся по ссылке, а свой отдаётся. Проверяется именно переход // по ссылке: токен файла хранилище выдаёт на предъявителя, а не на файл, и отказ // наступает на скачивании, где правило просмотра судит владельца. Проверка, // написанная на выдачу токена, зеленела бы, не касаясь пути, по которому аудио и // уходит. func TestFileDownload_NarrowedByOwner(t *testing.T) { env := setupTestEnv(t, readableMetaViewer()) job := jobWithFile(t, env) _, stranger := newSecondAccount(t, env.app) record, err := env.app.FindRecordById("files", *job.OriginalFileID) require.NoError(t, err) require.Equal(t, env.account.Id, record.GetString("owner")) link := "/api/files/files/" + record.Id + "/" + record.GetString("file") mine := httptest.NewRecorder() env.mux.ServeHTTP(mine, withSessionHeader( httptest.NewRequest("GET", link+"?token="+fileToken(t, env, env.session), http.NoBody), env.session)) foreign := httptest.NewRecorder() env.mux.ServeHTTP(foreign, withSessionHeader( httptest.NewRequest("GET", link+"?token="+fileToken(t, env, stranger), http.NoBody), stranger)) require.Equal(t, http.StatusOK, mine.Code, "свой файл отдаётся") assert.Equal(t, "запись", mine.Body.String(), "и отдаётся содержимым") assert.NotEqual(t, http.StatusOK, foreign.Code, "чужой файл не отдаётся") assert.NotContains(t, foreign.Body.String(), "запись", "содержимого в отказе нет") } // fileToken берёт у хранилища токен файла для названной сессии. Токен выдаётся // на предъявителя: о файле хранилище при выдаче не спрашивает. func fileToken(t *testing.T, env *testEnv, session string) string { t.Helper() w := httptest.NewRecorder() env.mux.ServeHTTP(w, withSessionHeader( httptest.NewRequest("POST", "/api/files/token", http.NoBody), session)) require.Equal(t, http.StatusOK, w.Code, "токен файла выдаётся всякому вошедшему") var body struct { Token string `json:"token"` } require.NoError(t, json.Unmarshal(w.Body.Bytes(), &body)) require.NotEmpty(t, body.Token) return body.Token }