- убраны клиент бота, транспорт обновлений, отправитель сообщений, сборка входа при старте, секция настроек и зависимость go-telegram-bot-api; из конвейера ушла доставка ответа отправителю — исход виден опросом готовности. Колонки адресата и значение источника остались в схеме: применённые шаги не переписываются - шаг 202608140003 запрещает пустого владельца у аудиозаписи и у файла; существующие строки он не проверяет, и это принято сознательно — искать их надо запросом до выкладки - ревью нашло два пред-существующих дефекта, оба закрыты: пустой второй ответ распознавателя стирал сохранённую расшифровку, а пустая расшифровка перестала быть заметной вместе с убранной доставкой. Попутно поднят golang.org/x/image до v0.45.0 — красный шаг vulns, воспроизводился и на чистом master
202 lines
9.1 KiB
Go
202 lines
9.1 KiB
Go
package http
|
|
|
|
import (
|
|
"encoding/json"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"testing"
|
|
|
|
"github.com/pocketbase/pocketbase/core"
|
|
"github.com/stretchr/testify/assert"
|
|
"github.com/stretchr/testify/require"
|
|
|
|
"git.vakhrushev.me/av/transcriber/internal/adapter/repo/pocketbase/migrations"
|
|
"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")
|
|
}
|
|
|
|
// Владельцем принятой записи становится предъявитель сессии — и у задачи, и у
|
|
// её файла.
|
|
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
|
|
}
|