у записи появился владелец: чужую больше не отдают
- колонка `owner` связью с `users` в обеих коллекциях новым шагом схемы `202608140001`; чтение задачи сужено владельцем, и чужая, ничья и несуществующая дают один ответ; правило просмотра файлов сужено им же - приём по HTTP берёт владельца из сессии, а предъявителя без учётной записи пользователя отвергает до чтения тела: позже пришлось бы убирать уложенный файл, а уборки файлов сервис не умеет. Выборка воркера владельцем не сужается - удаление учётной записи с записями отвергается стражем, и вешает его сама сборка хранилища: сборка, забывшая его позвать, теряла защиту молча
This commit is contained in:
@@ -0,0 +1,228 @@
|
||||
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/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.StateCreated)
|
||||
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.CreateLocal("voice.ogg", work, "")
|
||||
require.NoError(t, err)
|
||||
|
||||
job := &entity.TranscribeJob{
|
||||
State: entity.StateCreated,
|
||||
Source: entity.SourceTelegram,
|
||||
FileID: &file.Id,
|
||||
}
|
||||
require.NoError(t, env.handler.jobRepo.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("transcribe_jobs", response.JobID)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, env.account.Id, record.GetString("owner"), "владелец задачи — предъявитель")
|
||||
|
||||
fileRecord, err := env.app.FindRecordById("files", record.GetString("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("transcribe_jobs", 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.FileID)
|
||||
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
|
||||
}
|
||||
@@ -2,6 +2,7 @@ package http
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"time"
|
||||
@@ -10,6 +11,7 @@ import (
|
||||
"github.com/pocketbase/pocketbase/core"
|
||||
"github.com/pocketbase/pocketbase/tools/router"
|
||||
|
||||
"git.vakhrushev.me/av/transcriber/internal/adapter/repo/pocketbase/migrations"
|
||||
"git.vakhrushev.me/av/transcriber/internal/contract"
|
||||
"git.vakhrushev.me/av/transcriber/internal/entity"
|
||||
"git.vakhrushev.me/av/transcriber/internal/service"
|
||||
@@ -51,7 +53,13 @@ func (h *TranscribeHandler) Register(r *router.Router[*core.RequestEvent]) {
|
||||
// него не подпадает, часть её защищена ровно тем, что браузер заголовка сам
|
||||
// не шлёт.
|
||||
api.Bind(SessionFromCookie())
|
||||
api.Bind(apis.RequireAuth())
|
||||
// Коллекция названа поимённо, а не оставлена умолчанию. Без имени проверка
|
||||
// пускает всякую учётную запись хранилища, включая владельца панели, — а
|
||||
// записи в коллекции пользователей у него нет, и владельцем записи он стать
|
||||
// не может. Отказ такому предъявителю обязан наступить здесь, до чтения
|
||||
// тела: позже пришлось бы убирать уже уложенный файл, а уборки файлов
|
||||
// сервис не умеет вовсе.
|
||||
api.Bind(apis.RequireAuth(migrations.UsersCollection))
|
||||
|
||||
// Умолчание роутера хранилища — 32 МиБ на тело, и оно отсекало бы запись
|
||||
// раньше обработчика, без строки в журнале приёма. Приём размеру не судья,
|
||||
@@ -79,7 +87,11 @@ func (h *TranscribeHandler) CreateTranscribeJob(e *core.RequestEvent) error {
|
||||
// (журнал запроса, сессия) при этом сохраняются, теряется только отмена.
|
||||
ctx := context.WithoutCancel(e.Request.Context())
|
||||
|
||||
job, err := h.trsService.CreateJobFromApi(ctx, file, header.Filename)
|
||||
// Владелец берётся из предъявленной сессии и ниоткуда больше: владелец,
|
||||
// пришедший полем запроса, дал бы всякому вошедшему право завести запись на
|
||||
// чужое имя. Проверка предъявителя стоит слоем выше, поэтому здесь `e.Auth`
|
||||
// уже есть и принадлежит коллекции пользователей.
|
||||
job, err := h.trsService.CreateJobFromApi(ctx, file, header.Filename, e.Auth.Id)
|
||||
if err != nil {
|
||||
// Второй раз отказ не логируем: приём назван конвенцией логирующей
|
||||
// границей и уже написал о нём. Транспорт переводит ошибку в ответ.
|
||||
@@ -96,8 +108,22 @@ func (h *TranscribeHandler) CreateTranscribeJob(e *core.RequestEvent) error {
|
||||
func (h *TranscribeHandler) GetTranscribeJobStatus(e *core.RequestEvent) error {
|
||||
jobID := e.Request.PathValue("id")
|
||||
|
||||
job, err := h.jobRepo.GetByID(jobID)
|
||||
// Чужая задача, задача без владельца и несуществующая отвечают одним и тем
|
||||
// же: хранилище отдаёт на все три ту же ошибку, а транспорт — тот же код и
|
||||
// то же тело. Различать их наружу нельзя — по разнице ответов перебирается
|
||||
// список заведённых задач.
|
||||
job, err := h.jobRepo.GetByID(jobID, e.Auth.Id)
|
||||
if err != nil {
|
||||
// Наружу ответ один на все исходы, а в журнал они идут по-разному.
|
||||
// «Задачи нет» и «задача чужая» — штатная работа разграничения, о ней
|
||||
// писать нечего; всё прочее — отказ хранилища, и без этой строки он
|
||||
// приходит отправителю как «вашей записи нет», а владелец сервиса об
|
||||
// аварии не узнаёт ниоткуда. Журнал читает владелец, а не тот, кто
|
||||
// перебирает, поэтому различать их здесь можно.
|
||||
var notFound *contract.JobNotFoundError
|
||||
if !errors.As(err, ¬Found) {
|
||||
h.logger.Error("Failed to read transcribe job", "error", err, "job_id", jobID)
|
||||
}
|
||||
return e.JSON(http.StatusNotFound, map[string]string{"error": "Job not found"})
|
||||
}
|
||||
|
||||
|
||||
@@ -273,10 +273,18 @@ func jobWithFile(t *testing.T, env *testEnv) *entity.TranscribeJob {
|
||||
require.NoError(t, err)
|
||||
defer func() { require.NoError(t, work.Close()) }()
|
||||
|
||||
file, err := repo.CreateLocal("sample.mp3", work)
|
||||
// Владелец — учётная запись проверки: задача, пришедшая из веба, без
|
||||
// владельца больше не заводится, и фикстура без него описывала бы состояние,
|
||||
// которого в проде не бывает.
|
||||
file, err := repo.CreateLocal("sample.mp3", work, env.account.Id)
|
||||
require.NoError(t, err)
|
||||
|
||||
job := &entity.TranscribeJob{State: entity.StateCreated, Source: entity.SourceApi, FileID: &file.Id}
|
||||
job := &entity.TranscribeJob{
|
||||
State: entity.StateCreated,
|
||||
Source: entity.SourceApi,
|
||||
OwnerID: &env.account.Id,
|
||||
FileID: &file.Id,
|
||||
}
|
||||
require.NoError(t, env.handler.jobRepo.Create(job))
|
||||
return job
|
||||
}
|
||||
@@ -325,7 +333,7 @@ func TestCreateTranscribeJob_Success(t *testing.T) {
|
||||
// отправитель получит идентификатор записи, которой не будет никогда.
|
||||
require.Equal(t, 1, countJobs(t, env))
|
||||
|
||||
job, err := env.handler.jobRepo.GetByID(response.JobID)
|
||||
job, err := env.handler.jobRepo.GetByID(response.JobID, env.account.Id)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, entity.StateCreated, job.State)
|
||||
require.NotNil(t, job.FileID)
|
||||
@@ -595,7 +603,7 @@ func TestCreateTranscribeJob_JournalTracesRecord(t *testing.T) {
|
||||
var response CreateTranscribeJobResponse
|
||||
require.NoError(t, json.Unmarshal(w.Body.Bytes(), &response))
|
||||
|
||||
job, err := env.handler.jobRepo.GetByID(response.JobID)
|
||||
job, err := env.handler.jobRepo.GetByID(response.JobID, env.account.Id)
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, job.FileID)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user