у записи появился владелец: чужую больше не отдают

- колонка `owner` связью с `users` в обеих коллекциях новым шагом схемы
  `202608140001`; чтение задачи сужено владельцем, и чужая, ничья и
  несуществующая дают один ответ; правило просмотра файлов сужено им же
- приём по HTTP берёт владельца из сессии, а предъявителя без учётной записи
  пользователя отвергает до чтения тела: позже пришлось бы убирать уложенный
  файл, а уборки файлов сервис не умеет. Выборка воркера владельцем не сужается
- удаление учётной записи с записями отвергается стражем, и вешает его сама
  сборка хранилища: сборка, забывшая его позвать, теряла защиту молча
This commit is contained in:
av
2026-08-14 12:18:11 +03:00
parent b7d4660aef
commit 8af8ec2e54
46 changed files with 2520 additions and 101 deletions
+80 -9
View File
@@ -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
}