у записи появился владелец: чужую больше не отдают
- колонка `owner` связью с `users` в обеих коллекциях новым шагом схемы `202608140001`; чтение задачи сужено владельцем, и чужая, ничья и несуществующая дают один ответ; правило просмотра файлов сужено им же - приём по HTTP берёт владельца из сессии, а предъявителя без учётной записи пользователя отвергает до чтения тела: позже пришлось бы убирать уложенный файл, а уборки файлов сервис не умеет. Выборка воркера владельцем не сужается - удаление учётной записи с записями отвергается стражем, и вешает его сама сборка хранилища: сборка, забывшая его позвать, теряла защиту молча
This commit is contained in:
@@ -43,6 +43,13 @@ func New(dataDir string) (*pb.PocketBase, error) {
|
||||
return nil, fmt.Errorf("failed to apply storage schema: %w", err)
|
||||
}
|
||||
|
||||
// Страж владельца вешается здесь, а не вызывающим: он защищает архив от
|
||||
// удаления учётной записи, и сборка, забывшая его позвать, теряет защиту
|
||||
// молча. Так это уже и было — окружение проверок его не ставило, и всё
|
||||
// разграничение проверялось на приложении, где архив сносится одним
|
||||
// запросом.
|
||||
GuardOwnerDeletion(app)
|
||||
|
||||
return app, nil
|
||||
}
|
||||
|
||||
|
||||
@@ -116,7 +116,7 @@ func (repo *FileRepository) Localize(fileID string) (contract.WorkFile, error) {
|
||||
// библиотеки строит его из имени, данного отправителем, а имя отправителя в
|
||||
// хранилище не попадает — путь к файлу читается в журнале, и инвариант
|
||||
// приватности этого не допускает. Свой суффикс хранилище допишет само.
|
||||
func (repo *FileRepository) CreateLocal(name string, work contract.WorkFile) (*entity.File, error) {
|
||||
func (repo *FileRepository) CreateLocal(name string, work contract.WorkFile, ownerID string) (*entity.File, error) {
|
||||
collection, err := findCollection(repo.app, migrations.FilesCollection)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -132,6 +132,11 @@ func (repo *FileRepository) CreateLocal(name string, work contract.WorkFile) (*e
|
||||
record.Set("file", stored)
|
||||
record.Set("location", entity.LocationLocal)
|
||||
record.Set("size", stored.Size)
|
||||
// Владелец файла — владелец записи, которой файл принадлежит. Пустой значит
|
||||
// «файл без владельца»: таков всякий файл записи, принятой ботом. Правило
|
||||
// просмотра коллекции сужено этой колонкой, и без неё чужое аудио осталось
|
||||
// бы доступным всякому вошедшему.
|
||||
record.Set("owner", ownerID)
|
||||
|
||||
if err := repo.app.Save(record); err != nil {
|
||||
// Отказ укладки называет имя файла — то самое, из которого строится
|
||||
@@ -143,7 +148,7 @@ func (repo *FileRepository) CreateLocal(name string, work contract.WorkFile) (*e
|
||||
return recordToFile(record), nil
|
||||
}
|
||||
|
||||
func (repo *FileRepository) CreateRemote(objectKey string, size int64) (*entity.File, error) {
|
||||
func (repo *FileRepository) CreateRemote(objectKey string, size int64, ownerID string) (*entity.File, error) {
|
||||
collection, err := findCollection(repo.app, migrations.FilesCollection)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -153,6 +158,7 @@ func (repo *FileRepository) CreateRemote(objectKey string, size int64) (*entity.
|
||||
record.Set("location", entity.LocationS3)
|
||||
record.Set("object_key", objectKey)
|
||||
record.Set("size", size)
|
||||
record.Set("owner", ownerID)
|
||||
|
||||
if err := repo.app.Save(record); err != nil {
|
||||
return nil, fmt.Errorf("failed to store remote file record: %w", err)
|
||||
|
||||
@@ -31,7 +31,7 @@ func TestCreateLocal_AcceptsRecordLargerThanLibraryDefault(t *testing.T) {
|
||||
require.NoError(t, err)
|
||||
require.Greater(t, size, int64(libraryDefault), "запись заведомо больше умолчания библиотеки")
|
||||
|
||||
file, err := repo.CreateLocal("big.mp3", work)
|
||||
file, err := repo.CreateLocal("big.mp3", work, "")
|
||||
require.NoError(t, err, "запись длиннее умолчания библиотеки ложится в хранилище")
|
||||
assert.Equal(t, size, file.Size)
|
||||
assert.Greater(t, entity.MaxRecordSize, size, "объявленный потолок выше проверяемого размера")
|
||||
|
||||
@@ -39,6 +39,10 @@ func applyOwnedByPipeline(record *core.Record, job *entity.TranscribeJob) {
|
||||
// поля здесь не с кем.
|
||||
func applyToRecord(record *core.Record, job *entity.TranscribeJob) {
|
||||
applyOwnedByPipeline(record, job)
|
||||
// Владелец кладётся только здесь, при заведении. В applyOwnedByPipeline его
|
||||
// нет намеренно: конвейер владельца не назначает и не меняет, а снимок шага,
|
||||
// записанный поверх, стёр бы его молча.
|
||||
record.Set("owner", derefString(job.OwnerID))
|
||||
record.Set("source", job.Source)
|
||||
record.Set("tg_chat_id", derefInt64(job.TgChatId))
|
||||
record.Set("tg_reply_message_id", derefInt(job.TgReplyMessageId))
|
||||
@@ -48,6 +52,7 @@ func recordToJob(record *core.Record) *entity.TranscribeJob {
|
||||
return &entity.TranscribeJob{
|
||||
Id: record.Id,
|
||||
State: record.GetString("state"),
|
||||
OwnerID: nilIfEmpty(record.GetString("owner")),
|
||||
Source: record.GetString("source"),
|
||||
FileID: nilIfEmpty(record.GetString("file")),
|
||||
ErrorText: nilIfEmpty(record.GetString("error_text")),
|
||||
@@ -69,8 +74,13 @@ func recordToJob(record *core.Record) *entity.TranscribeJob {
|
||||
// перечнем держит константа acquireColumns и тест захвата, читающий задачу
|
||||
// целиком.
|
||||
type acquiredRow struct {
|
||||
Id string `db:"id"`
|
||||
State string `db:"state"`
|
||||
Id string `db:"id"`
|
||||
State string `db:"state"`
|
||||
// Владелец конвейеру не нужен для выборки — она им не сужается, — но
|
||||
// читается: снимок задачи, в котором владелец всегда пуст, был бы ловушкой
|
||||
// для первого же шага, начавшего сохранять задачу целиком. Сегодня это ещё
|
||||
// и несущее чтение: шаг конвейера кладёт владельца на файл, который заводит.
|
||||
OwnerID sql.NullString `db:"owner"`
|
||||
Source string `db:"source"`
|
||||
FileID sql.NullString `db:"file"`
|
||||
ErrorText sql.NullString `db:"error_text"`
|
||||
@@ -90,6 +100,7 @@ func (r *acquiredRow) toJob() *entity.TranscribeJob {
|
||||
job := &entity.TranscribeJob{
|
||||
Id: r.Id,
|
||||
State: r.State,
|
||||
OwnerID: nullToPtr(r.OwnerID),
|
||||
Source: r.Source,
|
||||
FileID: nullToPtr(r.FileID),
|
||||
ErrorText: nullToPtr(r.ErrorText),
|
||||
|
||||
@@ -0,0 +1,106 @@
|
||||
package migrations
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
|
||||
"github.com/pocketbase/pocketbase/core"
|
||||
)
|
||||
|
||||
// up202608140001 заводит владельца записи.
|
||||
//
|
||||
// Колонка — связь с коллекцией пользователей: хранилище само следит, чтобы
|
||||
// владельцем стояла существующая учётная запись, а не строка, похожая на её
|
||||
// идентификатор.
|
||||
//
|
||||
// Пустое значение допустимо, и это решение с названной ценой. Записи, принятые
|
||||
// ботом, владельца не имеют вовсе: связи чата Telegram с учётной записью сервис
|
||||
// не ведёт, её заводит отдельная задача. Обязательность для приёма по HTTP
|
||||
// держит поэтому сам приём, а не схема.
|
||||
//
|
||||
// Каскадное удаление выключено, но одного этого мало: при выключенном каскаде
|
||||
// хранилище **вынимает** идентификатор из поля связи и сохраняет запись без
|
||||
// проверок, то есть архив удалённого пользователя стал бы ничьим и не достался
|
||||
// бы никому. Поэтому удаление учётной записи, у которой остались задачи,
|
||||
// отвергается слоем приложения — `GuardOwnerDeletion`.
|
||||
func up202608140001(app core.App) error {
|
||||
users, err := app.FindCollectionByNameOrId(UsersCollection)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to find users collection: %w", err)
|
||||
}
|
||||
|
||||
jobs, err := app.FindCollectionByNameOrId(JobsCollection)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to find jobs collection: %w", err)
|
||||
}
|
||||
|
||||
jobs.Fields.Add(ownerField(users.Id))
|
||||
|
||||
if err := app.Save(jobs); err != nil {
|
||||
return fmt.Errorf("failed to add owner to jobs: %w", err)
|
||||
}
|
||||
|
||||
files, err := app.FindCollectionByNameOrId(FilesCollection)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to find files collection: %w", err)
|
||||
}
|
||||
|
||||
files.Fields.Add(ownerField(users.Id))
|
||||
|
||||
// Правило просмотра сужается владельцем. Прежнее пускало всякого узнанного:
|
||||
// владельца у записи тогда не было, и сужать выборку было нечем. Без этой
|
||||
// строки разграничение закрыло бы метаданные задачи и оставило открытым
|
||||
// содержимое — то самое, что оно и заведено прятать: знание идентификатора
|
||||
// файловой записи равнялось бы праву скачать чужое аудио.
|
||||
files.ViewRule = ptr(`@request.auth.id != "" && owner = @request.auth.id`)
|
||||
|
||||
if err := app.Save(files); err != nil {
|
||||
return fmt.Errorf("failed to narrow files by owner: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// ownerField собирает описание колонки владельца. Обе коллекции получают
|
||||
// одинаковую: разойдясь, они дали бы разное поведение у задачи и у её файла.
|
||||
func ownerField(usersCollectionID string) *core.RelationField {
|
||||
return &core.RelationField{
|
||||
Name: "owner",
|
||||
CollectionId: usersCollectionID,
|
||||
MaxSelect: 1,
|
||||
// Пустое значение допустимо — см. шапку шага. Умолчания у колонки нет:
|
||||
// связь его не имеет по устройству, и запись не достаётся никому по
|
||||
// недосмотру схемы.
|
||||
Required: false,
|
||||
// Удаление учётной записи не уносит её записи следом: сервис объявлен
|
||||
// архивом. Что происходит вместо этого, держит `GuardOwnerDeletion`.
|
||||
CascadeDelete: false,
|
||||
}
|
||||
}
|
||||
|
||||
// down202608140001 снимает колонку с обеих коллекций и возвращает правило
|
||||
// просмотра файлов к тому, что стояло до шага, — «всякий узнанный».
|
||||
func down202608140001(app core.App) error {
|
||||
for _, name := range []string{JobsCollection, FilesCollection} {
|
||||
collection, err := app.FindCollectionByNameOrId(name)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to find collection %s: %w", name, err)
|
||||
}
|
||||
|
||||
field := collection.Fields.GetByName("owner")
|
||||
if field == nil {
|
||||
return errors.New("collection " + name + " has no owner field")
|
||||
}
|
||||
collection.Fields.RemoveById(field.GetId())
|
||||
|
||||
if name == FilesCollection {
|
||||
collection.ViewRule = ptr(`@request.auth.id != ""`)
|
||||
}
|
||||
|
||||
if err := app.Save(collection); err != nil {
|
||||
return fmt.Errorf("failed to drop owner from %s: %w", name, err)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
@@ -24,6 +24,10 @@ import (
|
||||
const (
|
||||
FilesCollection = "files"
|
||||
JobsCollection = "transcribe_jobs"
|
||||
// UsersCollection заводит не наш шаг, а системный шаг библиотеки. Имя стоит
|
||||
// здесь потому, что на него ссылаются и шаги схемы, и проверка предъявителя
|
||||
// на приёме: строковый литерал в двух местах разошёлся бы молча.
|
||||
UsersCollection = "users"
|
||||
)
|
||||
|
||||
// Шаг регистрируется в списке приложения при загрузке пакета, а накатывает его
|
||||
@@ -31,6 +35,7 @@ const (
|
||||
func init() {
|
||||
pbmigrations.Register(up202608110001, down202608110001, "202608110001_init.go")
|
||||
pbmigrations.Register(up202608120001, down202608120001, "202608120001_oidc_login.go")
|
||||
pbmigrations.Register(up202608140001, down202608140001, "202608140001_record_owner.go")
|
||||
}
|
||||
|
||||
func ptr[T any](v T) *T { return &v }
|
||||
|
||||
@@ -0,0 +1,80 @@
|
||||
package pocketbase
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"github.com/pocketbase/dbx"
|
||||
"github.com/pocketbase/pocketbase/core"
|
||||
"github.com/pocketbase/pocketbase/tools/router"
|
||||
|
||||
"git.vakhrushev.me/av/transcriber/internal/adapter/repo/pocketbase/migrations"
|
||||
)
|
||||
|
||||
// GuardOwnerDeletion отвергает удаление учётной записи, у которой остались
|
||||
// задачи расшифровки.
|
||||
//
|
||||
// Колонка владельца — связь с выключенным каскадным удалением, и одного этого
|
||||
// мало: при выключенном каскаде хранилище не удаляет ссылающуюся запись, а
|
||||
// **вынимает** идентификатор из поля связи и сохраняет её без проверок. Задачи
|
||||
// остались бы на месте, но стали бы ничьими, а ничья задача не достаётся по API
|
||||
// никому — архив человека исчез бы молча и восстановлению не подлежал:
|
||||
// прежнего владельца не остаётся нигде.
|
||||
//
|
||||
// Цена запрета названа прямо: владелец панели упирается в отказ, а способа
|
||||
// удалить записи в сервисе пока нет вовсе — его приносит отдельная задача. До
|
||||
// неё удаление учётной записи с записями невозможно, и это осознанный тупик.
|
||||
//
|
||||
// Слой стоит на удалении записи, а не на запросе к панели: панель ходит правами
|
||||
// суперпользователя, и правило коллекции её не судит. Удаление при этом не
|
||||
// только панельное — умолчание библиотеки разрешает вошедшему удалить свою
|
||||
// учётную запись запросом, так что страж закрывает и публичную поверхность.
|
||||
//
|
||||
// Считаются **обе** коллекции с владельцем. Файл переживает свою задачу: шаг
|
||||
// конвейера заводит его до сохранения задачи, и потерянный захват оставляет файл
|
||||
// с владельцем и без ссылки. Учётная запись, у которой остались одни такие
|
||||
// файлы, без этого счёта удалялась бы штатно, а аудио становилось бы ничьим.
|
||||
func GuardOwnerDeletion(app core.App) {
|
||||
app.OnRecordDelete(migrations.UsersCollection).BindFunc(func(e *core.RecordEvent) error {
|
||||
count, err := countOwned(e.App, e.Record.Id)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if count > 0 {
|
||||
// Отказ отдаётся ошибкой роутера, а не обычной: библиотека пропускает
|
||||
// наружу только `*router.ApiError`, а всякую другую подменяет своим
|
||||
// сообщением — «убедитесь, что запись не участвует в обязательной
|
||||
// связи». Подсказка эта не просто бесполезная, а **ведущая**:
|
||||
// единственная обязательная связь у задачи — файл, и владелец панели,
|
||||
// поверив ей, пойдёт удалять задачи и файлы руками. То есть сделает
|
||||
// ровно то необратимое, ради предотвращения чего страж и заведён.
|
||||
//
|
||||
// Число в отказе — не содержимое записей, а их счёт: он говорит
|
||||
// владельцу панели, почему удаление не прошло, и не выносит наружу
|
||||
// ничего о самих записях.
|
||||
return router.NewBadRequestError(fmt.Sprintf(
|
||||
"у учётной записи остались записи (%d): сервис — архив, и удаление сделало бы их ничьими",
|
||||
count,
|
||||
), nil)
|
||||
}
|
||||
|
||||
return e.Next()
|
||||
})
|
||||
}
|
||||
|
||||
// countOwned считает всё, что принадлежит учётной записи, — по обеим коллекциям
|
||||
// с колонкой владельца. Перечень живёт здесь одним списком: разойдясь с шагом
|
||||
// схемы, он оставил бы половину архива без защиты молча.
|
||||
func countOwned(app core.App, ownerID string) (int64, error) {
|
||||
var total int64
|
||||
|
||||
for _, collection := range []string{migrations.JobsCollection, migrations.FilesCollection} {
|
||||
count, err := app.CountRecords(collection, dbx.HashExp{"owner": ownerID})
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("failed to count owned records in %s: %w", collection, err)
|
||||
}
|
||||
total += count
|
||||
}
|
||||
|
||||
return total, nil
|
||||
}
|
||||
@@ -0,0 +1,182 @@
|
||||
package pocketbase
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/pocketbase/pocketbase/core"
|
||||
"github.com/pocketbase/pocketbase/tools/router"
|
||||
"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/contract"
|
||||
"git.vakhrushev.me/av/transcriber/internal/entity"
|
||||
)
|
||||
|
||||
// newAccount заводит учётную запись и отдаёт её идентификатор.
|
||||
func newAccount(t *testing.T, app core.App, email string) string {
|
||||
t.Helper()
|
||||
|
||||
users, err := app.FindCollectionByNameOrId(migrations.UsersCollection)
|
||||
require.NoError(t, err)
|
||||
|
||||
record := core.NewRecord(users)
|
||||
record.Set("email", email)
|
||||
record.Set("verified", true)
|
||||
record.SetRandomPassword()
|
||||
require.NoError(t, app.Save(record))
|
||||
|
||||
return record.Id
|
||||
}
|
||||
|
||||
// Колонка владельца заводится шагом схемы на чистой базе, и умолчания у неё нет.
|
||||
func TestOwnerColumnHasNoDefault(t *testing.T) {
|
||||
app := newTestApp(t)
|
||||
|
||||
for _, name := range []string{migrations.JobsCollection, migrations.FilesCollection} {
|
||||
collection, err := app.FindCollectionByNameOrId(name)
|
||||
require.NoError(t, err)
|
||||
|
||||
field := collection.Fields.GetByName("owner")
|
||||
require.NotNil(t, field, "колонка владельца заведена в %s", name)
|
||||
|
||||
relation, ok := field.(*core.RelationField)
|
||||
require.True(t, ok, "владелец — связь с учётной записью, а не строка")
|
||||
assert.False(t, relation.Required, "пустое значение допустимо ради записей бота")
|
||||
assert.False(t, relation.CascadeDelete, "удаление учётной записи не уносит записи")
|
||||
|
||||
// Умолчания у связи нет по устройству: запись, чей владелец не назван,
|
||||
// не достаётся никому по недосмотру схемы.
|
||||
record := core.NewRecord(collection)
|
||||
assert.Empty(t, record.GetString("owner"), "новая запись приходит без владельца")
|
||||
}
|
||||
}
|
||||
|
||||
// Чужая задача, ничья и несуществующая дают одну и ту же ошибку.
|
||||
func TestGetByID_NarrowedByOwner(t *testing.T) {
|
||||
app := newTestApp(t)
|
||||
repo := NewTranscriptJobRepository(app)
|
||||
|
||||
mine := newAccount(t, app, "mine@example.com")
|
||||
stranger := newAccount(t, app, "stranger@example.com")
|
||||
|
||||
owned := newJob(t, repo, entity.StateCreated)
|
||||
owned.OwnerID = &mine
|
||||
require.NoError(t, repo.Save(owned, ""))
|
||||
// Владельца кладёт заведение, а не сохранение конвейера, — ставим его прямо.
|
||||
record, err := app.FindRecordById(migrations.JobsCollection, owned.Id)
|
||||
require.NoError(t, err)
|
||||
record.Set("owner", mine)
|
||||
require.NoError(t, app.Save(record))
|
||||
|
||||
ownerless := newJob(t, repo, entity.StateCreated)
|
||||
|
||||
got, err := repo.GetByID(owned.Id, mine)
|
||||
require.NoError(t, err, "своя задача отдаётся")
|
||||
assert.Equal(t, owned.Id, got.Id)
|
||||
|
||||
_, foreignErr := repo.GetByID(owned.Id, stranger)
|
||||
_, ownerlessErr := repo.GetByID(ownerless.Id, mine)
|
||||
_, missingErr := repo.GetByID("nonexistent0000", mine)
|
||||
|
||||
var notFound *contract.JobNotFoundError
|
||||
require.ErrorAs(t, foreignErr, ¬Found, "чужая задача не отдаётся")
|
||||
require.ErrorAs(t, ownerlessErr, ¬Found, "ничья задача не отдаётся")
|
||||
require.ErrorAs(t, missingErr, ¬Found, "несуществующая тоже")
|
||||
}
|
||||
|
||||
// Пустой владелец не совпадает ни с чем: ни со своей задачей, ни с чужой, ни с
|
||||
// ничьей. Правило записано со стороны спрашивающего — обязательность, которую
|
||||
// держит одна лишь подпись метода, пустую строку пропускает.
|
||||
func TestGetByID_EmptyOwnerMatchesNothing(t *testing.T) {
|
||||
app := newTestApp(t)
|
||||
repo := NewTranscriptJobRepository(app)
|
||||
|
||||
owner := newAccount(t, app, "mine@example.com")
|
||||
|
||||
owned := newJob(t, repo, entity.StateCreated)
|
||||
record, err := app.FindRecordById(migrations.JobsCollection, owned.Id)
|
||||
require.NoError(t, err)
|
||||
record.Set("owner", owner)
|
||||
require.NoError(t, app.Save(record))
|
||||
|
||||
ownerless := newJob(t, repo, entity.StateCreated)
|
||||
|
||||
var notFound *contract.JobNotFoundError
|
||||
for _, id := range []string{owned.Id, ownerless.Id, "nonexistent0000"} {
|
||||
_, err := repo.GetByID(id, "")
|
||||
require.ErrorAs(t, err, ¬Found, "пустой владелец не открывает %s", id)
|
||||
}
|
||||
}
|
||||
|
||||
// Удаление учётной записи с задачами отвергается: связь с выключенным каскадом
|
||||
// иначе снимает ссылку, и архив человека становится ничьим и недостижимым.
|
||||
func TestGuardOwnerDeletion(t *testing.T) {
|
||||
// Страж вешает сама сборка хранилища — звать его отдельно не нужно и нельзя:
|
||||
// второй вызов повесил бы второй слой.
|
||||
app := newTestApp(t)
|
||||
repo := NewTranscriptJobRepository(app)
|
||||
|
||||
withJobs := newAccount(t, app, "keeper@example.com")
|
||||
empty := newAccount(t, app, "empty@example.com")
|
||||
|
||||
job := newJob(t, repo, entity.StateCreated)
|
||||
record, err := app.FindRecordById(migrations.JobsCollection, job.Id)
|
||||
require.NoError(t, err)
|
||||
record.Set("owner", withJobs)
|
||||
require.NoError(t, app.Save(record))
|
||||
|
||||
keeper, err := app.FindRecordById(migrations.UsersCollection, withJobs)
|
||||
require.NoError(t, err)
|
||||
|
||||
err = app.Delete(keeper)
|
||||
require.Error(t, err, "учётная запись с задачами не удаляется")
|
||||
|
||||
// Отказ обязан быть ошибкой роутера, а не обычной: наружу библиотека
|
||||
// пропускает только её, а всякую другую подменяет своим сообщением про
|
||||
// обязательную связь — подсказкой, по которой владелец панели пойдёт удалять
|
||||
// задачи и файлы руками. Проверяется поэтому тип и текст, а не сам факт
|
||||
// отказа: на потерянном сообщении факт остаётся прежним.
|
||||
var apiErr *router.ApiError
|
||||
require.ErrorAs(t, err, &apiErr, "отказ доезжает до владельца панели")
|
||||
assert.Contains(t, apiErr.Message, "остались записи",
|
||||
"причина названа, а не подменена библиотечной")
|
||||
|
||||
// Задача и её владелец остались прежними.
|
||||
after, err := app.FindRecordById(migrations.JobsCollection, job.Id)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, withJobs, after.GetString("owner"), "владелец не снят")
|
||||
|
||||
free, err := app.FindRecordById(migrations.UsersCollection, empty)
|
||||
require.NoError(t, err)
|
||||
assert.NoError(t, app.Delete(free), "учётная запись без записей удаляется")
|
||||
}
|
||||
|
||||
// Файл переживает свою задачу: шаг конвейера заводит его до сохранения задачи, и
|
||||
// потерянный захват оставляет файл с владельцем и без ссылки. Считать одни
|
||||
// задачи значило бы отдать такое аудио на молчаливое обезличивание.
|
||||
func TestGuardOwnerDeletion_CountsFilesToo(t *testing.T) {
|
||||
app := newTestApp(t)
|
||||
repo := NewFileRepository(app)
|
||||
|
||||
owner := newAccount(t, app, "files@example.com")
|
||||
|
||||
work, err := repo.Stage(".ogg", strings.NewReader("запись"))
|
||||
require.NoError(t, err)
|
||||
defer func() { require.NoError(t, work.Close()) }()
|
||||
|
||||
_, err = repo.CreateLocal("sample.ogg", work, owner)
|
||||
require.NoError(t, err)
|
||||
|
||||
record, err := app.FindRecordById(migrations.UsersCollection, owner)
|
||||
require.NoError(t, err)
|
||||
|
||||
err = app.Delete(record)
|
||||
require.Error(t, err, "учётная запись с одними файлами тоже не удаляется")
|
||||
|
||||
after, err := app.FindAllRecords(migrations.FilesCollection)
|
||||
require.NoError(t, err)
|
||||
require.Len(t, after, 1)
|
||||
assert.Equal(t, owner, after[0].GetString("owner"), "владелец файла не снят")
|
||||
}
|
||||
@@ -80,17 +80,49 @@ func (repo *TranscriptJobRepository) Save(job *entity.TranscribeJob, holder stri
|
||||
return nil
|
||||
}
|
||||
|
||||
func (repo *TranscriptJobRepository) GetByID(id string) (*entity.TranscribeJob, error) {
|
||||
// GetByID отдаёт задачу, только если её владелец — ownerID.
|
||||
//
|
||||
// Чужая задача, задача без владельца и несуществующая дают одну и ту же ошибку:
|
||||
// по разнице ответов иначе перебирается список заведённых задач, а
|
||||
// идентификатор задачи и есть то, что разграничение прячет.
|
||||
//
|
||||
// Пустой ownerID отсекается **до** чтения и не совпадает ни с чем: иначе
|
||||
// вызывающий без учётной записи получил бы ровно множество задач без владельца,
|
||||
// то есть все записи бота.
|
||||
//
|
||||
// Владелец сверяется тем же чтением, каким берётся состояние, а не отдельным
|
||||
// запросом: между двумя чтениями задача успевает измениться, и ответ перестаёт
|
||||
// быть функцией от того, что с ней произошло.
|
||||
func (repo *TranscriptJobRepository) GetByID(id, ownerID string) (*entity.TranscribeJob, error) {
|
||||
if ownerID == "" {
|
||||
return nil, &contract.JobNotFoundError{Message: "job not found"}
|
||||
}
|
||||
|
||||
record, err := repo.app.FindRecordById(migrations.JobsCollection, id)
|
||||
if err != nil {
|
||||
// «Такой записи нет» переводится в доменную ошибку **здесь**, у
|
||||
// источника, как велит конвенция об ошибках. Иначе три исхода, которые
|
||||
// разграничение обязано сделать неразличимыми, разъезжаются: чужая и
|
||||
// ничья задачи дают доменную ошибку, а несуществующая — отказ базы,
|
||||
// неотличимый от настоящей аварии хранилища. Держалась бы эта
|
||||
// неразличимость только тем, что транспорт кладёт в `404` любую ошибку,
|
||||
// — то есть ровно тем расхождением, которое конвенция велит закрыть.
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return nil, &contract.JobNotFoundError{Message: "job not found"}
|
||||
}
|
||||
return nil, fmt.Errorf("failed to get transcribe job: %w", err)
|
||||
}
|
||||
|
||||
if record.GetString("owner") != ownerID {
|
||||
return nil, &contract.JobNotFoundError{Message: "job not found"}
|
||||
}
|
||||
|
||||
return recordToJob(record), nil
|
||||
}
|
||||
|
||||
// Колонки, которые читает захват. Список нужен запросу дословно: `RETURNING *`
|
||||
// отдал бы и порядок, зависящий от схемы.
|
||||
const acquireColumns = `id, state, source, file, error_text, acquisition_id, ` +
|
||||
const acquireColumns = `id, state, owner, source, file, error_text, acquisition_id, ` +
|
||||
`acquire_time, delay_time, attempts, recognition_op_id, transcription_text, ` +
|
||||
`tg_chat_id, tg_reply_message_id, created, updated`
|
||||
|
||||
|
||||
@@ -45,7 +45,7 @@ func newFile(t *testing.T, app core.App) *entity.File {
|
||||
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, "")
|
||||
require.NoError(t, err)
|
||||
return file
|
||||
}
|
||||
@@ -246,7 +246,7 @@ func TestSave_RefusesWriteFromLostAcquisition(t *testing.T) {
|
||||
require.ErrorAs(t, err, &lost)
|
||||
|
||||
// И состояние не поехало.
|
||||
after, err := repo.GetByID(mine.Id)
|
||||
after, err := readJobByID(t, app, mine.Id)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, entity.StateCreated, after.State)
|
||||
}
|
||||
@@ -261,7 +261,7 @@ func TestSave_WithoutHolderWritesAnyway(t *testing.T) {
|
||||
|
||||
require.NoError(t, repo.Save(job, ""))
|
||||
|
||||
after, err := repo.GetByID(job.Id)
|
||||
after, err := readJobByID(t, app, job.Id)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, entity.StateConverted, after.State)
|
||||
}
|
||||
@@ -291,7 +291,7 @@ func TestPanelRules_StateChangeByRequestClearsAcquisition(t *testing.T) {
|
||||
// запросом к записи, а не сохранением из кода.
|
||||
patchRecord(t, app, job.Id, `{"state":"`+entity.StateCreated+`"}`)
|
||||
|
||||
after, err := repo.GetByID(job.Id)
|
||||
after, err := readJobByID(t, app, job.Id)
|
||||
require.NoError(t, err)
|
||||
assert.Nil(t, after.AcquisitionID, "признак захвата снят")
|
||||
assert.Nil(t, after.AcquireTime, "время захвата снято")
|
||||
@@ -323,7 +323,7 @@ func TestPanelRules_DoNotTouchPipelineWrites(t *testing.T) {
|
||||
acquired.MoveToStateAndDelay(entity.StateTranscribe, &delay)
|
||||
require.NoError(t, repo.Save(acquired, "holder"))
|
||||
|
||||
after, err := repo.GetByID(job.Id)
|
||||
after, err := readJobByID(t, app, job.Id)
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, after.DelayTime, "задержка, поставленная шагом, пережила сохранение")
|
||||
|
||||
@@ -333,7 +333,7 @@ func TestPanelRules_DoNotTouchPipelineWrites(t *testing.T) {
|
||||
after.Die("attempts exhausted: 6")
|
||||
require.NoError(t, repo.Save(after, ""))
|
||||
|
||||
dead, err := repo.GetByID(job.Id)
|
||||
dead, err := readJobByID(t, app, job.Id)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, entity.StateDead, dead.State)
|
||||
assert.Equal(t, 6, dead.Attempts, "число попыток мёртвой задачи сохранено")
|
||||
@@ -404,9 +404,22 @@ func TestSave_KeepsOwnerEditMadeWhileStepHeldTheJob(t *testing.T) {
|
||||
acquired.MoveToState(entity.StateConverted)
|
||||
require.NoError(t, repo.Save(acquired, "holder"))
|
||||
|
||||
after, err := repo.GetByID(job.Id)
|
||||
after, err := readJobByID(t, app, job.Id)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, entity.StateConverted, after.State, "шаг свой результат записал")
|
||||
require.NotNil(t, after.TgChatId)
|
||||
assert.Equal(t, int64(999999), *after.TgChatId, "правка владельца пережила сохранение шага")
|
||||
}
|
||||
|
||||
// readJobByID читает задачу мимо сужения владельцем: проверки хранилища смотрят
|
||||
// задачи без владельца, а читающий метод их не отдаёт никому. Отображение при
|
||||
// этом то же самое — сверяется именно оно.
|
||||
func readJobByID(t *testing.T, app core.App, id string) (*entity.TranscribeJob, error) {
|
||||
t.Helper()
|
||||
|
||||
record, err := app.FindRecordById(migrations.JobsCollection, id)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return recordToJob(record), nil
|
||||
}
|
||||
|
||||
@@ -14,6 +14,11 @@ import (
|
||||
// и запись о недоставке делает шаг, у которого задача под рукой.
|
||||
var ErrDeliveryChannelDown = errors.New("delivery channel is down")
|
||||
|
||||
// ErrOwnerRequired — приём по HTTP дошёл до заведения задачи, а владельца ему не
|
||||
// назвали. Значение сентинельное: нести отказу нечего, а имя учётной записи в
|
||||
// него не кладётся никогда.
|
||||
var ErrOwnerRequired = errors.New("owner is required to accept a record")
|
||||
|
||||
type JobNotFoundError struct {
|
||||
State string
|
||||
Message string
|
||||
|
||||
@@ -36,9 +36,15 @@ type FileRepository interface {
|
||||
// CreateLocal кладёт рабочую копию в хранилище под именем name и заводит
|
||||
// запись о файле. Имя задаёт сервис: умолчание хранилища, строящее его из
|
||||
// имени отправителя, не применяется.
|
||||
CreateLocal(name string, work WorkFile) (*entity.File, error)
|
||||
//
|
||||
// ownerID — владелец записи, которой файл принадлежит; пустой значит «файл
|
||||
// без владельца», и таков всякий файл записи, принятой ботом. Владелец
|
||||
// лежит своей колонкой, а не выводится через задачу: ссылку на файл в
|
||||
// задаче переставляет каждый шаг конвейера, и исходная копия после
|
||||
// конвертации не связана с задачей ничем.
|
||||
CreateLocal(name string, work WorkFile, ownerID string) (*entity.File, error)
|
||||
// CreateRemote заводит запись о копии, лежащей во внешнем хранилище.
|
||||
CreateRemote(objectKey string, size int64) (*entity.File, error)
|
||||
CreateRemote(objectKey string, size int64, ownerID string) (*entity.File, error)
|
||||
GetByID(id string) (*entity.File, error)
|
||||
// Open отдаёт содержимое хранимого файла потоком.
|
||||
Open(fileID string) (io.ReadCloser, error)
|
||||
@@ -51,7 +57,19 @@ type TranscriptJobRepository interface {
|
||||
// Пустой holder снимает эту условность и в конвейере не употребляется: все
|
||||
// его шаги получают признак захвата от FindAndAcquire.
|
||||
Save(job *entity.TranscribeJob, holder string) error
|
||||
GetByID(id string) (*entity.TranscribeJob, error)
|
||||
// GetByID отдаёт задачу, только если её владелец — ownerID. Чужая задача,
|
||||
// ничья задача и несуществующая дают одну и ту же ошибку: по разнице
|
||||
// ответов иначе перебирается список заведённых задач.
|
||||
//
|
||||
// Владелец здесь обязателен, и пустой ownerID не совпадает ни с чем —
|
||||
// включая задачи без владельца. Правило записано со стороны спрашивающего:
|
||||
// обязательность, которую держит одна лишь подпись метода, пустую строку
|
||||
// пропускает, и вызывающий без учётной записи получил бы ровно множество
|
||||
// записей бота.
|
||||
//
|
||||
// Второго читающего метода нет намеренно: он выбирался бы по
|
||||
// внимательности вызывающего.
|
||||
GetByID(id, ownerID string) (*entity.TranscribeJob, error)
|
||||
// FindAndAcquire забирает задачу одним неделимым шагом и увеличивает число
|
||||
// её попыток. Работы в состоянии нет — JobNotFoundError.
|
||||
FindAndAcquire(state, acquisitionId string, rottingTime time.Time) (*entity.TranscribeJob, error)
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -7,8 +7,12 @@ import (
|
||||
)
|
||||
|
||||
type TranscribeJob struct {
|
||||
Id string
|
||||
State string
|
||||
Id string
|
||||
State string
|
||||
// OwnerID — учётная запись, от имени которой запись принята. Пуст у записей
|
||||
// из Telegram: связи чата с учётной записью сервис не ведёт. Назначается
|
||||
// один раз, при приёме, и у записи, где он есть, больше не меняется.
|
||||
OwnerID *string
|
||||
Source string
|
||||
FileID *string
|
||||
ErrorText *string
|
||||
|
||||
@@ -26,7 +26,7 @@ type stubJobRepo struct {
|
||||
func (r *stubJobRepo) Create(*entity.TranscribeJob) error { return nil }
|
||||
func (r *stubJobRepo) Save(*entity.TranscribeJob, string) error { return nil }
|
||||
|
||||
func (r *stubJobRepo) GetByID(string) (*entity.TranscribeJob, error) {
|
||||
func (r *stubJobRepo) GetByID(string, string) (*entity.TranscribeJob, error) {
|
||||
return nil, errors.New("не зовётся этими проверками")
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,105 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"git.vakhrushev.me/av/transcriber/internal/contract"
|
||||
"git.vakhrushev.me/av/transcriber/internal/entity"
|
||||
)
|
||||
|
||||
// Выборка воркера владельцем не сужается: владелец решает, кому запись
|
||||
// показывать, а не кому её считать. Сужение остановило бы расшифровку записей
|
||||
// бота вовсе, а записи остальных поставило бы в зависимость от того, кто первым
|
||||
// завёл учётную запись.
|
||||
func TestWorkerTakesJobsOfEveryOwner(t *testing.T) {
|
||||
env := newPipelineEnv(t, &okMetaViewer{}, &failingConverter{})
|
||||
|
||||
first, err := env.service.CreateJobFromApi(t.Context(),
|
||||
strings.NewReader("первая"), "one.mp3", newOwner(t, env.app))
|
||||
require.NoError(t, err)
|
||||
|
||||
second, err := env.service.CreateJobFromApi(t.Context(),
|
||||
strings.NewReader("вторая"), "two.mp3", newOwner(t, env.app))
|
||||
require.NoError(t, err)
|
||||
|
||||
// Третья пришла ботом, и владельца у неё нет вовсе.
|
||||
third := newTelegramJob(t, env)
|
||||
|
||||
// Срок протухания в прошлом: захваченная задача остаётся за держателем, и
|
||||
// следующий вызов берёт следующую, а не ту же самую.
|
||||
taken := map[string]bool{}
|
||||
for _, holder := range []string{"one", "two", "three"} {
|
||||
job, err := env.jobRepo.FindAndAcquire(entity.StateCreated, holder, time.Now().Add(-time.Hour))
|
||||
require.NoError(t, err, "воркер берёт задачи подряд, владельцем не сужаясь")
|
||||
taken[job.Id] = true
|
||||
}
|
||||
|
||||
assert.True(t, taken[first.Id], "задача первого владельца досталась воркеру")
|
||||
assert.True(t, taken[second.Id], "и второго")
|
||||
assert.True(t, taken[third.Id], "и задача без владельца")
|
||||
|
||||
_, err = env.jobRepo.FindAndAcquire(entity.StateCreated, "next", time.Now().Add(-time.Hour))
|
||||
var missing *contract.JobNotFoundError
|
||||
assert.ErrorAs(t, err, &missing, "больше в этом состоянии никого")
|
||||
}
|
||||
|
||||
// Захват читает владельца: снимок задачи, в котором он всегда пуст, был бы
|
||||
// ловушкой для первого же шага, начавшего сохранять задачу целиком, — и сегодня
|
||||
// уже ломал бы файл, который шаг заводит.
|
||||
func TestAcquireCarriesOwner(t *testing.T) {
|
||||
env := newPipelineEnv(t, &okMetaViewer{}, &failingConverter{})
|
||||
|
||||
owner := newOwner(t, env.app)
|
||||
job, err := env.service.CreateJobFromApi(t.Context(), strings.NewReader("запись"), "one.mp3", owner)
|
||||
require.NoError(t, err)
|
||||
|
||||
acquired, err := env.jobRepo.FindAndAcquire(entity.StateCreated, "holder", time.Now().Add(-time.Hour))
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, job.Id, acquired.Id)
|
||||
|
||||
require.NotNil(t, acquired.OwnerID, "владелец приехал из захвата")
|
||||
assert.Equal(t, owner, *acquired.OwnerID)
|
||||
}
|
||||
|
||||
// Шаг конвейера владельца не затирает: сохранение кладёт только то, чем
|
||||
// распоряжается конвейер, и владельца среди этого нет.
|
||||
func TestPipelineStepKeepsOwner(t *testing.T) {
|
||||
env := newPipelineEnv(t, &okMetaViewer{}, &failingConverter{})
|
||||
|
||||
owner := newOwner(t, env.app)
|
||||
job, err := env.service.CreateJobFromApi(t.Context(), strings.NewReader("запись"), "one.mp3", owner)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Отказ конвертации — приговор записи: шаг переводит задачу в `failed` и
|
||||
// сохраняет её. Это сохранение владельца тронуть не должно.
|
||||
require.NoError(t, env.service.FindAndRunConversionJob(t.Context()))
|
||||
|
||||
after, err := readJob(env.app, job.Id)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, entity.StateFailed, after.State, "шаг записал свой приговор")
|
||||
require.NotNil(t, after.OwnerID, "владелец пережил шаг")
|
||||
assert.Equal(t, owner, *after.OwnerID)
|
||||
}
|
||||
|
||||
// Приём из веба без владельца задачи не заводит. Обязательность держит здесь
|
||||
// код, а не схема: колонка допускает пустое значение ради записей бота.
|
||||
func TestCreateJobFromApiRequiresOwner(t *testing.T) {
|
||||
env := newPipelineEnv(t, &okMetaViewer{}, &failingConverter{})
|
||||
|
||||
_, err := env.service.CreateJobFromApi(t.Context(), strings.NewReader("запись"), "one.mp3", "")
|
||||
|
||||
require.ErrorIs(t, err, contract.ErrOwnerRequired)
|
||||
|
||||
records, err := env.app.FindAllRecords("transcribe_jobs")
|
||||
require.NoError(t, err)
|
||||
assert.Empty(t, records, "задачи не заведено")
|
||||
|
||||
files, err := env.app.FindAllRecords("files")
|
||||
require.NoError(t, err)
|
||||
assert.Empty(t, files, "и файла тоже: отказ наступает раньше укладки")
|
||||
}
|
||||
@@ -11,6 +11,7 @@ import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/pocketbase/pocketbase/core"
|
||||
"github.com/pocketbase/pocketbase/tools/types"
|
||||
"github.com/stretchr/testify/assert"
|
||||
@@ -155,7 +156,7 @@ func TestJobDiesAfterAttemptLimit(t *testing.T) {
|
||||
var noop *contract.NoopJobError
|
||||
require.ErrorAs(t, err, &noop, "мёртвая задача шагу не отдаётся")
|
||||
|
||||
after, err := env.jobRepo.GetByID(job.Id)
|
||||
after, err := readJob(env.app, job.Id)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, entity.StateDead, after.State, "задача видна отбором по состоянию")
|
||||
assert.Greater(t, after.Attempts, maxAttempts, "число попыток сохранено")
|
||||
@@ -201,7 +202,7 @@ func TestFailedStepSchedulesRetryWithGrowingDelay(t *testing.T) {
|
||||
|
||||
// Ссылку переставляем на запись без содержимого: шаг отказывает на получении
|
||||
// рабочей копии — то есть отказом, а не приговором записи.
|
||||
empty, err := env.fileRepo.CreateRemote("object-key", 1)
|
||||
empty, err := env.fileRepo.CreateRemote("object-key", 1, "")
|
||||
require.NoError(t, err)
|
||||
|
||||
record, err := env.app.FindRecordById(migrations.JobsCollection, job.Id)
|
||||
@@ -212,7 +213,7 @@ func TestFailedStepSchedulesRetryWithGrowingDelay(t *testing.T) {
|
||||
// Первый отказ.
|
||||
require.Error(t, env.service.FindAndRunConversionJob(t.Context()))
|
||||
|
||||
after, err := env.jobRepo.GetByID(job.Id)
|
||||
after, err := readJob(env.app, job.Id)
|
||||
require.NoError(t, err)
|
||||
require.Nil(t, after.AcquisitionID, "захват снят: задача пригодна к повтору")
|
||||
require.NotNil(t, after.DelayTime, "пауза поставлена")
|
||||
@@ -223,7 +224,7 @@ func TestFailedStepSchedulesRetryWithGrowingDelay(t *testing.T) {
|
||||
clearDelay(t, env, job.Id)
|
||||
require.Error(t, env.service.FindAndRunConversionJob(t.Context()))
|
||||
|
||||
after, err = env.jobRepo.GetByID(job.Id)
|
||||
after, err = readJob(env.app, job.Id)
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, after.DelayTime)
|
||||
|
||||
@@ -248,7 +249,7 @@ func TestWorkFileRemovedAfterIntakeFailure(t *testing.T) {
|
||||
|
||||
env := newPipelineEnv(t, &failingMetaViewer{}, &failingConverter{})
|
||||
|
||||
_, err := env.service.CreateJobFromApi(t.Context(), strings.NewReader("запись"), "sample.mp3")
|
||||
_, err := env.service.CreateJobFromApi(t.Context(), strings.NewReader("запись"), "sample.mp3", newOwner(t, env.app))
|
||||
require.Error(t, err, "отказ источника метаданных роняет приём")
|
||||
|
||||
leftovers, err := filepath.Glob(filepath.Join(tempDir, "transcriber-*"))
|
||||
@@ -264,7 +265,7 @@ func TestWorkFileRemovedAfterSuccessfulIntake(t *testing.T) {
|
||||
|
||||
env := newPipelineEnv(t, &okMetaViewer{}, &failingConverter{})
|
||||
|
||||
_, err := env.service.CreateJobFromApi(t.Context(), strings.NewReader("запись"), "sample.mp3")
|
||||
_, err := env.service.CreateJobFromApi(t.Context(), strings.NewReader("запись"), "sample.mp3", newOwner(t, env.app))
|
||||
require.NoError(t, err)
|
||||
|
||||
leftovers, err := filepath.Glob(filepath.Join(tempDir, "transcriber-*"))
|
||||
@@ -283,7 +284,7 @@ func TestJobNeverPointsToMissingFile(t *testing.T) {
|
||||
// исходную запись, а не на несозданный результат.
|
||||
require.NoError(t, env.service.FindAndRunConversionJob(t.Context()))
|
||||
|
||||
after, err := env.jobRepo.GetByID(job.Id)
|
||||
after, err := readJob(env.app, job.Id)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, entity.StateFailed, after.State)
|
||||
require.NotNil(t, after.FileID)
|
||||
@@ -299,7 +300,7 @@ func TestStoredContentSurvivesRoundTrip(t *testing.T) {
|
||||
|
||||
content := strings.Repeat("запись ", 1000)
|
||||
|
||||
job, err := env.service.CreateJobFromApi(t.Context(), strings.NewReader(content), "sample.mp3")
|
||||
job, err := env.service.CreateJobFromApi(t.Context(), strings.NewReader(content), "sample.mp3", newOwner(t, env.app))
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, job.FileID)
|
||||
|
||||
@@ -322,7 +323,7 @@ func TestStoredContentSurvivesRoundTrip(t *testing.T) {
|
||||
func TestLocalizeGivesReadableCopy(t *testing.T) {
|
||||
env := newPipelineEnv(t, &okMetaViewer{}, &failingConverter{})
|
||||
|
||||
job, err := env.service.CreateJobFromApi(t.Context(), strings.NewReader("содержимое"), "sample.mp3")
|
||||
job, err := env.service.CreateJobFromApi(t.Context(), strings.NewReader("содержимое"), "sample.mp3", newOwner(t, env.app))
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, job.FileID)
|
||||
|
||||
@@ -362,3 +363,73 @@ func TestWorkFilesRemovedAfterConversionFailure(t *testing.T) {
|
||||
require.NoError(t, err)
|
||||
assert.Empty(t, leftovers, "ни исходной копии, ни копии под результат не осталось")
|
||||
}
|
||||
|
||||
// readJob читает задачу мимо сужения владельцем.
|
||||
//
|
||||
// Читающий метод хранилища отдаёт задачу только её владельцу, а проверки
|
||||
// конвейера смотрят задачи, принятые ботом: владельца у таких нет вовсе, и по
|
||||
// правилу разграничения они не достаются никому. Проверке нужен не доступ, а
|
||||
// состояние записи после шага, поэтому она берёт его прямо из хранилища. Это
|
||||
// не второй способ читать задачу в сервисе — в сервисе способ по-прежнему один.
|
||||
func readJob(app core.App, id string) (*entity.TranscribeJob, error) {
|
||||
record, err := app.FindRecordById(migrations.JobsCollection, id)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
job := &entity.TranscribeJob{
|
||||
Id: record.Id,
|
||||
State: record.GetString("state"),
|
||||
Source: record.GetString("source"),
|
||||
Attempts: record.GetInt("attempts"),
|
||||
CreatedAt: record.GetDateTime("created").Time(),
|
||||
UpdatedAt: record.GetDateTime("updated").Time(),
|
||||
}
|
||||
|
||||
for _, field := range []struct {
|
||||
name string
|
||||
dst **string
|
||||
}{
|
||||
{"owner", &job.OwnerID},
|
||||
{"file", &job.FileID},
|
||||
{"error_text", &job.ErrorText},
|
||||
{"acquisition_id", &job.AcquisitionID},
|
||||
{"recognition_op_id", &job.RecognitionOpID},
|
||||
{"transcription_text", &job.TranscriptionText},
|
||||
} {
|
||||
if value := record.GetString(field.name); value != "" {
|
||||
stored := value
|
||||
*field.dst = &stored
|
||||
}
|
||||
}
|
||||
|
||||
if delay := record.GetDateTime("delay_time"); !delay.IsZero() {
|
||||
moment := delay.Time()
|
||||
job.DelayTime = &moment
|
||||
}
|
||||
if acquired := record.GetDateTime("acquire_time"); !acquired.IsZero() {
|
||||
moment := acquired.Time()
|
||||
job.AcquireTime = &moment
|
||||
}
|
||||
|
||||
return job, nil
|
||||
}
|
||||
|
||||
// newOwner заводит учётную запись и отдаёт её идентификатор.
|
||||
//
|
||||
// Владелец — связь с коллекцией пользователей, и хранилище проверяет, что такая
|
||||
// запись есть: выдуманный идентификатор задачу завести не даст.
|
||||
func newOwner(t *testing.T, app core.App) string {
|
||||
t.Helper()
|
||||
|
||||
users, err := app.FindCollectionByNameOrId(migrations.UsersCollection)
|
||||
require.NoError(t, err)
|
||||
|
||||
record := core.NewRecord(users)
|
||||
record.Set("email", uuid.NewString()+"@example.test")
|
||||
record.Set("verified", true)
|
||||
record.SetPassword(uuid.NewString())
|
||||
require.NoError(t, app.Save(record))
|
||||
|
||||
return record.Id
|
||||
}
|
||||
|
||||
@@ -98,7 +98,7 @@ func TestTranscribeJobHandsRecordOverAndMovesOn(t *testing.T) {
|
||||
assert.Equal(t, 1, rec.recognizeCalls, "содержимое отдано распознавателю")
|
||||
assert.NotEmpty(t, rec.lastObjectKey, "ключ объекта назван")
|
||||
|
||||
after, err := env.jobRepo.GetByID(job.Id)
|
||||
after, err := readJob(env.app, job.Id)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, entity.StateTranscribe, after.State)
|
||||
require.NotNil(t, after.RecognitionOpID)
|
||||
@@ -122,7 +122,7 @@ func TestTranscribeJobKeepsJobRetryableOnRecognizerFailure(t *testing.T) {
|
||||
|
||||
require.Error(t, svc.FindAndRunTranscribeJob(t.Context()))
|
||||
|
||||
after, err := env.jobRepo.GetByID(job.Id)
|
||||
after, err := readJob(env.app, job.Id)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, entity.StateConverted, after.State, "задача осталась на своём шаге")
|
||||
assert.Nil(t, after.AcquisitionID, "захват снят: задача пригодна к повтору")
|
||||
@@ -153,7 +153,7 @@ func TestCheckJobWaitsWithoutSpendingAttempts(t *testing.T) {
|
||||
for i := 0; i < 3; i++ {
|
||||
require.NoError(t, svc.FindAndRunTranscribeCheckJob(t.Context()))
|
||||
|
||||
after, err := env.jobRepo.GetByID(job.Id)
|
||||
after, err := readJob(env.app, job.Id)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, entity.StateTranscribe, after.State)
|
||||
assert.Equal(t, 0, after.Attempts, "ожидание операции попытку не тратит")
|
||||
@@ -177,7 +177,7 @@ func TestCheckJobFailsJobAndTellsSender(t *testing.T) {
|
||||
|
||||
require.NoError(t, svc.FindAndRunTranscribeCheckJob(t.Context()))
|
||||
|
||||
after, err := env.jobRepo.GetByID(job.Id)
|
||||
after, err := readJob(env.app, job.Id)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, entity.StateFailed, after.State)
|
||||
|
||||
@@ -199,7 +199,7 @@ func TestCheckJobCompletesAndAnswersOnce(t *testing.T) {
|
||||
|
||||
require.NoError(t, svc.FindAndRunTranscribeCheckJob(t.Context()))
|
||||
|
||||
after, err := env.jobRepo.GetByID(job.Id)
|
||||
after, err := readJob(env.app, job.Id)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, entity.StateDone, after.State)
|
||||
require.NotNil(t, after.TranscriptionText)
|
||||
@@ -227,7 +227,7 @@ func TestCheckJobCompletesEmptyTextWithExplanation(t *testing.T) {
|
||||
|
||||
require.NoError(t, svc.FindAndRunTranscribeCheckJob(t.Context()))
|
||||
|
||||
after, err := env.jobRepo.GetByID(job.Id)
|
||||
after, err := readJob(env.app, job.Id)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, entity.StateDone, after.State)
|
||||
|
||||
@@ -261,7 +261,7 @@ func TestCheckJobWritesNothingWhenAcquisitionLost(t *testing.T) {
|
||||
var lost *contract.LostAcquisitionError
|
||||
require.ErrorAs(t, err, &lost)
|
||||
|
||||
after, err := env.jobRepo.GetByID(job.Id)
|
||||
after, err := readJob(env.app, job.Id)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, entity.StateTranscribe, after.State, "результат не записан")
|
||||
assert.Empty(t, env.sender.messages, "отправителю ничего не отправлено")
|
||||
|
||||
@@ -45,7 +45,7 @@ func TestShutdownDuringConversionKeepsJobRetryable(t *testing.T) {
|
||||
require.Error(t, err, "шаг обязан сообщить об обрыве наверх")
|
||||
require.ErrorIs(t, err, context.Canceled, "обрыв узнаётся по смыслу, а не по тексту")
|
||||
|
||||
after, err := env.jobRepo.GetByID(job.Id)
|
||||
after, err := readJob(env.app, job.Id)
|
||||
require.NoError(t, err)
|
||||
|
||||
assert.Equal(t, entity.StateCreated, after.State, "задача осталась на повтор, а не похоронена")
|
||||
@@ -72,7 +72,7 @@ func TestShutdownBeforeStepLeavesJobUntouched(t *testing.T) {
|
||||
var noop *contract.NoopJobError
|
||||
require.ErrorAs(t, err, &noop)
|
||||
|
||||
after, err := env.jobRepo.GetByID(job.Id)
|
||||
after, err := readJob(env.app, job.Id)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, entity.StateCreated, after.State)
|
||||
assert.Equal(t, 0, after.Attempts, "захвата не было — попытке взяться неоткуда")
|
||||
|
||||
@@ -87,10 +87,24 @@ func (s *TranscribeService) CreateJobFromTelegram(ctx context.Context, file io.R
|
||||
return s.createTranscribeJob(ctx, job, file, fileName)
|
||||
}
|
||||
|
||||
func (s *TranscribeService) CreateJobFromApi(ctx context.Context, file io.Reader, fileName string) (*entity.TranscribeJob, error) {
|
||||
// CreateJobFromApi заводит задачу от имени вошедшего. Владелец обязателен:
|
||||
// пустой отвергается здесь, потому что колонка владельца допускает пустое
|
||||
// значение ради записей бота, и приём по HTTP — то место, где обязательность
|
||||
// держится.
|
||||
//
|
||||
// Отказ этот — последний рубеж, а не первый: предъявителя без учётной записи
|
||||
// пользователя транспорт отвергает раньше, до чтения тела. Здесь он остаётся на
|
||||
// случай нового вызывающего, который такой проверки не поставит.
|
||||
func (s *TranscribeService) CreateJobFromApi(ctx context.Context, file io.Reader, fileName, ownerID string) (*entity.TranscribeJob, error) {
|
||||
if ownerID == "" {
|
||||
s.logger.Error("Refusing to create job without owner")
|
||||
return nil, contract.ErrOwnerRequired
|
||||
}
|
||||
|
||||
job := &entity.TranscribeJob{
|
||||
State: entity.StateCreated,
|
||||
Source: entity.SourceApi,
|
||||
State: entity.StateCreated,
|
||||
Source: entity.SourceApi,
|
||||
OwnerID: &ownerID,
|
||||
}
|
||||
|
||||
return s.createTranscribeJob(ctx, job, file, fileName)
|
||||
@@ -135,7 +149,7 @@ func (s *TranscribeService) createTranscribeJob(ctx context.Context, job *entity
|
||||
return nil, err
|
||||
}
|
||||
|
||||
fileRecord, err := s.fileRepo.CreateLocal(storageFileName, work)
|
||||
fileRecord, err := s.fileRepo.CreateLocal(storageFileName, work, ownerOf(job))
|
||||
if err != nil {
|
||||
s.logger.Error("Failed to create file record", "error", err, "file_ext", ext)
|
||||
return nil, err
|
||||
@@ -284,7 +298,7 @@ func (s *TranscribeService) convertJob(ctx context.Context, job *entity.Transcri
|
||||
metrics.OutputFileSizeHistogram.WithLabelValues("ogg").Observe(float64(destSize))
|
||||
|
||||
destFileName := fmt.Sprintf("%s%s", uuid.NewString(), ".ogg")
|
||||
destFileRecord, err := s.fileRepo.CreateLocal(destFileName, dest)
|
||||
destFileRecord, err := s.fileRepo.CreateLocal(destFileName, dest, ownerOf(job))
|
||||
if err != nil {
|
||||
s.logger.Error("Failed to create converted file record", "error", err, "job_id", job.Id)
|
||||
return err
|
||||
@@ -346,7 +360,7 @@ func (s *TranscribeService) transcribeJob(ctx context.Context, job *entity.Trans
|
||||
"job_id", job.Id,
|
||||
"operation_id", operationID)
|
||||
|
||||
destFileRecord, err := s.fileRepo.CreateRemote(fileRecord.FileName, fileRecord.Size)
|
||||
destFileRecord, err := s.fileRepo.CreateRemote(fileRecord.FileName, fileRecord.Size, ownerOf(job))
|
||||
if err != nil {
|
||||
s.logger.Error("Failed to create S3 file record", "error", err, "job_id", job.Id)
|
||||
return err
|
||||
@@ -635,3 +649,14 @@ func (s *TranscribeService) closeWork(work contract.WorkFile) {
|
||||
s.logger.Error("Failed to remove work file", "error", err)
|
||||
}
|
||||
}
|
||||
|
||||
// ownerOf — владелец задачи строкой; пустая значит «владельца нет», и таковы
|
||||
// записи, принятые ботом. Файл наследует владельца своей задачи: правило
|
||||
// просмотра коллекции файлов сужено этой колонкой, и файл, заведённый шагом
|
||||
// конвейера без неё, перестал бы доставаться собственному владельцу.
|
||||
func ownerOf(job *entity.TranscribeJob) string {
|
||||
if job.OwnerID == nil {
|
||||
return ""
|
||||
}
|
||||
return *job.OwnerID
|
||||
}
|
||||
|
||||
@@ -71,7 +71,7 @@ func TestUndeliveredOnDownChannelKeepsJobDone(t *testing.T) {
|
||||
|
||||
assert.Equal(t, 1, sender.calls, "ответ до отправителя доехал")
|
||||
|
||||
after, err := env.jobRepo.GetByID(job.Id)
|
||||
after, err := readJob(env.app, job.Id)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, entity.StateDone, after.State, "задача осталась в достигнутом состоянии")
|
||||
require.NotNil(t, after.TranscriptionText)
|
||||
@@ -108,7 +108,7 @@ func TestUndeliveredWithoutChatKeepsJobDone(t *testing.T) {
|
||||
|
||||
assert.Equal(t, 0, sender.calls, "до отправителя дело не дошло: адресата нет")
|
||||
|
||||
after, err := env.jobRepo.GetByID(job.Id)
|
||||
after, err := readJob(env.app, job.Id)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, entity.StateDone, after.State)
|
||||
assert.Nil(t, after.ErrorText, "отказ задаче не приписан")
|
||||
@@ -127,7 +127,7 @@ func TestUndeliveredWithoutChatKeepsJobDone(t *testing.T) {
|
||||
func TestApiJobDoesNotReachSenderAndLogsNothing(t *testing.T) {
|
||||
env := newPipelineEnv(t, &okMetaViewer{}, &failingConverter{})
|
||||
|
||||
job, err := env.service.CreateJobFromApi(t.Context(), strings.NewReader("запись"), "voice.ogg")
|
||||
job, err := env.service.CreateJobFromApi(t.Context(), strings.NewReader("запись"), "voice.ogg", newOwner(t, env.app))
|
||||
require.NoError(t, err)
|
||||
|
||||
sender := &downSender{}
|
||||
|
||||
Reference in New Issue
Block a user