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

- колонка `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
@@ -0,0 +1,136 @@
## MODIFIED Requirements
### Requirement: Приём записи по HTTP
Сервис SHALL принимать запись от внешней программы запросом `POST /api/audio` с
телом `multipart/form-data` и полем `audio` **только от узнанного отправителя**.
Запрос без сессии MUST получать код `401`, и по нему MUST не заводиться ни файл,
ни задача расшифровки. Принятая запись от узнанного отправителя MUST быть
сохранена и получить заведённую под неё задачу расшифровки в состоянии
`created`; ответ MUST нести идентификатор задачи полем `job_id` и её состояние
полем `status`.
Отказ по отсутствию сессии наступает **раньше** чтения тела: запись, за которую
не заплатит узнанный отправитель, не должна попасть даже в память.
Имена полей ответа нормативны: контракт HTTP API объявлен проектом необратимым,
и переименование поля ломает внешнюю программу молча. Появление отказа без
сессии — намеренная ломка этого контракта: до неё приём стоял открытым наружу.
Приём не судит о годности записи сам: расширение он берёт из имени файла, а
пригодность содержимого узнаёт у источника метаданных.
Куда именно ложится принятая запись, приёму не принадлежит: раскладку выбирает
хранилище, и нормирует её capability `storage`.
Владельцем принятой записи приём SHALL назначать предъявителя сессии. Проверка
стоит здесь, а не только в схеме хранилища: колонка владельца допускает пустое
значение ради записей из Telegram, и приём по HTTP — то место, где
обязательность держится.
Предъявитель, чья сессия не даёт учётной записи пользователя, MUST получать
отказ `403` и MUST получать его **до чтения тела** — там же, где стоит отказ по
отсутствию сессии. Сессия владельца панели — именно такой случай: узнан он всё
же узнан, а записи в коллекции пользователей у него нет, и владельцем записи он
стать не может.
Код здесь другой, чем у запроса без сессии, и это не оплошность: `401` значит
«предъяви себя», а предъявитель себя предъявил. Утечки по разнице кодов нет —
оба ответа говорят о самом спрашивающем, а не о том, какие записи заведены.
Отказ **после** укладки записи потребовал бы убрать уже сохранённый файл, а
уборки файлов сервис не умеет вовсе: норма, обязывающая к недостижимому, не
пишется.
#### Scenario: Запись принята
- **GIVEN** источник метаданных читает запись и отдаёт её длительность
- **AND** отправитель предъявил сессию
- **WHEN** программа шлёт `POST /api/audio` с полем `audio`
- **THEN** ответ имеет код `201`, а в теле лежат непустой `job_id` и `status`
со значением `created`
- **AND** содержимое записи целиком лежит в хранилище одним файлом
- **AND** владельцем заведённой задачи стоит предъявитель сессии
#### Scenario: Сессия не даёт учётной записи пользователя
- **GIVEN** предъявлена сессия владельца панели
- **WHEN** он шлёт `POST /api/audio` с полем `audio`
- **THEN** ответ имеет код `403`
- **AND** ни файла, ни задачи не заводится
#### Scenario: Сессии нет
- **WHEN** программа шлёт `POST /api/audio` с полем `audio` без сессии
- **THEN** ответ имеет код `401`
- **AND** ни файла, ни задачи не заводится
- **AND** тело ответа не несёт данных задачи
#### Scenario: Поля с записью нет
- **GIVEN** отправитель предъявил сессию
- **WHEN** программа шлёт `POST /api/audio` без поля `audio`
- **THEN** ответ имеет код `400` и сообщение об отсутствии записи
- **AND** ни файла, ни задачи не заводится
#### Scenario: Размеру записи приём не судья
- **GIVEN** источник метаданных читает запись и отдаёт её длительность
- **AND** отправитель предъявил сессию
- **WHEN** программа шлёт запись нулевой длины
- **THEN** ответ имеет код `201`: собственного порога по размеру у приёма нет
### Requirement: Опрос готовности задачи
Сервис SHALL отдавать состояние задачи расшифровки по запросу
`GET /api/status/:id` **только её владельцу**. Запрос без сессии MUST получать
код `401`, и тело такого ответа MUST не нести ни состояния задачи, ни текста
расшифровки. Ответ владельцу MUST нести идентификатор полем `job_id`, состояние
полем `status` и время заведения полем `created_at`, а текст расшифровки полем
`transcription_text`, и это поле MUST отсутствовать в ответе, пока текста нет:
пустая строка на месте отсутствующего текста читается как «расшифровка пуста».
Отказ без сессии MUST не зависеть от того, есть такая задача или нет: иначе по
кодам ответа перебирается список заведённых задач.
Задача, принадлежащая другому, MUST отвечать тем же, чем отвечает неизвестный
идентификатор, — кодом `404` и тем же телом. То же MUST относиться к задаче без
владельца: запись, принятая ботом, по этому адресу не достаётся никому.
#### Scenario: Задача найдена
- **GIVEN** отправитель предъявил сессию
- **WHEN** он спрашивает состояние своей задачи
- **THEN** ответ имеет код `200` и несёт `job_id`, `status` и `created_at`
#### Scenario: Сессии нет
- **WHEN** программа спрашивает состояние заведённой задачи без сессии
- **THEN** ответ имеет код `401`
- **AND** тело ответа не несёт ни состояния задачи, ни текста расшифровки
#### Scenario: Без сессии неизвестная задача неотличима от заведённой
- **WHEN** программа без сессии спрашивает состояние заведённой задачи, а затем
состояние по неизвестному идентификатору
- **THEN** оба ответа имеют код `401`
#### Scenario: Чужая задача неотличима от неизвестной
- **GIVEN** задача заведена одним вошедшим
- **WHEN** её состояние спрашивает другой вошедший
- **THEN** ответ имеет код `404` и то же тело, что и ответ по неизвестному
идентификатору
- **AND** тело ответа не несёт ни состояния задачи, ни текста расшифровки
#### Scenario: Расшифровки ещё нет
- **GIVEN** отправитель предъявил сессию
- **WHEN** он спрашивает состояние своей задачи, которая ещё не дошла до текста
- **THEN** поля `transcription_text` в ответе нет вовсе
#### Scenario: Задачи с таким идентификатором нет
- **GIVEN** отправитель предъявил сессию
- **WHEN** программа спрашивает состояние по неизвестному идентификатору
- **THEN** ответ имеет код `404` и сообщение о ненайденной задаче