у записи появился владелец: чужую больше не отдают
- колонка `owner` связью с `users` в обеих коллекциях новым шагом схемы `202608140001`; чтение задачи сужено владельцем, и чужая, ничья и несуществующая дают один ответ; правило просмотра файлов сужено им же - приём по HTTP берёт владельца из сессии, а предъявителя без учётной записи пользователя отвергает до чтения тела: позже пришлось бы убирать уложенный файл, а уборки файлов сервис не умеет. Выборка воркера владельцем не сужается - удаление учётной записи с записями отвергается стражем, и вешает его сама сборка хранилища: сборка, забывшая его позвать, теряла защиту молча
This commit is contained in:
@@ -0,0 +1,63 @@
|
|||||||
|
# ADR-2026-08-14. Учётная запись с записями не удаляется, и это осознанный тупик
|
||||||
|
|
||||||
|
- **Дата:** 2026-08-14
|
||||||
|
- **Источник:** [openspec/changes/archive/2026-08-14-record-ownership/design.md](../../openspec/changes/archive/2026-08-14-record-ownership/design.md), раздел `Open Questions`
|
||||||
|
|
||||||
|
## Решение
|
||||||
|
|
||||||
|
Удаление учётной записи, у которой остались задачи расшифровки либо файлы,
|
||||||
|
отвергается — отказом с названной причиной. Способа удалить записи в сервисе нет
|
||||||
|
вовсе, поэтому до задачи про удаление записи такая учётная запись не удаляется
|
||||||
|
никак: ни владельцем панели, ни самим человеком.
|
||||||
|
|
||||||
|
Решение принято человеком на чекпоинте задачи `record-ownership` из трёх
|
||||||
|
предложенных способов.
|
||||||
|
|
||||||
|
Дословно из источника:
|
||||||
|
|
||||||
|
> **Что делать с записями удалённого пользователя?** Связь при выключенном
|
||||||
|
> каскаде снимает ссылку — записи остаются, но становятся ничьими и
|
||||||
|
> недостижимыми по API навсегда. Способы: запретить удаление учётной записи, пока
|
||||||
|
> у неё есть записи; держать рядом со связью неизменяемый снимок идентификатора;
|
||||||
|
> признать потерю ценой и записать её.
|
||||||
|
|
||||||
|
## Почему
|
||||||
|
|
||||||
|
Колонка владельца — связь с учётной записью, и каскадное удаление у неё
|
||||||
|
выключено: сервис объявлен архивом и молча удалить чужой архив не вправе. Одного
|
||||||
|
этого мало, и проверка по исходникам `pocketbase@v0.39.10` показала почему: при
|
||||||
|
выключенном каскаде хранилище **вынимает** идентификатор из поля связи и
|
||||||
|
сохраняет запись без проверок. Задачи остались бы на месте, но стали бы ничьими —
|
||||||
|
а ничья запись по правилу той же задачи не достаётся по API никому. Архив
|
||||||
|
человека исчезал бы молча, и восстановить владельца было бы нечем: прежнего
|
||||||
|
значения не остаётся нигде.
|
||||||
|
|
||||||
|
Прежнее обоснование выбора связи вместо строки — «связь удержит целостность» —
|
||||||
|
было неверным, и это выяснилось на ревью дизайна.
|
||||||
|
|
||||||
|
## Чем платим
|
||||||
|
|
||||||
|
Владелец панели упирается в отказ, а выхода из него сегодня нет: удаление записи
|
||||||
|
приносит отдельная задача. Тупик назван прямо, а не обнаружен потом.
|
||||||
|
|
||||||
|
Отказ обязан доезжать до спрашивающего: хранилище пропускает наружу только свою
|
||||||
|
ошибку роутера, а всякую другую подменяет сообщением про обязательную связь.
|
||||||
|
Подсказка эта ведущая — единственная обязательная связь у задачи это файл, — и
|
||||||
|
владелец панели, поверив ей, пошёл бы удалять записи руками, то есть делать ровно
|
||||||
|
то необратимое, ради предотвращения чего запрет и заведён. Это нашло ревью кода.
|
||||||
|
|
||||||
|
## Что рассматривалось и отвергнуто
|
||||||
|
|
||||||
|
- **Неизменяемый снимок идентификатора рядом со связью.** Пережил бы удаление, и
|
||||||
|
запись можно было бы вернуть человеку. Отвергнуто: владельцем становится любая
|
||||||
|
строка, и целостность, ради которой выбрана связь, теряется.
|
||||||
|
- **Признать потерю ценой и записать её.** Дешевле всего сегодня — удаления
|
||||||
|
пользователей в сервисе нет вовсе. Отвергнуто: архив, теряемый одной кнопкой в
|
||||||
|
панели, противоречит решению от 2026-08-11 о том, что сервис — архив.
|
||||||
|
|
||||||
|
## Связанное
|
||||||
|
|
||||||
|
Запрет ставит сама сборка хранилища, а не вызывающий: сборка, забывшая его
|
||||||
|
позвать, теряет защиту молча — и теряла, пока его добавляли отдельной строкой
|
||||||
|
запуска. Норма — `openspec/specs/storage`, «Учётная запись с записями не
|
||||||
|
удаляется».
|
||||||
@@ -35,6 +35,7 @@
|
|||||||
|
|
||||||
| Дата | Запись | Статус |
|
| Дата | Запись | Статус |
|
||||||
| --- | --- | --- |
|
| --- | --- | --- |
|
||||||
|
| 2026-08-14 | [Учётная запись с записями не удаляется, и это осознанный тупик](ADR-2026-08-14-account-with-records-is-not-deleted.md) | |
|
||||||
| 2026-08-13 | [Намерение объявляется признаком, а не выводится из ключа доступа](ADR-2026-08-13-telegram-intent-declared-not-inferred.md) | |
|
| 2026-08-13 | [Намерение объявляется признаком, а не выводится из ключа доступа](ADR-2026-08-13-telegram-intent-declared-not-inferred.md) | |
|
||||||
| 2026-08-13 | [Недоступность Telegram подъёму сервиса не мешает](ADR-2026-08-13-telegram-outage-does-not-block-startup.md) | |
|
| 2026-08-13 | [Недоступность Telegram подъёму сервиса не мешает](ADR-2026-08-13-telegram-outage-does-not-block-startup.md) | |
|
||||||
| 2026-08-12 | [Файл записи закрыт защищённым полем и отдаётся вошедшему по токену файла](ADR-2026-08-12-protected-file-behind-session.md) | |
|
| 2026-08-12 | [Файл записи закрыт защищённым полем и отдаётся вошедшему по токену файла](ADR-2026-08-12-protected-file-behind-session.md) | |
|
||||||
|
|||||||
@@ -35,8 +35,10 @@
|
|||||||
- [access](../openspec/specs/access/spec.md) — кто пришёл в сервис и пускают ли
|
- [access](../openspec/specs/access/spec.md) — кто пришёл в сервис и пускают ли
|
||||||
его дальше: вход через внешнего провайдера OIDC, чем предъявляется сессия, что
|
его дальше: вход через внешнего провайдера OIDC, чем предъявляется сессия, что
|
||||||
её прекращает и какие адреса остаются открытыми. Задача `oidc-login`
|
её прекращает и какие адреса остаются открытыми. Задача `oidc-login`
|
||||||
2026-08-12. Разграничения записей по владельцу здесь нет: всякий вошедший
|
2026-08-12. Здесь же разграничение записей по владельцу: запись из веба
|
||||||
видит всё, что видел прежде аноним.
|
принадлежит тому, кто её принёс, чужая неотличима от несуществующей, а запись
|
||||||
|
из Telegram владельца не имеет и по API не достаётся никому. Задача
|
||||||
|
`record-ownership` 2026-08-14.
|
||||||
|
|
||||||
Поведение прочих узлов, включая приём из Telegram, по-прежнему живёт только в
|
Поведение прочих узлов, включая приём из Telegram, по-прежнему живёт только в
|
||||||
коде. Задача, которая его трогает, дописывает спеку своей capability.
|
коде. Задача, которая его трогает, дописывает спеку своей capability.
|
||||||
|
|||||||
+24
-2
@@ -45,6 +45,7 @@ Object Storage — каждая своей записью.
|
|||||||
| --- | --- | --- |
|
| --- | --- | --- |
|
||||||
| `id` | TEXT PK | Идентификатор записи, выдаёт хранилище |
|
| `id` | TEXT PK | Идентификатор записи, выдаёт хранилище |
|
||||||
| `file` | file | Сам файл; пусто у копии в Object Storage |
|
| `file` | file | Сам файл; пусто у копии в Object Storage |
|
||||||
|
| `owner` | relation → `users` | Владелец файла; пусто у файлов записи, принятой ботом |
|
||||||
| `location` | select | `local` или `s3` |
|
| `location` | select | `local` или `s3` |
|
||||||
| `object_key` | TEXT | Ключ объекта; пусто у местной копии |
|
| `object_key` | TEXT | Ключ объекта; пусто у местной копии |
|
||||||
| `size` | INTEGER | Размер в байтах |
|
| `size` | INTEGER | Размер в байтах |
|
||||||
@@ -60,6 +61,7 @@ capability, и третий смысл развёл бы одно слово п
|
|||||||
| Поле | Тип | Что |
|
| Поле | Тип | Что |
|
||||||
| --- | --- | --- |
|
| --- | --- | --- |
|
||||||
| `id` | TEXT PK | Идентификатор записи, выдаёт хранилище |
|
| `id` | TEXT PK | Идентификатор записи, выдаёт хранилище |
|
||||||
|
| `owner` | relation → `users` | Владелец записи; пусто у записей, принятых ботом |
|
||||||
| `state` | select | `created`, `converted`, `transcribe`, `done`, `failed`, `dead`; перечень закрыт схемой |
|
| `state` | select | `created`, `converted`, `transcribe`, `done`, `failed`, `dead`; перечень закрыт схемой |
|
||||||
| `source` | select | `api`, `telegram`, `unknown` |
|
| `source` | select | `api`, `telegram`, `unknown` |
|
||||||
| `file` | relation → `files` | **Текущий** файл задачи: шаг конвейера переставляет ссылку на свой результат |
|
| `file` | relation → `files` | **Текущий** файл задачи: шаг конвейера переставляет ссылку на свой результат |
|
||||||
@@ -83,8 +85,28 @@ capability, и третий смысл развёл бы одно слово п
|
|||||||
«мертва»». Схеме принадлежит только закрытость перечня: шестое состояние
|
«мертва»». Схеме принадлежит только закрытость перечня: шестое состояние
|
||||||
потребует нового шага.
|
потребует нового шага.
|
||||||
|
|
||||||
**Правила доступа обеих коллекций пусты**, то есть перечислять и читать записи
|
**Владелец записи** заведён шагом `202608140001` — связью с коллекцией `users` в
|
||||||
может только владелец панели. Проверено прогоном: анонимный запрос к
|
обеих таблицах. Пустое значение допустимо, и это решение с ценой: записи,
|
||||||
|
принятые ботом, владельца не имеют вовсе, потому что связи чата Telegram с
|
||||||
|
учётной записью сервис не ведёт. Обязательность для приёма по HTTP держит
|
||||||
|
поэтому сам приём, а не схема.
|
||||||
|
|
||||||
|
Выборка по владельцу сужает **чтение задачи**: чужая, ничья и несуществующая
|
||||||
|
дают один и тот же отказ. Выборку воркера владелец не сужает — конвейер
|
||||||
|
обрабатывает записи всех. Тот же шаг сужает правило просмотра коллекции
|
||||||
|
`files` владельцем: прежнее правило пускало всякого вошедшего, и знание
|
||||||
|
идентификатора файловой записи равнялось праву скачать чужое аудио.
|
||||||
|
|
||||||
|
**Учётная запись с задачами не удаляется.** Каскадное удаление у связи выключено,
|
||||||
|
но одного этого мало: при выключенном каскаде хранилище снимает ссылку и
|
||||||
|
сохраняет запись без проверок — задачи остались бы, но стали бы ничьими, а ничья
|
||||||
|
задача не достаётся никому. Отказ ставит слой приложения `GuardOwnerDeletion`,
|
||||||
|
а не правило коллекции: панель ходит правами суперпользователя, и правило её не
|
||||||
|
судит. Цена названа прямо — владелец панели упирается в отказ, а удаления
|
||||||
|
записей в сервисе пока нет вовсе.
|
||||||
|
|
||||||
|
**Правила доступа задач пусты**, то есть перечислять и читать их может только
|
||||||
|
владелец панели. Проверено прогоном: анонимный запрос к
|
||||||
`/api/collections/*/records` отвечает `403`, к `/api/logs`, `/api/backups`,
|
`/api/collections/*/records` отвечает `403`, к `/api/logs`, `/api/backups`,
|
||||||
`/api/settings` и `/api/crons` — `401`.
|
`/api/settings` и `/api/crons` — `401`.
|
||||||
|
|
||||||
|
|||||||
+6
-6
@@ -69,10 +69,10 @@
|
|||||||
файл. Своей записи и работы без сети не делаем.
|
файл. Своей записи и работы без сети не делаем.
|
||||||
- **Файловое хранилище общего назначения.** Храним аудио и видео, отданные ради
|
- **Файловое хранилище общего назначения.** Храним аудио и видео, отданные ради
|
||||||
речи в них. Складом произвольных файлов и папками сервис не становится. Общего
|
речи в них. Складом произвольных файлов и папками сервис не становится. Общего
|
||||||
доступа к чужим записям целью тоже нет — но **сегодня он есть**: владельца у
|
доступа к чужим записям целью нет, и с 2026-08-14 его нет и на деле: у записи
|
||||||
записи в модели данных не существует, и всякий вошедший видит все записи
|
есть владелец, и чужую по её идентификатору не отдают
|
||||||
([security.md](security.md), «Периметр»). Это состояние, а не решение;
|
([security.md](security.md), «Периметр»). Закрыла это задача
|
||||||
закрывает его `record-ownership`.
|
`record-ownership`.
|
||||||
- **Учёт денег.** Считаем объём, минуты и токены по каждому пользователю и
|
- **Учёт денег.** Считаем объём, минуты и токены по каждому пользователю и
|
||||||
показываем их владельцу. Цен, счетов и отказов по исчерпании квоты не делаем:
|
показываем их владельцу. Цен, счетов и отказов по исчерпании квоты не делаем:
|
||||||
пользователя, потратившего слишком много, останавливает разговор или отзыв
|
пользователя, потратившего слишком много, останавливает разговор или отзыв
|
||||||
@@ -100,8 +100,8 @@
|
|||||||
получает идентификатор задачи и опрашивает `GET /api/status/:id`, пока не
|
получает идентификатор задачи и опрашивает `GET /api/status/:id`, пока не
|
||||||
увидит `done` и текст. Сегодня доступно только предъявившему сессию OIDC:
|
увидит `done` и текст. Сегодня доступно только предъявившему сессию OIDC:
|
||||||
анонимный запрос обоими адресами отклоняется. Своего входа у программы нет —
|
анонимный запрос обоими адресами отклоняется. Своего входа у программы нет —
|
||||||
его заводит `api-tokens`, — как нет и разграничения записей между
|
его заводит `api-tokens`. Записи при этом разграничены: программа с чужой
|
||||||
пользователями.
|
сессией видит только записи того, чью сессию предъявила.
|
||||||
6. **Отказ на середине.** Конвертация или распознавание не удались — задача
|
6. **Отказ на середине.** Конвертация или распознавание не удались — задача
|
||||||
переходит в `failed`, а пользователь получает сообщение о том, что именно не
|
переходит в `failed`, а пользователь получает сообщение о том, что именно не
|
||||||
вышло, и предложение повторить.
|
вышло, и предложение повторить.
|
||||||
|
|||||||
@@ -186,3 +186,37 @@ pb_data/storage/<коллекция>/<запись>/<имя>_<10 случайн
|
|||||||
способов:
|
способов:
|
||||||
вместе с панелью отказ выбрасывал бы уход CGO и встроенное резервное
|
вместе с панелью отказ выбрасывал бы уход CGO и встроенное резервное
|
||||||
копирование, которых у сервиса-архива нет никаких.
|
копирование, которых у сервиса-архива нет никаких.
|
||||||
|
|
||||||
|
## Разграничение по владельцу: что выяснилось при реализации
|
||||||
|
|
||||||
|
Дописано 2026-08-14 задачей `record-ownership`. Все находки ниже получены одним
|
||||||
|
способом: чтением исходников `pocketbase@v0.39.10` из кеша модулей и прогонами
|
||||||
|
против настоящего хранилища на временном каталоге — в ходе ревью того change.
|
||||||
|
|
||||||
|
**Связь с выключенным каскадом не удерживает целостность при удалении.**
|
||||||
|
`core/record_model.go`, `deleteRefRecords`: при `CascadeDelete = false` и
|
||||||
|
необязательном поле хранилище **вынимает** идентификатор из поля связи и
|
||||||
|
сохраняет запись через `SaveNoValidate`. То есть «не уносить записи следом» и
|
||||||
|
«сохранить у них владельца» — разные вещи, и связь даёт только первое.
|
||||||
|
|
||||||
|
**Наружу проходит только ошибка роутера.** `apis/record_crud.go` заворачивает
|
||||||
|
отказ хука в `firstApiError(err, e.BadRequestError("Failed to delete record. Make
|
||||||
|
sure that the record is not part of a required relation reference.", err))`, а
|
||||||
|
`firstApiError` берёт первый аргумент, только если он `*router.ApiError`. Обычная
|
||||||
|
ошибка из хука до ответа не доезжает вовсе, и спрашивающий получает библиотечную
|
||||||
|
подсказку про обязательную связь — в нашем случае указывающую не на ту связь.
|
||||||
|
|
||||||
|
**`apis/file.go` выдаёт токен файла на предъявителя, а не на файл.** О файле при
|
||||||
|
выдаче он не спрашивает. Владельца судит переход по ссылке: правило просмотра
|
||||||
|
коллекции проверяет защищённое поле файла по учётной записи **из токена**. Значит
|
||||||
|
чужой токен получить можно всегда, а скачать по нему чужой файл — нет.
|
||||||
|
|
||||||
|
**Проверка сессии с именем коллекции отвечает `403`, а не `401`.**
|
||||||
|
`apis.RequireAuth("users")` пускает только запись названной коллекции; предъявитель
|
||||||
|
из другой — например, владелец панели — узнан, но не годится, и код отказа это
|
||||||
|
различает.
|
||||||
|
|
||||||
|
**Связь в SQLite лежит пустой строкой, а не `NULL`.** `RelationField.ColumnType`
|
||||||
|
даёт `TEXT DEFAULT '' NOT NULL`; сырой запрос и чтение через запись коллекции
|
||||||
|
совпадают побайтово. «Умолчания у колонки нет» верно по замыслу — пустое значение
|
||||||
|
не совпадает ни с кем, — но не буквально на уровне схемы.
|
||||||
|
|||||||
+11
-6
@@ -117,12 +117,17 @@
|
|||||||
- **«Файлы и объекты не удаляются, диск растёт».** Факт верный и записан в
|
- **«Файлы и объекты не удаляются, диск растёт».** Факт верный и записан в
|
||||||
[database.md](database.md); срок хранения не задан сознательно, задачи на него нет.
|
[database.md](database.md); срок хранения не задан сознательно, задачи на него нет.
|
||||||
Новой находкой это не считается, пока не измерен рост.
|
Новой находкой это не считается, пока не измерен рост.
|
||||||
- **«У записи нет владельца: вошедший видит чужие записи».** Не дефект и не
|
- **«Запись без владельца не достаётся никому».** Не дефект: владельца не имеют
|
||||||
новость: приём, опрос и файл закрыты сессией OIDC с 2026-08-12, а
|
записи, принятые ботом, — связи чата Telegram с учётной записью приложения
|
||||||
разграничения по владельцу нет сознательно — [security.md](security.md),
|
сервис не ведёт, её заводит `telegram-account-link`. Ответ такой записи по API
|
||||||
«Периметр», и `openspec/specs/access`, «Purpose». Находкой считается новая
|
совпадает с ответом на несуществующую, и это норма — расшифровку отправитель
|
||||||
поверхность, выставленная наружу, либо путь к содержимому записи **без**
|
получает в чат.
|
||||||
сессии, а не повторение этого факта.
|
|
||||||
|
**Прежняя редакция этой строки отменена 2026-08-14.** До задачи
|
||||||
|
`record-ownership` здесь стояло «вошедший видит чужие записи — не дефект и не
|
||||||
|
новость»: разграничения не было сознательно. Теперь оно есть, и такая находка
|
||||||
|
настоящая. Строка оставлена вместо удаления намеренно: прогон, помнящий её
|
||||||
|
прежний вид, выбросил бы регрессию не глядя.
|
||||||
|
|
||||||
### Вопросы по темам
|
### Вопросы по темам
|
||||||
|
|
||||||
|
|||||||
+11
-7
@@ -10,9 +10,13 @@
|
|||||||
|
|
||||||
Целевой периметр добавляет к нему отдельный вход для программ по личным токенам
|
Целевой периметр добавляет к нему отдельный вход для программ по личным токенам
|
||||||
и два уровня доступа — пользователь видит свои записи, владелец сервиса ещё и
|
и два уровня доступа — пользователь видит свои записи, владелец сервиса ещё и
|
||||||
страницу расхода. **Разграничения по владельцу нет:** всякий вошедший видит все
|
страницу расхода. **Разграничение по владельцу записи заведено 2026-08-14**
|
||||||
записи и все расшифровки, как видел их прежде аноним. Его заводит задача
|
задачей `record-ownership`: и опрос готовности, и файл записи сужены владельцем
|
||||||
`record-ownership`.
|
записи, а чужая отвечает «не найдено». Целевому периметру недостаёт теперь второго уровня
|
||||||
|
доступа — страницы расхода для владельца сервиса.
|
||||||
|
|
||||||
|
Записи, принятые ботом, владельца не имеют и по API не достаются никому: связи
|
||||||
|
чата с учётной записью приложения нет, её заводит `telegram-account-link`.
|
||||||
|
|
||||||
Разграничение доступа в Telegram осталось прежним — белым списком, и с учётной
|
Разграничение доступа в Telegram осталось прежним — белым списком, и с учётной
|
||||||
записью приложения он не связан.
|
записью приложения он не связан.
|
||||||
@@ -173,9 +177,9 @@ Telegram отправителю.
|
|||||||
прокси — работа выкладки, и сервис на неё не полагается: содержимого записей
|
прокси — работа выкладки, и сервис на неё не полагается: содержимого записей
|
||||||
эти адреса не несут.
|
эти адреса не несут.
|
||||||
|
|
||||||
Владения записью в модели данных по-прежнему нет: у задачи нет пользователя.
|
Владение записью в модели данных появилось 2026-08-14: у задачи и у её файла
|
||||||
Знание UUID задачи и есть право её читать — теперь для всякого вошедшего, а не
|
есть владелец. Знание идентификатора задачи правом её читать больше не является
|
||||||
для всякого встречного.
|
— читает её тот, кто её принёс.
|
||||||
|
|
||||||
Целевой периметр заводит четыре механизма вместо одного белого списка; первый из
|
Целевой периметр заводит четыре механизма вместо одного белого списка; первый из
|
||||||
них уже стоит:
|
них уже стоит:
|
||||||
@@ -183,7 +187,7 @@ Telegram отправителю.
|
|||||||
| Механизм | Что даёт | Чья задача |
|
| Механизм | Что даёт | Чья задача |
|
||||||
| --- | --- | --- |
|
| --- | --- | --- |
|
||||||
| Сессия OIDC у Authelia | Право открыть приложение и его эндпоинты — **сделано 2026-08-12** | `oidc-login` |
|
| Сессия OIDC у Authelia | Право открыть приложение и его эндпоинты — **сделано 2026-08-12** | `oidc-login` |
|
||||||
| Владелец у задачи и файла | Чужая запись по её идентификатору отвечает «не найдено» | `record-ownership` |
|
| Владелец у задачи и файла | Чужая запись по её идентификатору отвечает «не найдено» — **сделано 2026-08-14** | `record-ownership` |
|
||||||
| Личный токен | Права своего владельца программе, без браузерной сессии | `api-tokens` |
|
| Личный токен | Права своего владельца программе, без браузерной сессии | `api-tokens` |
|
||||||
| Признак владельца сервиса | Страницу расхода и сводку по всем пользователям | `admin-stats-screen` |
|
| Признак владельца сервиса | Страницу расхода и сводку по всем пользователям | `admin-stats-screen` |
|
||||||
|
|
||||||
|
|||||||
@@ -43,6 +43,13 @@ func New(dataDir string) (*pb.PocketBase, error) {
|
|||||||
return nil, fmt.Errorf("failed to apply storage schema: %w", err)
|
return nil, fmt.Errorf("failed to apply storage schema: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Страж владельца вешается здесь, а не вызывающим: он защищает архив от
|
||||||
|
// удаления учётной записи, и сборка, забывшая его позвать, теряет защиту
|
||||||
|
// молча. Так это уже и было — окружение проверок его не ставило, и всё
|
||||||
|
// разграничение проверялось на приложении, где архив сносится одним
|
||||||
|
// запросом.
|
||||||
|
GuardOwnerDeletion(app)
|
||||||
|
|
||||||
return app, nil
|
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)
|
collection, err := findCollection(repo.app, migrations.FilesCollection)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
@@ -132,6 +132,11 @@ func (repo *FileRepository) CreateLocal(name string, work contract.WorkFile) (*e
|
|||||||
record.Set("file", stored)
|
record.Set("file", stored)
|
||||||
record.Set("location", entity.LocationLocal)
|
record.Set("location", entity.LocationLocal)
|
||||||
record.Set("size", stored.Size)
|
record.Set("size", stored.Size)
|
||||||
|
// Владелец файла — владелец записи, которой файл принадлежит. Пустой значит
|
||||||
|
// «файл без владельца»: таков всякий файл записи, принятой ботом. Правило
|
||||||
|
// просмотра коллекции сужено этой колонкой, и без неё чужое аудио осталось
|
||||||
|
// бы доступным всякому вошедшему.
|
||||||
|
record.Set("owner", ownerID)
|
||||||
|
|
||||||
if err := repo.app.Save(record); err != nil {
|
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
|
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)
|
collection, err := findCollection(repo.app, migrations.FilesCollection)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
@@ -153,6 +158,7 @@ func (repo *FileRepository) CreateRemote(objectKey string, size int64) (*entity.
|
|||||||
record.Set("location", entity.LocationS3)
|
record.Set("location", entity.LocationS3)
|
||||||
record.Set("object_key", objectKey)
|
record.Set("object_key", objectKey)
|
||||||
record.Set("size", size)
|
record.Set("size", size)
|
||||||
|
record.Set("owner", ownerID)
|
||||||
|
|
||||||
if err := repo.app.Save(record); err != nil {
|
if err := repo.app.Save(record); err != nil {
|
||||||
return nil, fmt.Errorf("failed to store remote file record: %w", err)
|
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.NoError(t, err)
|
||||||
require.Greater(t, size, int64(libraryDefault), "запись заведомо больше умолчания библиотеки")
|
require.Greater(t, size, int64(libraryDefault), "запись заведомо больше умолчания библиотеки")
|
||||||
|
|
||||||
file, err := repo.CreateLocal("big.mp3", work)
|
file, err := repo.CreateLocal("big.mp3", work, "")
|
||||||
require.NoError(t, err, "запись длиннее умолчания библиотеки ложится в хранилище")
|
require.NoError(t, err, "запись длиннее умолчания библиотеки ложится в хранилище")
|
||||||
assert.Equal(t, size, file.Size)
|
assert.Equal(t, size, file.Size)
|
||||||
assert.Greater(t, entity.MaxRecordSize, 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) {
|
func applyToRecord(record *core.Record, job *entity.TranscribeJob) {
|
||||||
applyOwnedByPipeline(record, job)
|
applyOwnedByPipeline(record, job)
|
||||||
|
// Владелец кладётся только здесь, при заведении. В applyOwnedByPipeline его
|
||||||
|
// нет намеренно: конвейер владельца не назначает и не меняет, а снимок шага,
|
||||||
|
// записанный поверх, стёр бы его молча.
|
||||||
|
record.Set("owner", derefString(job.OwnerID))
|
||||||
record.Set("source", job.Source)
|
record.Set("source", job.Source)
|
||||||
record.Set("tg_chat_id", derefInt64(job.TgChatId))
|
record.Set("tg_chat_id", derefInt64(job.TgChatId))
|
||||||
record.Set("tg_reply_message_id", derefInt(job.TgReplyMessageId))
|
record.Set("tg_reply_message_id", derefInt(job.TgReplyMessageId))
|
||||||
@@ -48,6 +52,7 @@ func recordToJob(record *core.Record) *entity.TranscribeJob {
|
|||||||
return &entity.TranscribeJob{
|
return &entity.TranscribeJob{
|
||||||
Id: record.Id,
|
Id: record.Id,
|
||||||
State: record.GetString("state"),
|
State: record.GetString("state"),
|
||||||
|
OwnerID: nilIfEmpty(record.GetString("owner")),
|
||||||
Source: record.GetString("source"),
|
Source: record.GetString("source"),
|
||||||
FileID: nilIfEmpty(record.GetString("file")),
|
FileID: nilIfEmpty(record.GetString("file")),
|
||||||
ErrorText: nilIfEmpty(record.GetString("error_text")),
|
ErrorText: nilIfEmpty(record.GetString("error_text")),
|
||||||
@@ -71,6 +76,11 @@ func recordToJob(record *core.Record) *entity.TranscribeJob {
|
|||||||
type acquiredRow struct {
|
type acquiredRow struct {
|
||||||
Id string `db:"id"`
|
Id string `db:"id"`
|
||||||
State string `db:"state"`
|
State string `db:"state"`
|
||||||
|
// Владелец конвейеру не нужен для выборки — она им не сужается, — но
|
||||||
|
// читается: снимок задачи, в котором владелец всегда пуст, был бы ловушкой
|
||||||
|
// для первого же шага, начавшего сохранять задачу целиком. Сегодня это ещё
|
||||||
|
// и несущее чтение: шаг конвейера кладёт владельца на файл, который заводит.
|
||||||
|
OwnerID sql.NullString `db:"owner"`
|
||||||
Source string `db:"source"`
|
Source string `db:"source"`
|
||||||
FileID sql.NullString `db:"file"`
|
FileID sql.NullString `db:"file"`
|
||||||
ErrorText sql.NullString `db:"error_text"`
|
ErrorText sql.NullString `db:"error_text"`
|
||||||
@@ -90,6 +100,7 @@ func (r *acquiredRow) toJob() *entity.TranscribeJob {
|
|||||||
job := &entity.TranscribeJob{
|
job := &entity.TranscribeJob{
|
||||||
Id: r.Id,
|
Id: r.Id,
|
||||||
State: r.State,
|
State: r.State,
|
||||||
|
OwnerID: nullToPtr(r.OwnerID),
|
||||||
Source: r.Source,
|
Source: r.Source,
|
||||||
FileID: nullToPtr(r.FileID),
|
FileID: nullToPtr(r.FileID),
|
||||||
ErrorText: nullToPtr(r.ErrorText),
|
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 (
|
const (
|
||||||
FilesCollection = "files"
|
FilesCollection = "files"
|
||||||
JobsCollection = "transcribe_jobs"
|
JobsCollection = "transcribe_jobs"
|
||||||
|
// UsersCollection заводит не наш шаг, а системный шаг библиотеки. Имя стоит
|
||||||
|
// здесь потому, что на него ссылаются и шаги схемы, и проверка предъявителя
|
||||||
|
// на приёме: строковый литерал в двух местах разошёлся бы молча.
|
||||||
|
UsersCollection = "users"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Шаг регистрируется в списке приложения при загрузке пакета, а накатывает его
|
// Шаг регистрируется в списке приложения при загрузке пакета, а накатывает его
|
||||||
@@ -31,6 +35,7 @@ const (
|
|||||||
func init() {
|
func init() {
|
||||||
pbmigrations.Register(up202608110001, down202608110001, "202608110001_init.go")
|
pbmigrations.Register(up202608110001, down202608110001, "202608110001_init.go")
|
||||||
pbmigrations.Register(up202608120001, down202608120001, "202608120001_oidc_login.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 }
|
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
|
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)
|
record, err := repo.app.FindRecordById(migrations.JobsCollection, id)
|
||||||
if err != nil {
|
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)
|
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
|
return recordToJob(record), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// Колонки, которые читает захват. Список нужен запросу дословно: `RETURNING *`
|
// Колонки, которые читает захват. Список нужен запросу дословно: `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, ` +
|
`acquire_time, delay_time, attempts, recognition_op_id, transcription_text, ` +
|
||||||
`tg_chat_id, tg_reply_message_id, created, updated`
|
`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)
|
require.NoError(t, err)
|
||||||
defer func() { require.NoError(t, work.Close()) }()
|
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)
|
require.NoError(t, err)
|
||||||
return file
|
return file
|
||||||
}
|
}
|
||||||
@@ -246,7 +246,7 @@ func TestSave_RefusesWriteFromLostAcquisition(t *testing.T) {
|
|||||||
require.ErrorAs(t, err, &lost)
|
require.ErrorAs(t, err, &lost)
|
||||||
|
|
||||||
// И состояние не поехало.
|
// И состояние не поехало.
|
||||||
after, err := repo.GetByID(mine.Id)
|
after, err := readJobByID(t, app, mine.Id)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
assert.Equal(t, entity.StateCreated, after.State)
|
assert.Equal(t, entity.StateCreated, after.State)
|
||||||
}
|
}
|
||||||
@@ -261,7 +261,7 @@ func TestSave_WithoutHolderWritesAnyway(t *testing.T) {
|
|||||||
|
|
||||||
require.NoError(t, repo.Save(job, ""))
|
require.NoError(t, repo.Save(job, ""))
|
||||||
|
|
||||||
after, err := repo.GetByID(job.Id)
|
after, err := readJobByID(t, app, job.Id)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
assert.Equal(t, entity.StateConverted, after.State)
|
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+`"}`)
|
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)
|
require.NoError(t, err)
|
||||||
assert.Nil(t, after.AcquisitionID, "признак захвата снят")
|
assert.Nil(t, after.AcquisitionID, "признак захвата снят")
|
||||||
assert.Nil(t, after.AcquireTime, "время захвата снято")
|
assert.Nil(t, after.AcquireTime, "время захвата снято")
|
||||||
@@ -323,7 +323,7 @@ func TestPanelRules_DoNotTouchPipelineWrites(t *testing.T) {
|
|||||||
acquired.MoveToStateAndDelay(entity.StateTranscribe, &delay)
|
acquired.MoveToStateAndDelay(entity.StateTranscribe, &delay)
|
||||||
require.NoError(t, repo.Save(acquired, "holder"))
|
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.NoError(t, err)
|
||||||
require.NotNil(t, after.DelayTime, "задержка, поставленная шагом, пережила сохранение")
|
require.NotNil(t, after.DelayTime, "задержка, поставленная шагом, пережила сохранение")
|
||||||
|
|
||||||
@@ -333,7 +333,7 @@ func TestPanelRules_DoNotTouchPipelineWrites(t *testing.T) {
|
|||||||
after.Die("attempts exhausted: 6")
|
after.Die("attempts exhausted: 6")
|
||||||
require.NoError(t, repo.Save(after, ""))
|
require.NoError(t, repo.Save(after, ""))
|
||||||
|
|
||||||
dead, err := repo.GetByID(job.Id)
|
dead, err := readJobByID(t, app, job.Id)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
assert.Equal(t, entity.StateDead, dead.State)
|
assert.Equal(t, entity.StateDead, dead.State)
|
||||||
assert.Equal(t, 6, dead.Attempts, "число попыток мёртвой задачи сохранено")
|
assert.Equal(t, 6, dead.Attempts, "число попыток мёртвой задачи сохранено")
|
||||||
@@ -404,9 +404,22 @@ func TestSave_KeepsOwnerEditMadeWhileStepHeldTheJob(t *testing.T) {
|
|||||||
acquired.MoveToState(entity.StateConverted)
|
acquired.MoveToState(entity.StateConverted)
|
||||||
require.NoError(t, repo.Save(acquired, "holder"))
|
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.NoError(t, err)
|
||||||
assert.Equal(t, entity.StateConverted, after.State, "шаг свой результат записал")
|
assert.Equal(t, entity.StateConverted, after.State, "шаг свой результат записал")
|
||||||
require.NotNil(t, after.TgChatId)
|
require.NotNil(t, after.TgChatId)
|
||||||
assert.Equal(t, int64(999999), *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")
|
var ErrDeliveryChannelDown = errors.New("delivery channel is down")
|
||||||
|
|
||||||
|
// ErrOwnerRequired — приём по HTTP дошёл до заведения задачи, а владельца ему не
|
||||||
|
// назвали. Значение сентинельное: нести отказу нечего, а имя учётной записи в
|
||||||
|
// него не кладётся никогда.
|
||||||
|
var ErrOwnerRequired = errors.New("owner is required to accept a record")
|
||||||
|
|
||||||
type JobNotFoundError struct {
|
type JobNotFoundError struct {
|
||||||
State string
|
State string
|
||||||
Message string
|
Message string
|
||||||
|
|||||||
@@ -36,9 +36,15 @@ type FileRepository interface {
|
|||||||
// CreateLocal кладёт рабочую копию в хранилище под именем name и заводит
|
// CreateLocal кладёт рабочую копию в хранилище под именем name и заводит
|
||||||
// запись о файле. Имя задаёт сервис: умолчание хранилища, строящее его из
|
// запись о файле. Имя задаёт сервис: умолчание хранилища, строящее его из
|
||||||
// имени отправителя, не применяется.
|
// имени отправителя, не применяется.
|
||||||
CreateLocal(name string, work WorkFile) (*entity.File, error)
|
//
|
||||||
|
// ownerID — владелец записи, которой файл принадлежит; пустой значит «файл
|
||||||
|
// без владельца», и таков всякий файл записи, принятой ботом. Владелец
|
||||||
|
// лежит своей колонкой, а не выводится через задачу: ссылку на файл в
|
||||||
|
// задаче переставляет каждый шаг конвейера, и исходная копия после
|
||||||
|
// конвертации не связана с задачей ничем.
|
||||||
|
CreateLocal(name string, work WorkFile, ownerID string) (*entity.File, error)
|
||||||
// CreateRemote заводит запись о копии, лежащей во внешнем хранилище.
|
// 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)
|
GetByID(id string) (*entity.File, error)
|
||||||
// Open отдаёт содержимое хранимого файла потоком.
|
// Open отдаёт содержимое хранимого файла потоком.
|
||||||
Open(fileID string) (io.ReadCloser, error)
|
Open(fileID string) (io.ReadCloser, error)
|
||||||
@@ -51,7 +57,19 @@ type TranscriptJobRepository interface {
|
|||||||
// Пустой holder снимает эту условность и в конвейере не употребляется: все
|
// Пустой holder снимает эту условность и в конвейере не употребляется: все
|
||||||
// его шаги получают признак захвата от FindAndAcquire.
|
// его шаги получают признак захвата от FindAndAcquire.
|
||||||
Save(job *entity.TranscribeJob, holder string) error
|
Save(job *entity.TranscribeJob, holder string) error
|
||||||
GetByID(id string) (*entity.TranscribeJob, error)
|
// GetByID отдаёт задачу, только если её владелец — ownerID. Чужая задача,
|
||||||
|
// ничья задача и несуществующая дают одну и ту же ошибку: по разнице
|
||||||
|
// ответов иначе перебирается список заведённых задач.
|
||||||
|
//
|
||||||
|
// Владелец здесь обязателен, и пустой ownerID не совпадает ни с чем —
|
||||||
|
// включая задачи без владельца. Правило записано со стороны спрашивающего:
|
||||||
|
// обязательность, которую держит одна лишь подпись метода, пустую строку
|
||||||
|
// пропускает, и вызывающий без учётной записи получил бы ровно множество
|
||||||
|
// записей бота.
|
||||||
|
//
|
||||||
|
// Второго читающего метода нет намеренно: он выбирался бы по
|
||||||
|
// внимательности вызывающего.
|
||||||
|
GetByID(id, ownerID string) (*entity.TranscribeJob, error)
|
||||||
// FindAndAcquire забирает задачу одним неделимым шагом и увеличивает число
|
// FindAndAcquire забирает задачу одним неделимым шагом и увеличивает число
|
||||||
// её попыток. Работы в состоянии нет — JobNotFoundError.
|
// её попыток. Работы в состоянии нет — JobNotFoundError.
|
||||||
FindAndAcquire(state, acquisitionId string, rottingTime time.Time) (*entity.TranscribeJob, error)
|
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 (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"errors"
|
||||||
"log/slog"
|
"log/slog"
|
||||||
"net/http"
|
"net/http"
|
||||||
"time"
|
"time"
|
||||||
@@ -10,6 +11,7 @@ import (
|
|||||||
"github.com/pocketbase/pocketbase/core"
|
"github.com/pocketbase/pocketbase/core"
|
||||||
"github.com/pocketbase/pocketbase/tools/router"
|
"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/contract"
|
||||||
"git.vakhrushev.me/av/transcriber/internal/entity"
|
"git.vakhrushev.me/av/transcriber/internal/entity"
|
||||||
"git.vakhrushev.me/av/transcriber/internal/service"
|
"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(SessionFromCookie())
|
||||||
api.Bind(apis.RequireAuth())
|
// Коллекция названа поимённо, а не оставлена умолчанию. Без имени проверка
|
||||||
|
// пускает всякую учётную запись хранилища, включая владельца панели, — а
|
||||||
|
// записи в коллекции пользователей у него нет, и владельцем записи он стать
|
||||||
|
// не может. Отказ такому предъявителю обязан наступить здесь, до чтения
|
||||||
|
// тела: позже пришлось бы убирать уже уложенный файл, а уборки файлов
|
||||||
|
// сервис не умеет вовсе.
|
||||||
|
api.Bind(apis.RequireAuth(migrations.UsersCollection))
|
||||||
|
|
||||||
// Умолчание роутера хранилища — 32 МиБ на тело, и оно отсекало бы запись
|
// Умолчание роутера хранилища — 32 МиБ на тело, и оно отсекало бы запись
|
||||||
// раньше обработчика, без строки в журнале приёма. Приём размеру не судья,
|
// раньше обработчика, без строки в журнале приёма. Приём размеру не судья,
|
||||||
@@ -79,7 +87,11 @@ func (h *TranscribeHandler) CreateTranscribeJob(e *core.RequestEvent) error {
|
|||||||
// (журнал запроса, сессия) при этом сохраняются, теряется только отмена.
|
// (журнал запроса, сессия) при этом сохраняются, теряется только отмена.
|
||||||
ctx := context.WithoutCancel(e.Request.Context())
|
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 {
|
if err != nil {
|
||||||
// Второй раз отказ не логируем: приём назван конвенцией логирующей
|
// Второй раз отказ не логируем: приём назван конвенцией логирующей
|
||||||
// границей и уже написал о нём. Транспорт переводит ошибку в ответ.
|
// границей и уже написал о нём. Транспорт переводит ошибку в ответ.
|
||||||
@@ -96,8 +108,22 @@ func (h *TranscribeHandler) CreateTranscribeJob(e *core.RequestEvent) error {
|
|||||||
func (h *TranscribeHandler) GetTranscribeJobStatus(e *core.RequestEvent) error {
|
func (h *TranscribeHandler) GetTranscribeJobStatus(e *core.RequestEvent) error {
|
||||||
jobID := e.Request.PathValue("id")
|
jobID := e.Request.PathValue("id")
|
||||||
|
|
||||||
job, err := h.jobRepo.GetByID(jobID)
|
// Чужая задача, задача без владельца и несуществующая отвечают одним и тем
|
||||||
|
// же: хранилище отдаёт на все три ту же ошибку, а транспорт — тот же код и
|
||||||
|
// то же тело. Различать их наружу нельзя — по разнице ответов перебирается
|
||||||
|
// список заведённых задач.
|
||||||
|
job, err := h.jobRepo.GetByID(jobID, e.Auth.Id)
|
||||||
if err != nil {
|
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"})
|
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)
|
require.NoError(t, err)
|
||||||
defer func() { require.NoError(t, work.Close()) }()
|
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)
|
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))
|
require.NoError(t, env.handler.jobRepo.Create(job))
|
||||||
return job
|
return job
|
||||||
}
|
}
|
||||||
@@ -325,7 +333,7 @@ func TestCreateTranscribeJob_Success(t *testing.T) {
|
|||||||
// отправитель получит идентификатор записи, которой не будет никогда.
|
// отправитель получит идентификатор записи, которой не будет никогда.
|
||||||
require.Equal(t, 1, countJobs(t, env))
|
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)
|
require.NoError(t, err)
|
||||||
assert.Equal(t, entity.StateCreated, job.State)
|
assert.Equal(t, entity.StateCreated, job.State)
|
||||||
require.NotNil(t, job.FileID)
|
require.NotNil(t, job.FileID)
|
||||||
@@ -595,7 +603,7 @@ func TestCreateTranscribeJob_JournalTracesRecord(t *testing.T) {
|
|||||||
var response CreateTranscribeJobResponse
|
var response CreateTranscribeJobResponse
|
||||||
require.NoError(t, json.Unmarshal(w.Body.Bytes(), &response))
|
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.NoError(t, err)
|
||||||
require.NotNil(t, job.FileID)
|
require.NotNil(t, job.FileID)
|
||||||
|
|
||||||
|
|||||||
@@ -9,6 +9,10 @@ import (
|
|||||||
type TranscribeJob struct {
|
type TranscribeJob struct {
|
||||||
Id string
|
Id string
|
||||||
State string
|
State string
|
||||||
|
// OwnerID — учётная запись, от имени которой запись принята. Пуст у записей
|
||||||
|
// из Telegram: связи чата с учётной записью сервис не ведёт. Назначается
|
||||||
|
// один раз, при приёме, и у записи, где он есть, больше не меняется.
|
||||||
|
OwnerID *string
|
||||||
Source string
|
Source string
|
||||||
FileID *string
|
FileID *string
|
||||||
ErrorText *string
|
ErrorText *string
|
||||||
|
|||||||
@@ -26,7 +26,7 @@ type stubJobRepo struct {
|
|||||||
func (r *stubJobRepo) Create(*entity.TranscribeJob) error { return nil }
|
func (r *stubJobRepo) Create(*entity.TranscribeJob) error { return nil }
|
||||||
func (r *stubJobRepo) Save(*entity.TranscribeJob, string) 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("не зовётся этими проверками")
|
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"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"github.com/google/uuid"
|
||||||
"github.com/pocketbase/pocketbase/core"
|
"github.com/pocketbase/pocketbase/core"
|
||||||
"github.com/pocketbase/pocketbase/tools/types"
|
"github.com/pocketbase/pocketbase/tools/types"
|
||||||
"github.com/stretchr/testify/assert"
|
"github.com/stretchr/testify/assert"
|
||||||
@@ -155,7 +156,7 @@ func TestJobDiesAfterAttemptLimit(t *testing.T) {
|
|||||||
var noop *contract.NoopJobError
|
var noop *contract.NoopJobError
|
||||||
require.ErrorAs(t, err, &noop, "мёртвая задача шагу не отдаётся")
|
require.ErrorAs(t, err, &noop, "мёртвая задача шагу не отдаётся")
|
||||||
|
|
||||||
after, err := env.jobRepo.GetByID(job.Id)
|
after, err := readJob(env.app, job.Id)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
assert.Equal(t, entity.StateDead, after.State, "задача видна отбором по состоянию")
|
assert.Equal(t, entity.StateDead, after.State, "задача видна отбором по состоянию")
|
||||||
assert.Greater(t, after.Attempts, maxAttempts, "число попыток сохранено")
|
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)
|
require.NoError(t, err)
|
||||||
|
|
||||||
record, err := env.app.FindRecordById(migrations.JobsCollection, job.Id)
|
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()))
|
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.NoError(t, err)
|
||||||
require.Nil(t, after.AcquisitionID, "захват снят: задача пригодна к повтору")
|
require.Nil(t, after.AcquisitionID, "захват снят: задача пригодна к повтору")
|
||||||
require.NotNil(t, after.DelayTime, "пауза поставлена")
|
require.NotNil(t, after.DelayTime, "пауза поставлена")
|
||||||
@@ -223,7 +224,7 @@ func TestFailedStepSchedulesRetryWithGrowingDelay(t *testing.T) {
|
|||||||
clearDelay(t, env, job.Id)
|
clearDelay(t, env, job.Id)
|
||||||
require.Error(t, env.service.FindAndRunConversionJob(t.Context()))
|
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.NoError(t, err)
|
||||||
require.NotNil(t, after.DelayTime)
|
require.NotNil(t, after.DelayTime)
|
||||||
|
|
||||||
@@ -248,7 +249,7 @@ func TestWorkFileRemovedAfterIntakeFailure(t *testing.T) {
|
|||||||
|
|
||||||
env := newPipelineEnv(t, &failingMetaViewer{}, &failingConverter{})
|
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, "отказ источника метаданных роняет приём")
|
require.Error(t, err, "отказ источника метаданных роняет приём")
|
||||||
|
|
||||||
leftovers, err := filepath.Glob(filepath.Join(tempDir, "transcriber-*"))
|
leftovers, err := filepath.Glob(filepath.Join(tempDir, "transcriber-*"))
|
||||||
@@ -264,7 +265,7 @@ func TestWorkFileRemovedAfterSuccessfulIntake(t *testing.T) {
|
|||||||
|
|
||||||
env := newPipelineEnv(t, &okMetaViewer{}, &failingConverter{})
|
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)
|
require.NoError(t, err)
|
||||||
|
|
||||||
leftovers, err := filepath.Glob(filepath.Join(tempDir, "transcriber-*"))
|
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()))
|
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)
|
require.NoError(t, err)
|
||||||
assert.Equal(t, entity.StateFailed, after.State)
|
assert.Equal(t, entity.StateFailed, after.State)
|
||||||
require.NotNil(t, after.FileID)
|
require.NotNil(t, after.FileID)
|
||||||
@@ -299,7 +300,7 @@ func TestStoredContentSurvivesRoundTrip(t *testing.T) {
|
|||||||
|
|
||||||
content := strings.Repeat("запись ", 1000)
|
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.NoError(t, err)
|
||||||
require.NotNil(t, job.FileID)
|
require.NotNil(t, job.FileID)
|
||||||
|
|
||||||
@@ -322,7 +323,7 @@ func TestStoredContentSurvivesRoundTrip(t *testing.T) {
|
|||||||
func TestLocalizeGivesReadableCopy(t *testing.T) {
|
func TestLocalizeGivesReadableCopy(t *testing.T) {
|
||||||
env := newPipelineEnv(t, &okMetaViewer{}, &failingConverter{})
|
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.NoError(t, err)
|
||||||
require.NotNil(t, job.FileID)
|
require.NotNil(t, job.FileID)
|
||||||
|
|
||||||
@@ -362,3 +363,73 @@ func TestWorkFilesRemovedAfterConversionFailure(t *testing.T) {
|
|||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
assert.Empty(t, leftovers, "ни исходной копии, ни копии под результат не осталось")
|
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.Equal(t, 1, rec.recognizeCalls, "содержимое отдано распознавателю")
|
||||||
assert.NotEmpty(t, rec.lastObjectKey, "ключ объекта назван")
|
assert.NotEmpty(t, rec.lastObjectKey, "ключ объекта назван")
|
||||||
|
|
||||||
after, err := env.jobRepo.GetByID(job.Id)
|
after, err := readJob(env.app, job.Id)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
assert.Equal(t, entity.StateTranscribe, after.State)
|
assert.Equal(t, entity.StateTranscribe, after.State)
|
||||||
require.NotNil(t, after.RecognitionOpID)
|
require.NotNil(t, after.RecognitionOpID)
|
||||||
@@ -122,7 +122,7 @@ func TestTranscribeJobKeepsJobRetryableOnRecognizerFailure(t *testing.T) {
|
|||||||
|
|
||||||
require.Error(t, svc.FindAndRunTranscribeJob(t.Context()))
|
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)
|
require.NoError(t, err)
|
||||||
assert.Equal(t, entity.StateConverted, after.State, "задача осталась на своём шаге")
|
assert.Equal(t, entity.StateConverted, after.State, "задача осталась на своём шаге")
|
||||||
assert.Nil(t, after.AcquisitionID, "захват снят: задача пригодна к повтору")
|
assert.Nil(t, after.AcquisitionID, "захват снят: задача пригодна к повтору")
|
||||||
@@ -153,7 +153,7 @@ func TestCheckJobWaitsWithoutSpendingAttempts(t *testing.T) {
|
|||||||
for i := 0; i < 3; i++ {
|
for i := 0; i < 3; i++ {
|
||||||
require.NoError(t, svc.FindAndRunTranscribeCheckJob(t.Context()))
|
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)
|
require.NoError(t, err)
|
||||||
assert.Equal(t, entity.StateTranscribe, after.State)
|
assert.Equal(t, entity.StateTranscribe, after.State)
|
||||||
assert.Equal(t, 0, after.Attempts, "ожидание операции попытку не тратит")
|
assert.Equal(t, 0, after.Attempts, "ожидание операции попытку не тратит")
|
||||||
@@ -177,7 +177,7 @@ func TestCheckJobFailsJobAndTellsSender(t *testing.T) {
|
|||||||
|
|
||||||
require.NoError(t, svc.FindAndRunTranscribeCheckJob(t.Context()))
|
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)
|
require.NoError(t, err)
|
||||||
assert.Equal(t, entity.StateFailed, after.State)
|
assert.Equal(t, entity.StateFailed, after.State)
|
||||||
|
|
||||||
@@ -199,7 +199,7 @@ func TestCheckJobCompletesAndAnswersOnce(t *testing.T) {
|
|||||||
|
|
||||||
require.NoError(t, svc.FindAndRunTranscribeCheckJob(t.Context()))
|
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)
|
require.NoError(t, err)
|
||||||
assert.Equal(t, entity.StateDone, after.State)
|
assert.Equal(t, entity.StateDone, after.State)
|
||||||
require.NotNil(t, after.TranscriptionText)
|
require.NotNil(t, after.TranscriptionText)
|
||||||
@@ -227,7 +227,7 @@ func TestCheckJobCompletesEmptyTextWithExplanation(t *testing.T) {
|
|||||||
|
|
||||||
require.NoError(t, svc.FindAndRunTranscribeCheckJob(t.Context()))
|
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)
|
require.NoError(t, err)
|
||||||
assert.Equal(t, entity.StateDone, after.State)
|
assert.Equal(t, entity.StateDone, after.State)
|
||||||
|
|
||||||
@@ -261,7 +261,7 @@ func TestCheckJobWritesNothingWhenAcquisitionLost(t *testing.T) {
|
|||||||
var lost *contract.LostAcquisitionError
|
var lost *contract.LostAcquisitionError
|
||||||
require.ErrorAs(t, err, &lost)
|
require.ErrorAs(t, err, &lost)
|
||||||
|
|
||||||
after, err := env.jobRepo.GetByID(job.Id)
|
after, err := readJob(env.app, job.Id)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
assert.Equal(t, entity.StateTranscribe, after.State, "результат не записан")
|
assert.Equal(t, entity.StateTranscribe, after.State, "результат не записан")
|
||||||
assert.Empty(t, env.sender.messages, "отправителю ничего не отправлено")
|
assert.Empty(t, env.sender.messages, "отправителю ничего не отправлено")
|
||||||
|
|||||||
@@ -45,7 +45,7 @@ func TestShutdownDuringConversionKeepsJobRetryable(t *testing.T) {
|
|||||||
require.Error(t, err, "шаг обязан сообщить об обрыве наверх")
|
require.Error(t, err, "шаг обязан сообщить об обрыве наверх")
|
||||||
require.ErrorIs(t, err, context.Canceled, "обрыв узнаётся по смыслу, а не по тексту")
|
require.ErrorIs(t, err, context.Canceled, "обрыв узнаётся по смыслу, а не по тексту")
|
||||||
|
|
||||||
after, err := env.jobRepo.GetByID(job.Id)
|
after, err := readJob(env.app, job.Id)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
|
|
||||||
assert.Equal(t, entity.StateCreated, after.State, "задача осталась на повтор, а не похоронена")
|
assert.Equal(t, entity.StateCreated, after.State, "задача осталась на повтор, а не похоронена")
|
||||||
@@ -72,7 +72,7 @@ func TestShutdownBeforeStepLeavesJobUntouched(t *testing.T) {
|
|||||||
var noop *contract.NoopJobError
|
var noop *contract.NoopJobError
|
||||||
require.ErrorAs(t, err, &noop)
|
require.ErrorAs(t, err, &noop)
|
||||||
|
|
||||||
after, err := env.jobRepo.GetByID(job.Id)
|
after, err := readJob(env.app, job.Id)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
assert.Equal(t, entity.StateCreated, after.State)
|
assert.Equal(t, entity.StateCreated, after.State)
|
||||||
assert.Equal(t, 0, after.Attempts, "захвата не было — попытке взяться неоткуда")
|
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)
|
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{
|
job := &entity.TranscribeJob{
|
||||||
State: entity.StateCreated,
|
State: entity.StateCreated,
|
||||||
Source: entity.SourceApi,
|
Source: entity.SourceApi,
|
||||||
|
OwnerID: &ownerID,
|
||||||
}
|
}
|
||||||
|
|
||||||
return s.createTranscribeJob(ctx, job, file, fileName)
|
return s.createTranscribeJob(ctx, job, file, fileName)
|
||||||
@@ -135,7 +149,7 @@ func (s *TranscribeService) createTranscribeJob(ctx context.Context, job *entity
|
|||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
fileRecord, err := s.fileRepo.CreateLocal(storageFileName, work)
|
fileRecord, err := s.fileRepo.CreateLocal(storageFileName, work, ownerOf(job))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
s.logger.Error("Failed to create file record", "error", err, "file_ext", ext)
|
s.logger.Error("Failed to create file record", "error", err, "file_ext", ext)
|
||||||
return nil, err
|
return nil, err
|
||||||
@@ -284,7 +298,7 @@ func (s *TranscribeService) convertJob(ctx context.Context, job *entity.Transcri
|
|||||||
metrics.OutputFileSizeHistogram.WithLabelValues("ogg").Observe(float64(destSize))
|
metrics.OutputFileSizeHistogram.WithLabelValues("ogg").Observe(float64(destSize))
|
||||||
|
|
||||||
destFileName := fmt.Sprintf("%s%s", uuid.NewString(), ".ogg")
|
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 {
|
if err != nil {
|
||||||
s.logger.Error("Failed to create converted file record", "error", err, "job_id", job.Id)
|
s.logger.Error("Failed to create converted file record", "error", err, "job_id", job.Id)
|
||||||
return err
|
return err
|
||||||
@@ -346,7 +360,7 @@ func (s *TranscribeService) transcribeJob(ctx context.Context, job *entity.Trans
|
|||||||
"job_id", job.Id,
|
"job_id", job.Id,
|
||||||
"operation_id", operationID)
|
"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 {
|
if err != nil {
|
||||||
s.logger.Error("Failed to create S3 file record", "error", err, "job_id", job.Id)
|
s.logger.Error("Failed to create S3 file record", "error", err, "job_id", job.Id)
|
||||||
return err
|
return err
|
||||||
@@ -635,3 +649,14 @@ func (s *TranscribeService) closeWork(work contract.WorkFile) {
|
|||||||
s.logger.Error("Failed to remove work file", "error", err)
|
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, "ответ до отправителя доехал")
|
assert.Equal(t, 1, sender.calls, "ответ до отправителя доехал")
|
||||||
|
|
||||||
after, err := env.jobRepo.GetByID(job.Id)
|
after, err := readJob(env.app, job.Id)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
assert.Equal(t, entity.StateDone, after.State, "задача осталась в достигнутом состоянии")
|
assert.Equal(t, entity.StateDone, after.State, "задача осталась в достигнутом состоянии")
|
||||||
require.NotNil(t, after.TranscriptionText)
|
require.NotNil(t, after.TranscriptionText)
|
||||||
@@ -108,7 +108,7 @@ func TestUndeliveredWithoutChatKeepsJobDone(t *testing.T) {
|
|||||||
|
|
||||||
assert.Equal(t, 0, sender.calls, "до отправителя дело не дошло: адресата нет")
|
assert.Equal(t, 0, sender.calls, "до отправителя дело не дошло: адресата нет")
|
||||||
|
|
||||||
after, err := env.jobRepo.GetByID(job.Id)
|
after, err := readJob(env.app, job.Id)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
assert.Equal(t, entity.StateDone, after.State)
|
assert.Equal(t, entity.StateDone, after.State)
|
||||||
assert.Nil(t, after.ErrorText, "отказ задаче не приписан")
|
assert.Nil(t, after.ErrorText, "отказ задаче не приписан")
|
||||||
@@ -127,7 +127,7 @@ func TestUndeliveredWithoutChatKeepsJobDone(t *testing.T) {
|
|||||||
func TestApiJobDoesNotReachSenderAndLogsNothing(t *testing.T) {
|
func TestApiJobDoesNotReachSenderAndLogsNothing(t *testing.T) {
|
||||||
env := newPipelineEnv(t, &okMetaViewer{}, &failingConverter{})
|
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)
|
require.NoError(t, err)
|
||||||
|
|
||||||
sender := &downSender{}
|
sender := &downSender{}
|
||||||
|
|||||||
@@ -0,0 +1,61 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Имя файла в хранилище в журнал не идёт: оно последняя часть ссылки
|
||||||
|
// `/api/files/...`, и строка журнала вместе с идентификатором записи собрала бы
|
||||||
|
// ссылку целиком. Инвариант проекта, critical.
|
||||||
|
func TestJournalRouteHidesStoredFileName(t *testing.T) {
|
||||||
|
cases := []struct {
|
||||||
|
name string
|
||||||
|
path string
|
||||||
|
want string
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "ссылка на файл теряет имя",
|
||||||
|
path: "/api/files/files/abc123def456ghi/9f1c-3b2a.mp3",
|
||||||
|
want: "/api/files/files/abc123def456ghi/<имя>",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "маршрут остаётся различимым",
|
||||||
|
path: "/api/files/files/abc123def456ghi/запись.ogg",
|
||||||
|
want: "/api/files/files/abc123def456ghi/<имя>",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "прочие пути не трогаются",
|
||||||
|
path: "/api/status/abc123def456ghi",
|
||||||
|
want: "/api/status/abc123def456ghi",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "приём не трогается",
|
||||||
|
path: "/api/audio",
|
||||||
|
want: "/api/audio",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "сам префикс без имени не портится",
|
||||||
|
path: "/api/files/",
|
||||||
|
want: "/api/files/",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, c := range cases {
|
||||||
|
t.Run(c.name, func(t *testing.T) {
|
||||||
|
assert.Equal(t, c.want, journalRoute(c.path))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Отдельно и прямо: имени в готовой строке нет. Проверка судит результат, а не
|
||||||
|
// устройство — переписанная реализация обязана остаться зелёной.
|
||||||
|
func TestJournalRouteDropsNameEntirely(t *testing.T) {
|
||||||
|
const stored = "0f7b8dd3-d1cc-424c.mp3"
|
||||||
|
|
||||||
|
route := journalRoute("/api/files/files/rec0000000000000/" + stored)
|
||||||
|
|
||||||
|
assert.NotContains(t, route, stored, "имя файла в хранилище не доезжает до журнала")
|
||||||
|
assert.Contains(t, route, "rec0000000000000", "идентификатор записи остаётся: по нему прослеживается путь")
|
||||||
|
}
|
||||||
@@ -9,6 +9,7 @@ import (
|
|||||||
"net/http"
|
"net/http"
|
||||||
"os"
|
"os"
|
||||||
"os/signal"
|
"os/signal"
|
||||||
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
"syscall"
|
"syscall"
|
||||||
"time"
|
"time"
|
||||||
@@ -231,7 +232,7 @@ func main() {
|
|||||||
|
|
||||||
logger.Log(e.Request.Context(), level, "Incoming request",
|
logger.Log(e.Request.Context(), level, "Incoming request",
|
||||||
"http.method", e.Request.Method,
|
"http.method", e.Request.Method,
|
||||||
"http.route", e.Request.URL.Path,
|
"http.route", journalRoute(e.Request.URL.Path),
|
||||||
"http.status_code", e.Status(),
|
"http.status_code", e.Status(),
|
||||||
"duration_ms", time.Since(start).Milliseconds(),
|
"duration_ms", time.Since(start).Milliseconds(),
|
||||||
"transport", "http")
|
"transport", "http")
|
||||||
@@ -344,3 +345,30 @@ func main() {
|
|||||||
|
|
||||||
logger.Info("Transcriber service stopped")
|
logger.Info("Transcriber service stopped")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// filesPathPrefix — начало пути, которым хранилище отдаёт файл записи. Последний
|
||||||
|
// сегмент такого пути и есть имя файла в хранилище.
|
||||||
|
const filesPathPrefix = "/api/files/"
|
||||||
|
|
||||||
|
// journalRoute готовит путь запроса к записи в журнал.
|
||||||
|
//
|
||||||
|
// Инвариант проекта запрещает имени файла в хранилище попадать в журнал: имя —
|
||||||
|
// последняя часть ссылки `/api/files/...`, и строка журнала вместе с
|
||||||
|
// идентификатором записи собирала бы ссылку целиком. Слой журнала пишет путь
|
||||||
|
// всякого запроса, поэтому имя срезается здесь — иначе оно уезжало бы в
|
||||||
|
// собранные логи при каждом скачивании записи.
|
||||||
|
//
|
||||||
|
// Срезается только имя: маршрут остаётся различимым, и наблюдаемость от этого не
|
||||||
|
// теряется.
|
||||||
|
func journalRoute(path string) string {
|
||||||
|
if !strings.HasPrefix(path, filesPathPrefix) {
|
||||||
|
return path
|
||||||
|
}
|
||||||
|
|
||||||
|
cut := strings.LastIndex(path, "/")
|
||||||
|
if cut < len(filesPathPrefix) {
|
||||||
|
return path
|
||||||
|
}
|
||||||
|
|
||||||
|
return path[:cut+1] + "<имя>"
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,2 @@
|
|||||||
|
schema: spec-driven
|
||||||
|
created: 2026-08-14
|
||||||
@@ -0,0 +1,197 @@
|
|||||||
|
## Context
|
||||||
|
|
||||||
|
Сессия сегодня отвечает на один вопрос — узнан ли пришедший. На вопрос «чьё он
|
||||||
|
смотрит» не отвечает никто: опрос готовности отдаёт задачу всякому вошедшему по
|
||||||
|
её идентификатору. Так записано и в спеках прямым текстом — обе строки
|
||||||
|
(`access`, преамбула; `intake`, «Опрос готовности задачи») обещают, что сужение
|
||||||
|
придёт отдельной задачей. Эта задача та самая.
|
||||||
|
|
||||||
|
Учётные записи в сервисе уже есть: их заводит вход через внешнего провайдера,
|
||||||
|
заведённый 2026-08-12. Значит владельцем записи есть кому быть.
|
||||||
|
|
||||||
|
Ограничения, из которых собрано решение:
|
||||||
|
|
||||||
|
- **Данные не переносим** — рамки задачи объявляют чистый лист. Значит шагу схемы
|
||||||
|
не нужно ни умолчание для старых записей, ни правило их раздачи.
|
||||||
|
- **Применённый шаг схемы не переписывается** (инвариант, critical). Колонка
|
||||||
|
приезжает новым шагом.
|
||||||
|
- **Колонки очереди правятся в четырёх местах** пакета хранилища (инвариант,
|
||||||
|
major), и компилятор видит два из них.
|
||||||
|
- **Записи из Telegram владельца получить не могут**: связи чата с учётной
|
||||||
|
записью не существует, её заводит `telegram-account-link` следующей задачей.
|
||||||
|
|
||||||
|
## Goals / Non-Goals
|
||||||
|
|
||||||
|
**Goals:**
|
||||||
|
|
||||||
|
- у задачи расшифровки есть владелец, назначенный при приёме и неизменяемый;
|
||||||
|
- чужая задача по её идентификатору неотличима от несуществующей;
|
||||||
|
- конвейер работает по всем записям подряд, владельцем не сужаясь;
|
||||||
|
- колонка владельца не имеет умолчания: запись не достаётся никому по недосмотру.
|
||||||
|
|
||||||
|
**Non-Goals:**
|
||||||
|
|
||||||
|
- совместный доступ, роли, передача записи другому;
|
||||||
|
- перенос записей, заведённых до этого шага;
|
||||||
|
- владелец у записи, пришедшей ботом, — его назначает следующая задача;
|
||||||
|
- список своих записей и удаление: они стоят **на** владельце, но заводятся
|
||||||
|
своими задачами.
|
||||||
|
|
||||||
|
## Decisions
|
||||||
|
|
||||||
|
### Сужение живёт в хранилище, а не в обработчике
|
||||||
|
|
||||||
|
Читающий метод репозитория задач принимает владельца и отдаёт задачу, только
|
||||||
|
если она его. Не «прочитать и сравнить в обработчике»: сравнение, стоящее в
|
||||||
|
вызывающем, повторяется в каждом новом вызывающем, и первый же забывший его
|
||||||
|
открывает чужую запись. Компилятор при смене подписи метода приводит всех
|
||||||
|
вызывающих сам.
|
||||||
|
|
||||||
|
**Отвергнуто: правило доступа коллекции хранилища.** Коллекции хранилища знают
|
||||||
|
правила вида «владелец записи равен предъявителю», и в панели они работают. Наш
|
||||||
|
опрос готовности идёт мимо коллекций — своим обработчиком и своим запросом, — и
|
||||||
|
правило коллекции на этом пути не срабатывает вовсе. Опереться на него значит
|
||||||
|
получить защиту, которой не существует, и не заметить этого: тест, ходящий
|
||||||
|
нашим API, был бы зелёным в обоих случаях.
|
||||||
|
|
||||||
|
**Отвергнуто: второй метод чтения рядом с прежним** (`GetByID` и
|
||||||
|
`GetOwnedByID`). Это второй способ делать одно и то же, и выбирается он по
|
||||||
|
внимательности вызывающего. Метод остаётся один, и владелец у него обязателен.
|
||||||
|
|
||||||
|
### Чужая запись отвечает «не найдено», а не «доступ запрещён»
|
||||||
|
|
||||||
|
Отказ по чужой записи и отказ по несуществующей — один и тот же ответ, `404` с
|
||||||
|
тем же телом. Отдельный код на чужую запись превращает опрос в перебор: по нему
|
||||||
|
считывается, какие идентификаторы заведены, а идентификатор задачи и есть то,
|
||||||
|
что мы прячем.
|
||||||
|
|
||||||
|
Различать их в журнале при этом можно и нужно — журнал читает владелец сервиса,
|
||||||
|
а не тот, кто перебирает.
|
||||||
|
|
||||||
|
### Владелец — связь на учётную запись, а не строка
|
||||||
|
|
||||||
|
Колонка заводится связью с коллекцией пользователей: хранилище само следит за
|
||||||
|
тем, чтобы владельцем стояла существующая учётная запись, а не строка, похожая
|
||||||
|
на её идентификатор.
|
||||||
|
|
||||||
|
**Целостности при удалении учётной записи связь при этом не удерживает, и
|
||||||
|
прежнее обоснование этого решения было неверным.** Проверено по исходникам
|
||||||
|
`pocketbase@v0.39.10`, `core/record_model.go`, `deleteRefRecords`: при выключенном
|
||||||
|
каскадном удалении хранилище **вынимает** идентификатор из поля связи и сохраняет
|
||||||
|
запись без проверок. То есть удаление учётной записи не уносит её задачи — но
|
||||||
|
делает их ничьими, а по правилу этой же задачи ничья запись не достаётся по API
|
||||||
|
никому. Архив человека становится недостижим молча, и восстановить владельца
|
||||||
|
нечем.
|
||||||
|
|
||||||
|
Строка с идентификатором вела бы себя здесь лучше — она пережила бы удаление, —
|
||||||
|
но платила бы тем, что владельцем становится любое значение. Что делать с этим,
|
||||||
|
решает человек на чекпоинте: развилка вынесена в открытые вопросы.
|
||||||
|
|
||||||
|
### Захват читает колонку владельца
|
||||||
|
|
||||||
|
Владелец добавляется во все четыре места правки колонок очереди, включая
|
||||||
|
`acquireColumns` и `acquiredRow`, хотя конвейер владельцем не пользуется.
|
||||||
|
|
||||||
|
Причина в инварианте: колонка, забытая в этой паре, приезжает из захвата нулевой.
|
||||||
|
Сохранение конвейера сегодня кладёт только свои поля и владельца не трогает —
|
||||||
|
значит потери нет; но снимок задачи, в котором владелец всегда пуст, это ловушка
|
||||||
|
для первого же шага, который начнёт сохранять задачу целиком. Чтение колонки
|
||||||
|
стоит одной строки в каждом из четырёх мест и снимает ловушку насовсем.
|
||||||
|
|
||||||
|
### Приём назначает владельца из предъявленной сессии
|
||||||
|
|
||||||
|
Приём по HTTP уже требует сессию и отказывает `401` без неё. Владелец берётся
|
||||||
|
оттуда же, и другого источника у него нет: параметром запроса владелец не
|
||||||
|
задаётся никогда, иначе всякий вошедший заводит запись на чужое имя.
|
||||||
|
|
||||||
|
## Risks / Trade-offs
|
||||||
|
|
||||||
|
- **Колонка обязательна в схеме → приём из Telegram перестаёт вставлять записи.**
|
||||||
|
Критерий приёмки требует обязательности, рамки запрещают назначать владельца
|
||||||
|
записям бота, и вместе это ломает основной сегодня вход. → Развилка вынесена в
|
||||||
|
открытые вопросы и на чекпоинт; ниже разобраны три способа с ценой каждого.
|
||||||
|
- **Записи бота остаются без владельца и потому невидимы в API.** Это желаемое
|
||||||
|
поведение (ответ бот отдаёт сам, в чат), но оно значит, что после
|
||||||
|
`telegram-account-link` записи придётся связать с учётной записью задним
|
||||||
|
числом — а рамки объявляют владельца неизменяемым. → Граница названа здесь и
|
||||||
|
передаётся той задаче: неизменяемость относится к записи, у которой владелец
|
||||||
|
**есть**.
|
||||||
|
- **Тест на две сессии дороже прежних.** Нужны две учётные записи в тестовой
|
||||||
|
базе и две куки. → Заводятся тем же способом, что и в тестах входа.
|
||||||
|
- **Публичный контракт меняется**: `GET /api/status/{id}` на чужую задачу
|
||||||
|
отвечает `404` там, где отдавал содержимое. → Ломка намеренная и объявлена в
|
||||||
|
предложении; форма ответа при этом не трогается.
|
||||||
|
|
||||||
|
## Migration Plan
|
||||||
|
|
||||||
|
Один новый шаг схемы, добавляющий колонку в таблицу задач. Прежние шаги не
|
||||||
|
трогаются. Данные не переносятся: рамки объявляют чистый лист, и записей,
|
||||||
|
заведённых до шага, в боевой базе на момент выкладки быть не должно — это
|
||||||
|
проверяет владелец перед выкладкой, машина такого проверить не может.
|
||||||
|
|
||||||
|
**Отката приложения нет — решение владельца от 2026-08-14.** Прежний образ поверх
|
||||||
|
новой схемы поднялся бы: колонку он не читает, и она ему не мешает. Но и не
|
||||||
|
пишет — записи, принятые по HTTP в таком окне, остались бы без владельца и после
|
||||||
|
возврата вперёд не достались бы создателю уже никогда, молча. Способ выхода из
|
||||||
|
беды поэтому другой: восстановить базу из резервной копии либо выпустить новый
|
||||||
|
релиз вперёд.
|
||||||
|
|
||||||
|
## Open Questions
|
||||||
|
|
||||||
|
**Обязательна ли колонка владельца в схеме?** Решает человек на чекпоинте.
|
||||||
|
Критерий приёмки задачи требует «обязательна и без умолчания», рамки той же
|
||||||
|
задачи запрещают назначать владельца записям бота. Три способа:
|
||||||
|
|
||||||
|
1. **Колонка не обязательна в схеме, обязательность держит приём по HTTP.**
|
||||||
|
Запись из веба без владельца завести нельзя — отказывает приём; запись бота
|
||||||
|
лежит без владельца и по API невидима никому. Цена: «обязательна» держит код,
|
||||||
|
а не схема, и прямое обращение к базе мимо приёма может завести запись без
|
||||||
|
владельца. Критерий приёмки выполняется наполовину и требует переформулировки.
|
||||||
|
**Рекомендую этот.**
|
||||||
|
2. **Колонка обязательна, записи бота получают служебную учётную запись.**
|
||||||
|
Схема держит обязательность целиком. Цена втрое больше, чем казалось:
|
||||||
|
учётная запись, за которой не стоит человек, делает сервис источником учётных
|
||||||
|
записей — а паспорт объявляет управление ими вне цели; завести её штатным
|
||||||
|
путём нельзя вовсе, потому что создание записи разрешено только контексту
|
||||||
|
обмена OIDC, и понадобится ещё один шаг схемы; после
|
||||||
|
`telegram-account-link` записи придётся переназначать, нарушая неизменяемость
|
||||||
|
владельца в первой же следующей задаче. **Способ рекомендую снять с выбора.**
|
||||||
|
3. **Колонка обязательна, связь чата с учётной записью делается здесь же.**
|
||||||
|
Критерий выполняется буквально. Цена: задача вбирает в себя следующую и
|
||||||
|
вырастает примерно вдвое — а `telegram-account-link` заведена отдельной
|
||||||
|
строкой беклога намеренно.
|
||||||
|
|
||||||
|
**Сужается ли по владельцу сам файл записи?** Нашли независимо все три прохода
|
||||||
|
ревью дизайна, и это крупнее прочего. Разграничение, спроектированное выше,
|
||||||
|
закрывает опрос состояния — то есть метаданные задачи, — а само аудио лежит в
|
||||||
|
другой коллекции, у которой владельца нет и по этому плану не появится. Правило
|
||||||
|
просмотра там пускает всякого вошедшего, и знание идентификатора файловой записи
|
||||||
|
по-прежнему равно праву скачать чужую запись. Запись задачи между тем называет
|
||||||
|
затронутой «таблицу задач **и таблицу файлов**», а модель угроз обещает владельца
|
||||||
|
у задачи и файла именно этой задачей.
|
||||||
|
|
||||||
|
Дороже всего не сама дыра, а отметка о закрытии: после мерджа паспорт и модель
|
||||||
|
угроз перепишут разграничение как сделанное, и открытый путь перестанет быть
|
||||||
|
виден. Способы:
|
||||||
|
|
||||||
|
1. **Владелец и у файла, тем же шагом схемы**, правило просмотра сужается им.
|
||||||
|
Задача растёт на колонку, правило и свой тест. Полное закрытие.
|
||||||
|
2. **Обратная ссылка с файла на задачу**, владелец выводится через неё. Дешевле в
|
||||||
|
схеме, дороже в правиле; вдобавок ссылка на файл в задаче **переставляется**
|
||||||
|
каждым шагом конвейера, и исходная копия после конвертации не связана ни с чем.
|
||||||
|
3. **Оставить как есть и записать границу честно** — строкой Non-Goals с адресом
|
||||||
|
задачи-преемника, и не переписывать паспорт с моделью угроз словом «закрыто».
|
||||||
|
Дешевле всего сегодня, но разграничение остаётся половинчатым.
|
||||||
|
|
||||||
|
**Что делать с записями удалённого пользователя?** Связь при выключенном каскаде
|
||||||
|
снимает ссылку — записи остаются, но становятся ничьими и недостижимыми по API
|
||||||
|
навсегда. Способы: запретить удаление учётной записи, пока у неё есть записи;
|
||||||
|
держать рядом со связью неизменяемый снимок идентификатора; признать потерю
|
||||||
|
ценой и записать её. Сегодня удаления пользователей в сервисе нет вовсе, так что
|
||||||
|
третий способ ничего не ломает сейчас — но он же превращает архив в то, что
|
||||||
|
теряется одной кнопкой в панели.
|
||||||
|
|
||||||
|
**Подтверждение ломки публичного контракта.** `GET /api/status/{id}` на чужую
|
||||||
|
задачу начнёт отвечать `404` там, где отдавал содержимое. Проект относит
|
||||||
|
публичный контракт HTTP API к необратимому и требует спрашивать человека всегда —
|
||||||
|
здесь и спрашивается.
|
||||||
@@ -0,0 +1,61 @@
|
|||||||
|
## Why
|
||||||
|
|
||||||
|
Сегодня знание идентификатора задачи и есть право её читать: всякий вошедший
|
||||||
|
видит любую расшифровку по её идентификатору — свою, соседа, чью угодно. Паспорт
|
||||||
|
обещает приглашённому пользователю обратное: «каждый видит только свои записи».
|
||||||
|
|
||||||
|
Разграничение стоит первым в очереди не само по себе. На владельце записи стоят
|
||||||
|
список записей, дедупликация, удаление, учёт расхода и квота — всё, что проект
|
||||||
|
собирается делать дальше. Заводить их поверх общей кучи записей значит переделать
|
||||||
|
каждое из них потом.
|
||||||
|
|
||||||
|
## What Changes
|
||||||
|
|
||||||
|
- У записи появляется **владелец** — учётная запись, от имени которой её
|
||||||
|
завели. Назначается он один раз, при приёме, и больше не меняется.
|
||||||
|
- **Опрос готовности отдаёт только свои записи.** Чужая запись по её
|
||||||
|
идентификатору отвечает «не найдено» — тем же ответом, что и несуществующая.
|
||||||
|
Отдельного «доступ запрещён» нет намеренно: по нему перебирается список
|
||||||
|
заведённых задач.
|
||||||
|
- **Приём по HTTP заводит владельца** из предъявленной сессии. Приём без сессии
|
||||||
|
отказывал и раньше, и это не меняется.
|
||||||
|
- **Конвейер владельцем не сужается**: расшифровка идёт для всех записей подряд,
|
||||||
|
как и прежде. Владелец решает, кому запись показывать, а не кому её считать.
|
||||||
|
- **BREAKING** для содержимого базы: колонка владельца добавляется шагом схемы, и
|
||||||
|
записи, заведённые до него, владельца не получают. Данные не переносим —
|
||||||
|
проект заводится с чистого листа, так решено рамками задачи.
|
||||||
|
|
||||||
|
Записи, пришедшие ботом, владельца здесь не получают: связи чата Telegram с
|
||||||
|
учётной записью приложения ещё нет, её заводит следующая задача. Что это значит
|
||||||
|
для обязательности колонки — развилка, вынесенная в design.md и на чекпоинт.
|
||||||
|
|
||||||
|
## Capabilities
|
||||||
|
|
||||||
|
### New Capabilities
|
||||||
|
|
||||||
|
Новых нет: разграничение доступа — это поведение уже заведённой `access`, а не
|
||||||
|
новый домен.
|
||||||
|
|
||||||
|
### Modified Capabilities
|
||||||
|
|
||||||
|
- `access`: появляется владелец записи и правило «чужая запись неотличима от
|
||||||
|
несуществующей». Сегодня спека прямо говорит обратное — «разграничения записей
|
||||||
|
по владельцу здесь нет».
|
||||||
|
- `intake`: приём назначает владельца принятой записи, опрос готовности сужается
|
||||||
|
владельцем. Обе строки спеки, обещающие обратное, снимаются.
|
||||||
|
- `pipeline`: записывается то, что до сих пор было умолчанием, — выборка задачи
|
||||||
|
воркером владельцем не сужается.
|
||||||
|
- `storage`: у задачи в схеме появляется колонка владельца, и заводится она без
|
||||||
|
умолчания.
|
||||||
|
|
||||||
|
## Impact
|
||||||
|
|
||||||
|
- схема хранилища: новый шаг с колонкой владельца в таблице задач; `docs/database.md`;
|
||||||
|
- `internal/entity`, `internal/contract`: у задачи появляется владелец, чтение
|
||||||
|
сужается им;
|
||||||
|
- `internal/adapter/repo/pocketbase`: четыре места правки колонок очереди —
|
||||||
|
`applyToRecord`, `recordToJob`, `acquireColumns`, `acquiredRow`;
|
||||||
|
- `internal/service`: оба метода заведения задачи;
|
||||||
|
- `internal/controller/http`: опрос готовности и приём;
|
||||||
|
- публичный контракт `GET /api/status/{id}`: чужая запись начинает отвечать
|
||||||
|
`404` там, где прежде отдавала содержимое. Форма ответа не меняется.
|
||||||
@@ -0,0 +1,204 @@
|
|||||||
|
# Ревью изменения `record-ownership` — финальный триаж
|
||||||
|
|
||||||
|
## Сводка
|
||||||
|
|
||||||
|
- **Размер:** среднее, пограничное с крупным. **Сложность:** незнакомое
|
||||||
|
(`docs/review.md`, «Триггеры метки»: вход через OIDC и разграничение доступа до
|
||||||
|
начала работы назвать нельзя). **Метка:** `large`. **Режим:** по графу.
|
||||||
|
- **Изменение не закоммичено**, судилось рабочее дерево (19 изменённых файлов,
|
||||||
|
5 новых).
|
||||||
|
- **Гейт зелёный**, проверено независимо от прохода `autotests`: на чистой копии
|
||||||
|
дерева `go build`, `go vet`, `go test ./... -count=1` — все пакеты `ok`.
|
||||||
|
- **Сигнал о заниженной метке:** `review-code` метку проверил, занижением не
|
||||||
|
считает. `review-basics` не запускался, поэтому второго независимого голоса
|
||||||
|
нет. Возражений против `large` не подал никто.
|
||||||
|
- **Находок на входе:** 26 (autotests 1, specs 5, code 3, adversary 6, ops 4,
|
||||||
|
architecture 7) плюс 3 наблюдения. **Осталось:** 7 в основном списке,
|
||||||
|
4 понижены в гипотезы, 3 в promote.
|
||||||
|
|
||||||
|
### План разметки задачи с исходом по каждой теме
|
||||||
|
|
||||||
|
| Тема | Дом | Глубина | Кто закрывает | Исход |
|
||||||
|
|---|---|---|---|---|
|
||||||
|
| requirements | дельты + `openspec/specs/{access,intake,pipeline,storage}` | разбор | `specs` | закрыта, 5 находок |
|
||||||
|
| autotests | `CLAUDE.md` «Гейт» | — | `autotests` | закрыта, 1 находка |
|
||||||
|
| conventions | `docs/conventions/` | разбор | `code` | закрыта, 3 находки; конвенционных 0 из 4 |
|
||||||
|
| architecture | `docs/architecture.md` + `passport.md` | доказательство | `architecture` | закрыта, 7 находок |
|
||||||
|
| security | `docs/security.md` | доказательство | `adversary` | закрыта, 3 пути + 3 свойства |
|
||||||
|
| operations | `docs/architecture.md` «Эксплуатация» + `database.md` | доказательство | `ops` | закрыта, 4 находки |
|
||||||
|
|
||||||
|
Тем без отчёта нет. Тем без дома нет.
|
||||||
|
|
||||||
|
## Блокирует мердж
|
||||||
|
|
||||||
|
### 1. Владелец панели видит на отказе удаления чужую подсказку и по ней идёт сносить архив руками
|
||||||
|
|
||||||
|
- Файл: `internal/adapter/repo/pocketbase/owner_guard.go:42-45`
|
||||||
|
- Severity: major. Confidence: high. Действие: **инлайн**
|
||||||
|
- Оракул: прогон через собранный роутер. Удаление учётной записи с задачей даёт
|
||||||
|
`400 {"message":"Failed to delete record. Make sure that the record is not part
|
||||||
|
of a required relation reference."}` — текст стража не доезжает вовсе
|
||||||
|
(`apis/record_crud.go` и `firstApiError` подменяют неклассифицированную ошибку
|
||||||
|
своей). Починка проверена тем же прогоном: `router.NewBadRequestError` доезжает
|
||||||
|
дословно.
|
||||||
|
- Последствие: требование дельты `storage` «Отказ MUST называть причину владельцу
|
||||||
|
панели» не выполнено. Подсказка библиотеки **ведущая**: единственная
|
||||||
|
обязательная связь у задачи — `file`, и владелец панели, поверив ей, пойдёт
|
||||||
|
удалять задачи и файлы руками. Это ровно то необратимое удаление архива,
|
||||||
|
которое страж и заведён предотвращать.
|
||||||
|
- Найдено проходами: `specs`, `code`, `adversary` (одна причина, три
|
||||||
|
формулировки).
|
||||||
|
|
||||||
|
### 2. Право на архив снимается удалением учётной записи: страж считает задачи и не считает файлы, а вешается мимо сборки хранилища
|
||||||
|
|
||||||
|
- Файл: `internal/adapter/repo/pocketbase/owner_guard.go:28-49`, `main.go:208-213`
|
||||||
|
- Severity: major. Confidence: high. Действие: **развилка**
|
||||||
|
- Оракул: два прогона через роутер. **Без стража** — окружение `setupTestEnv`
|
||||||
|
ставит `BindPanelRules`, но `GuardOwnerDeletion` не ставит: удаление своей
|
||||||
|
учётной записи собственной сессией даёт `204`, у задачи и у файла владелец
|
||||||
|
снимается. **Со стражем** — учётная запись, у которой есть файлы и нет задач,
|
||||||
|
удаляется штатно, владелец файла снимается.
|
||||||
|
- Последствие: `users.DeleteRule` умолчанием библиотеки равен
|
||||||
|
`id = @request.auth.id` — вошедший сносит себя сам, и это публичная поверхность,
|
||||||
|
а не только панель. Единственная защита архива — одна строка в `main.go`, и она
|
||||||
|
покрывает одну коллекцию из двух. Потеря необратима: прежнего владельца не
|
||||||
|
остаётся нигде.
|
||||||
|
- Найдено проходами: `adversary`, `specs`, `architecture`.
|
||||||
|
|
||||||
|
### 3. Канон продолжит утверждать, что разграничения по владельцу нет
|
||||||
|
|
||||||
|
- Файл: `openspec/specs/storage/spec.md:109-111`, `docs/architecture.md:38`,
|
||||||
|
`docs/review.md:120-126`
|
||||||
|
- Severity: major. Confidence: high. Действие: **инлайн**
|
||||||
|
- Оракул: дословный текст. Действующая спека `storage` держит «Сужения по
|
||||||
|
владельцу здесь нет — его заводит отдельная задача»; дельта заводит сужение
|
||||||
|
секцией `## ADDED`, не сняв это. `docs/review.md:120` держит запись в разделе
|
||||||
|
«Типовые ложноположительные» — прямое указание будущему проходу выбросить
|
||||||
|
такую находку.
|
||||||
|
- Последствие: следующая задача вправе вернуть правило «всякий узнанный» и снова
|
||||||
|
открыть чужое аудио, а ревью эту регрессию выбросит не глядя.
|
||||||
|
- Найдено проходами: `specs` (две находки), `architecture`.
|
||||||
|
|
||||||
|
## Стоит исправить сейчас
|
||||||
|
|
||||||
|
### 4. Сценарий «Чужой файл не отдаётся» нормирует механизм, которого нет
|
||||||
|
|
||||||
|
- Файл: дельта `storage`, `internal/controller/http/ownership_test.go:180-206`
|
||||||
|
- Severity: major. Confidence: high. Действие: **инлайн**
|
||||||
|
- Оракул: прогон. Чужой просит токен файла — `200` и валидный токен: токен
|
||||||
|
выдаётся **на предъявителя**, а не на файл. Он же идёт по настоящей ссылке
|
||||||
|
`GET /api/files/files/{recordId}/{name}?token=…` — `404`; владелец — `200` и
|
||||||
|
содержимое.
|
||||||
|
- Последствие: спека обязывает к отказу, которого не будет никогда. Единственный
|
||||||
|
механизм, закрывающий чужое аудио, сквозной проверки не имеет: снятие правила
|
||||||
|
просмотра гейт не покраснит.
|
||||||
|
- Найдено проходом: `specs`.
|
||||||
|
|
||||||
|
### 5. Сбой хранилища приходит отправителю как «задачи нет» и не оставляет строки в журнале
|
||||||
|
|
||||||
|
- Файл: `internal/adapter/repo/pocketbase/transcript_job_repo.go:96-111`,
|
||||||
|
`internal/controller/http/transcribe.go:107-118`
|
||||||
|
- Severity: minor. Confidence: high. Действие: **инлайн**
|
||||||
|
- Оракул: прогон. Запрос недостижимой задачи — `404`, прибавка к журналу пустая.
|
||||||
|
Обработчик кладёт в `404` **любую** ошибку, включая отказ базы.
|
||||||
|
- Последствие: `design.md` обещает «различать в журнале» — не реализовано.
|
||||||
|
Отправитель на аварию хранилища получает «записи нет», владелец сервиса об
|
||||||
|
аварии не узнаёт ниоткуда.
|
||||||
|
- Найдено проходами: `code`, `ops`.
|
||||||
|
|
||||||
|
### 6. Окно отката образа заводит записи без владельца — навсегда недостижимые создателю и молча
|
||||||
|
|
||||||
|
- Файл: `internal/adapter/repo/pocketbase/migrations/202608140001_record_owner.go`,
|
||||||
|
`internal/service/transcribe.go:581-584`
|
||||||
|
- Severity: minor (ущерб крупный, наступает только при откате). Confidence: high.
|
||||||
|
Действие: **развилка**
|
||||||
|
- Оракул: прогон, воспроизводящий состояние окна отката: создатель просит
|
||||||
|
состояние — `404`, журнал пуст. `send()` для источника, отличного от Telegram,
|
||||||
|
возвращает `nil` без отправки — иных каналов доставки у записи из API нет.
|
||||||
|
- Последствие: шаг схемы применён и не откатывается вместе с образом; прежний
|
||||||
|
бинарь колонку не пишет.
|
||||||
|
|
||||||
|
### 7. Имя файла в хранилище уезжает в журнал контейнера при каждом скачивании
|
||||||
|
|
||||||
|
- Файл: `main.go:236-240`
|
||||||
|
- Severity: major. Confidence: high. Действие: **развилка**
|
||||||
|
- Оракул: `CLAUDE.md`, «Инварианты», дословно. Форма маршрута подтверждена
|
||||||
|
прогоном: скачивание идёт по `GET /api/files/files/{recordId}/{name}`, а слой
|
||||||
|
журнала пишет `URL.Path` целиком.
|
||||||
|
- Последствие: инвариант нарушен буквально, второй случай того же класса в
|
||||||
|
журнале проекта. До `critical` не поднято: названная инвариантом цена — «строка
|
||||||
|
журнала стала бы бессрочным ключом» — этой же задачей снимается. Находка **вне
|
||||||
|
дельты**.
|
||||||
|
|
||||||
|
## Гипотезы без доказательства
|
||||||
|
|
||||||
|
- **Счёт задач по `owner` идёт полным сканом, индекса нет** (`ops`). Замер: 42 мс
|
||||||
|
против 0,07 мс на 150 тыс. строк. Понижено: объёма, на котором снят замер, у
|
||||||
|
проекта нет.
|
||||||
|
- **Гонка «удаление учётной записи против приёма»** — не воспроизведена.
|
||||||
|
- **Разница во времени ответа на свою и чужую запись** — замера нет.
|
||||||
|
- **`users.UpdateRule` открыт правке своей записи** — построенного пути нет.
|
||||||
|
- **Ветка отказа `CountRecords` не покрыта** — поведение fail-closed; после
|
||||||
|
починки находки 1 покроется тем же тестом.
|
||||||
|
- **Неизменяемость владельца механизмом не держится** — панель правит `owner`
|
||||||
|
свободно, но панель в модели угроз доверена.
|
||||||
|
|
||||||
|
## Promote candidates
|
||||||
|
|
||||||
|
- **Именование колонок-связей.** `owner` — третий смысл «владельца» в проекте при
|
||||||
|
живом прецеденте `location`/`storage`. Правило именования стоит завести в
|
||||||
|
`docs/conventions/`.
|
||||||
|
- **Одно отображение записи в задачу — и в тестах тоже.** `readJob` в
|
||||||
|
`internal/service/pipeline_test.go` — второе, неполное отображение. Кандидат в
|
||||||
|
расширение инварианта либо в правило `archrules`.
|
||||||
|
- **Индекс на колонке, по которой ходит счёт внутри транзакции удаления.**
|
||||||
|
Кандидат в `docs/database.md`.
|
||||||
|
|
||||||
|
## Границы покрытия
|
||||||
|
|
||||||
|
**Что запускалось.** Метка `large`, режим по графу: `specs`, `autotests`, `code`,
|
||||||
|
`architecture`, `adversary`, `ops` — шесть именных проходов, все вернули отчёт.
|
||||||
|
|
||||||
|
**Что не запускалось и почему.** `review-basics` — план сказал, что своих тем
|
||||||
|
проекта нет и все темы разобраны именными проходами. Следствие: у сигнала о
|
||||||
|
заниженной метке остался один голос.
|
||||||
|
|
||||||
|
**Чего проходы не могли проверить в принципе.** Живой откат образа прежним
|
||||||
|
бинарём; поведение под реальным потоком; настоящая панель как интерфейс;
|
||||||
|
настоящие Telegram, SpeechKit и Object Storage.
|
||||||
|
|
||||||
|
**Что осталось на человеке** (`docs/review.md`, «Недоступно проверке»): поведение
|
||||||
|
внешних сервисов под нагрузкой, реальный профиль нагрузки, стойкость `ffmpeg` к
|
||||||
|
вредоносному входу, поведение настоящей Authelia, поведение браузера с куками.
|
||||||
|
Сознательно перестали проверять: разбор вывода настоящего `ffprobe`, работу с
|
||||||
|
настоящими внешними собеседниками.
|
||||||
|
|
||||||
|
**Сработавшие потолки.** Ни один проход не сообщил своего потолка и того, что
|
||||||
|
осталось за срезом. Контракт требует этого от каждого — **это находка о прогоне**,
|
||||||
|
а не о коде.
|
||||||
|
|
||||||
|
**Что не влезло в потолок 7** (ничего не выброшено молча):
|
||||||
|
|
||||||
|
- `RequireAuth(users)` висит на всей группе `/api`, поэтому и опрос отвечает
|
||||||
|
`403`; дельта `intake` нормирует `403` только у приёма;
|
||||||
|
- `design.md` прямым текстом отвергает правила коллекций, а реализация ими
|
||||||
|
пользуется; «Единые точки проекта» о владельце не знают;
|
||||||
|
- `ErrOwnerRequired` заведена без потребителя;
|
||||||
|
- три вопроса, объявленных решаемыми человеком, возведены в норму реализацией —
|
||||||
|
частично снято тем, что человек четыре решения на чекпоинте принял;
|
||||||
|
- `RelationField.ColumnType` даёт `TEXT DEFAULT '' NOT NULL`: «умолчания нет»
|
||||||
|
верно по замыслу, но не буквально.
|
||||||
|
|
||||||
|
**Чего в конвейере нет вовсе:**
|
||||||
|
|
||||||
|
1. **Решения проекта не сверялись** — `docs/adr/` процессный документ, прогон его
|
||||||
|
не открывает. Расхождение ловит `av-dev:doc-healthcheck`.
|
||||||
|
2. **Записанные наблюдения не использовались** — `docs/research/` не открывался.
|
||||||
|
3. **Поимённая сверка с руководствами по стилю Go** не задавалась ни одним
|
||||||
|
проходом.
|
||||||
|
4. **Альтернативной реализации, с которой можно сдиффить решения, нет.** Для
|
||||||
|
изменения со сложностью «незнакомое» это самый дорогой пробел прогона.
|
||||||
|
|
||||||
|
Формулировка «критичных проблем не обнаружено» не употребляется: `critical` в
|
||||||
|
отчёте нет потому, что ни одна находка не собрала оракула на этот уровень, а не
|
||||||
|
потому, что путей туда нет.
|
||||||
@@ -0,0 +1,61 @@
|
|||||||
|
## ADDED Requirements
|
||||||
|
|
||||||
|
### Requirement: У записи есть владелец, и чужую ей не отдают
|
||||||
|
|
||||||
|
Сервис SHALL заводить у каждой записи, принятой **по HTTP**, — владельца, то
|
||||||
|
есть учётную запись, от имени которой запись принята, — и MUST отдавать данные
|
||||||
|
такой записи только её владельцу. Владелец назначается один раз, при приёме, и
|
||||||
|
MUST не меняться у записи, у которой владелец есть: совместного доступа, ролей и
|
||||||
|
передачи записи другому сервис не знает. Оговорка не случайна — назначить
|
||||||
|
владельца записи, у которой его нет, вправе задача, заводящая связь чата
|
||||||
|
Telegram с учётной записью.
|
||||||
|
|
||||||
|
Владелец MUST браться из предъявленной сессии и ниоткуда больше. Владелец,
|
||||||
|
пришедший полем запроса, дал бы всякому вошедшему право завести запись на чужое
|
||||||
|
имя.
|
||||||
|
|
||||||
|
Обращение к чужой записи MUST быть неотличимо от обращения к несуществующей.
|
||||||
|
Отдельный отказ «доступ запрещён» превращает опрос в перебор — по разнице
|
||||||
|
ответов считывается, какие записи заведены, а идентификатор записи и есть то,
|
||||||
|
что разграничение прячет. Каким именно ответом это выражено, нормирует
|
||||||
|
capability `intake`: там живёт адрес опроса, и держатель нормы обязан быть один.
|
||||||
|
|
||||||
|
Пустой владелец MUST не совпадать ни с одной записью — ни со своей, ни с чужой,
|
||||||
|
ни с ничьей. Правило записано со стороны **спрашивающего**, а не со стороны
|
||||||
|
записи: обязательность владельца, которую держит одна лишь подпись метода, пустую
|
||||||
|
строку пропускает, и первый же вызывающий без учётной записи получил бы ровно
|
||||||
|
множество записей без владельца, то есть все записи бота.
|
||||||
|
|
||||||
|
Записи, принятые из Telegram, владельца не имеют: связи чата с учётной записью
|
||||||
|
приложения сервис не ведёт. Такая запись MUST не доставаться по API никому —
|
||||||
|
ответ на неё тот же, что и на несуществующую, — а её расшифровка уезжает
|
||||||
|
отправителю в чат, как и прежде.
|
||||||
|
|
||||||
|
#### Scenario: Своя запись доступна
|
||||||
|
|
||||||
|
- **GIVEN** человек вошёл и принял запись
|
||||||
|
- **WHEN** он спрашивает состояние этой записи своей сессией
|
||||||
|
- **THEN** ответ несёт состояние записи
|
||||||
|
|
||||||
|
#### Scenario: Чужая запись неотличима от несуществующей
|
||||||
|
|
||||||
|
- **GIVEN** запись принята одним вошедшим
|
||||||
|
- **WHEN** её состояние спрашивает другой вошедший
|
||||||
|
- **THEN** ответ тот же, что и на неизвестный идентификатор, — и кодом, и телом
|
||||||
|
|
||||||
|
#### Scenario: Владельца не задают запросом
|
||||||
|
|
||||||
|
- **WHEN** запрос на приём записи несёт своё значение владельца
|
||||||
|
- **THEN** владельцем принятой записи становится предъявитель сессии
|
||||||
|
|
||||||
|
#### Scenario: Запись из Telegram не достаётся по API
|
||||||
|
|
||||||
|
- **GIVEN** запись принята ботом
|
||||||
|
- **WHEN** её состояние спрашивает вошедший человек
|
||||||
|
- **THEN** ответ тот же, что и на неизвестный идентификатор
|
||||||
|
|
||||||
|
#### Scenario: Пустой владелец не открывает ничего
|
||||||
|
|
||||||
|
- **GIVEN** заведены три задачи: своя, чужая и принятая ботом
|
||||||
|
- **WHEN** состояние каждой спрашивают с пустым владельцем
|
||||||
|
- **THEN** ответ на все три тот же, что и на неизвестный идентификатор
|
||||||
@@ -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` и сообщение о ненайденной задаче
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
## ADDED Requirements
|
||||||
|
|
||||||
|
### Requirement: Выборка воркера владельцем не сужается
|
||||||
|
|
||||||
|
Воркер SHALL брать задачи всех владельцев подряд и MUST не учитывать владельца
|
||||||
|
при выборе очередной задачи. Задача без владельца — принятая ботом — MUST
|
||||||
|
обрабатываться наравне с прочими.
|
||||||
|
|
||||||
|
Владелец решает, кому запись показывать, а не кому её считать. Сужение выборки
|
||||||
|
владельцем остановило бы расшифровку записей бота вовсе, а записи остальных
|
||||||
|
поставило бы в зависимость от того, кто первым завёл учётную запись.
|
||||||
|
|
||||||
|
Владелец задачи MUST переживать работу конвейера: шаг, сохраняющий свой
|
||||||
|
результат, владельца не трогает и не затирает.
|
||||||
|
|
||||||
|
#### Scenario: Задачи двух владельцев проходят одним воркером
|
||||||
|
|
||||||
|
- **GIVEN** заведены задачи двух разных владельцев в одном состоянии
|
||||||
|
- **WHEN** воркер забирает задачи этого состояния
|
||||||
|
- **THEN** ему достаются обе, в порядке заведения
|
||||||
|
|
||||||
|
#### Scenario: Задача без владельца обрабатывается
|
||||||
|
|
||||||
|
- **GIVEN** заведена задача, принятая ботом, — без владельца
|
||||||
|
- **WHEN** воркер забирает задачи её состояния
|
||||||
|
- **THEN** она достаётся ему наравне с прочими
|
||||||
|
|
||||||
|
#### Scenario: Шаг конвейера владельца не затирает
|
||||||
|
|
||||||
|
- **GIVEN** задача с владельцем прошла шаг конвейера
|
||||||
|
- **WHEN** шаг сохраняет свой результат
|
||||||
|
- **THEN** владелец задачи остаётся прежним
|
||||||
@@ -0,0 +1,199 @@
|
|||||||
|
## ADDED Requirements
|
||||||
|
|
||||||
|
### Requirement: Владелец задачи лежит связью с учётной записью
|
||||||
|
|
||||||
|
Хранилище SHALL держать владельца задачи расшифровки отдельной колонкой — связью
|
||||||
|
с учётной записью, — и эта колонка MUST не иметь умолчания: запись, чей владелец
|
||||||
|
не назван, не достаётся никому по недосмотру схемы.
|
||||||
|
|
||||||
|
Колонка MUST допускать пустое значение, и это решение с названной ценой: записи,
|
||||||
|
принятые ботом, владельца не имеют, потому что связи чата Telegram с учётной
|
||||||
|
записью сервис не ведёт. Обязательность для приёма по HTTP держит сама
|
||||||
|
capability `intake`, а не схема.
|
||||||
|
|
||||||
|
Колонка приезжает **новым шагом схемы**: применённый шаг не переписывается.
|
||||||
|
Записей, заведённых до этого шага, сервис не переносит — проект заводится с
|
||||||
|
чистого листа.
|
||||||
|
|
||||||
|
#### Scenario: Колонка появляется на пустой базе
|
||||||
|
|
||||||
|
- **WHEN** сервис поднимается на чистом каталоге данных
|
||||||
|
- **THEN** у таблицы задач есть колонка владельца
|
||||||
|
- **AND** умолчания у неё нет
|
||||||
|
|
||||||
|
### Requirement: Файл записи сужается владельцем наравне с задачей
|
||||||
|
|
||||||
|
Хранилище SHALL держать владельца и у файла записи — той же связью с учётной
|
||||||
|
записью, тем же шагом схемы, — и правило просмотра файлов MUST пускать к файлу
|
||||||
|
только его владельца. Прежнее правило пускало всякого узнанного, и знание
|
||||||
|
идентификатора файловой записи равнялось праву скачать чужое аудио.
|
||||||
|
|
||||||
|
Без этого требования разграничение закрывает метаданные задачи и оставляет
|
||||||
|
открытым содержимое — то самое, что оно и заведено прятать. Хуже самой дыры была
|
||||||
|
бы отметка о закрытии: паспорт и модель угроз называют исполнителем этой работы
|
||||||
|
именно эту задачу, и слово «закрыто» скрыло бы открытый путь.
|
||||||
|
|
||||||
|
Владелец файла MUST назначаться там же, где владелец задачи, — при приёме, из
|
||||||
|
предъявленной сессии, — и MUST оставаться пустым у файлов, заведённых конвейером
|
||||||
|
для записи без владельца.
|
||||||
|
|
||||||
|
Ссылка на файл в задаче переставляется каждым шагом конвейера, поэтому владелец
|
||||||
|
файла MUST лежать своей колонкой, а не выводиться через задачу: исходная копия
|
||||||
|
после конвертации не связана с задачей ничем.
|
||||||
|
|
||||||
|
Отказ наступает **на переходе по ссылке**, а не на выдаче токена файла: токен
|
||||||
|
хранилище выдаёт на предъявителя, а не на файл, и о файле при выдаче не
|
||||||
|
спрашивает вовсе. Требовать отказа при выдаче значит требовать механизма,
|
||||||
|
которого нет, — а проверка, написанная под такое требование, зеленела бы, не
|
||||||
|
касаясь пути, по которому аудио и уходит.
|
||||||
|
|
||||||
|
#### Scenario: Чужой файл не отдаётся
|
||||||
|
|
||||||
|
- **GIVEN** запись принята одним вошедшим
|
||||||
|
- **WHEN** другой вошедший идёт по ссылке на файл этой записи со своим токеном
|
||||||
|
- **THEN** содержимого он не получает
|
||||||
|
|
||||||
|
#### Scenario: Свой файл отдаётся
|
||||||
|
|
||||||
|
- **GIVEN** человек принял запись
|
||||||
|
- **WHEN** он идёт по ссылке на файл своей записи со своим токеном
|
||||||
|
- **THEN** содержимое отдаётся
|
||||||
|
|
||||||
|
#### Scenario: Файл записи из Telegram не отдаётся по API
|
||||||
|
|
||||||
|
- **GIVEN** запись принята ботом, и владельца у неё нет
|
||||||
|
- **WHEN** вошедший человек идёт по ссылке на её файл со своим токеном
|
||||||
|
- **THEN** содержимого он не получает
|
||||||
|
|
||||||
|
### Requirement: Учётная запись с записями не удаляется
|
||||||
|
|
||||||
|
Хранилище SHALL отвергать удаление учётной записи, у которой остались задачи
|
||||||
|
расшифровки **либо файлы**. Отказ MUST называть причину, и MUST доезжать до
|
||||||
|
спрашивающего: хранилище пропускает наружу только свою ошибку роутера, а всякую
|
||||||
|
другую подменяет сообщением про обязательную связь — подсказкой, по которой
|
||||||
|
владелец панели пойдёт удалять записи руками.
|
||||||
|
|
||||||
|
Считаются обе коллекции с владельцем. Файл переживает свою задачу: шаг конвейера
|
||||||
|
заводит его до сохранения задачи, и потерянный захват оставляет файл с владельцем
|
||||||
|
и без ссылки.
|
||||||
|
|
||||||
|
Запрет MUST ставить сама сборка хранилища, а не вызывающий: сборка, забывшая его
|
||||||
|
позвать, теряет защиту молча — и теряла, пока запрет вешался отдельной строкой
|
||||||
|
запуска, а окружение проверок его не ставило вовсе.
|
||||||
|
|
||||||
|
Удаление при этом не только панельное: умолчание библиотеки разрешает вошедшему
|
||||||
|
удалить **свою** учётную запись запросом, так что запрет закрывает и публичную
|
||||||
|
поверхность.
|
||||||
|
|
||||||
|
Требование заведено вместо прежнего «удаление не уносит задачи следом»: оно
|
||||||
|
выглядело выполненным, а на деле хранилище при выключенном каскаде **снимает
|
||||||
|
ссылку** — задачи остаются, но становятся ничьими, а ничья задача не достаётся
|
||||||
|
по API никому. Архив человека исчезал бы молча и восстановлению не подлежал:
|
||||||
|
прежнего владельца не остаётся нигде.
|
||||||
|
|
||||||
|
Цена требования названа прямо: владелец панели упирается в отказ, а способа
|
||||||
|
удалить записи в сервисе пока нет вовсе — его приносит задача про удаление
|
||||||
|
записи. До неё удаление учётной записи с записями невозможно, и это осознанный
|
||||||
|
тупик, а не недосмотр.
|
||||||
|
|
||||||
|
#### Scenario: Удаление учётной записи с записями отвергается
|
||||||
|
|
||||||
|
- **GIVEN** у учётной записи есть задачи расшифровки
|
||||||
|
- **WHEN** её удаляют
|
||||||
|
- **THEN** удаление не проходит, а отказ называет причину
|
||||||
|
- **AND** задачи и их владелец остаются прежними
|
||||||
|
|
||||||
|
#### Scenario: Учётная запись с одними файлами тоже не удаляется
|
||||||
|
|
||||||
|
- **GIVEN** у учётной записи остались файлы, но задач нет
|
||||||
|
- **WHEN** её удаляют
|
||||||
|
- **THEN** удаление не проходит, а владелец файлов остаётся прежним
|
||||||
|
|
||||||
|
#### Scenario: Учётная запись без записей удаляется
|
||||||
|
|
||||||
|
- **GIVEN** у учётной записи нет ни задач, ни файлов
|
||||||
|
- **WHEN** её удаляют
|
||||||
|
- **THEN** удаление проходит
|
||||||
|
|
||||||
|
## MODIFIED Requirements
|
||||||
|
|
||||||
|
### Requirement: Файл отдаётся ссылкой
|
||||||
|
|
||||||
|
Сервис SHALL отдавать файл записи ссылкой, которую строит хранилище по самой
|
||||||
|
записи, **и только узнанному отправителю**. Поле файла MUST быть помечено
|
||||||
|
защищённым: без этого ссылка открывает запись любому, кто её знает, и знание
|
||||||
|
ссылки становится правом. Отданный файл MUST совпадать с принятым по длине.
|
||||||
|
|
||||||
|
Одной пометки мало: защищённый файл судится **коротким токеном файла**, который
|
||||||
|
узнанный отправитель берёт у хранилища, предъявив сессию, — и правилом просмотра
|
||||||
|
коллекции. Правило MUST пускать только владельца файла: незаданное означает
|
||||||
|
«только владелец панели», и тогда файла не получит и вошедший, а прежнее «всякий
|
||||||
|
узнанный» отдавало чужое аудио тому, кто знает идентификатор записи.
|
||||||
|
|
||||||
|
Токен файла хранилище выдаёт **на предъявителя**, а не на файл, и о файле при
|
||||||
|
выдаче не спрашивает. Значит владельца судит переход по ссылке, а не выдача
|
||||||
|
токена: отказ наступает там, и требовать его от выдачи значит требовать
|
||||||
|
механизма, которого нет.
|
||||||
|
|
||||||
|
Отсюда порядок для потребителя: сессия → токен файла → ссылка с этим токеном.
|
||||||
|
Браузер с одной лишь кукой файла не получит, и это свойство хранилища, а не
|
||||||
|
недосмотр.
|
||||||
|
|
||||||
|
Конвейер расшифровки этим не затронут: он читает файл из файловой системы
|
||||||
|
хранилища, а не по ссылке.
|
||||||
|
|
||||||
|
Ссылка на несуществующую запись MUST отвечать отказом, а не пустым файлом.
|
||||||
|
|
||||||
|
**Ссылка сама по себе и есть право пройти по ней**, и потому она MUST не попадать
|
||||||
|
ни в журнал, ни в метку метрики, ни в ответ отправителю. Имя, под которым файл
|
||||||
|
лёг в хранилище, из журнала выводимо быть не должно: журнал уезжает в собранные
|
||||||
|
логи, откуда строку не убрать, и оттуда ссылка на чужую запись работала бы
|
||||||
|
бессрочно.
|
||||||
|
|
||||||
|
Защищённое поле сужает это право, но не отменяет запрета: право пройти теперь
|
||||||
|
требует ещё и сессии, а строка журнала со ссылкой по-прежнему собирала бы
|
||||||
|
половину ключа.
|
||||||
|
|
||||||
|
Отсюда требование к отказам: сообщение об отказе хранилища MUST не выходить за
|
||||||
|
пределы хранилища дословно. Отказ чтения и отказ укладки называют ключ файла
|
||||||
|
целиком, а отказ выгрузки во внешнее хранилище — полный адрес объекта; и то и
|
||||||
|
другое кончается в журнале и собирает ссылку не хуже успешного пути.
|
||||||
|
|
||||||
|
Что именно журнал приёма пишет ради прослеживаемости, нормирует capability
|
||||||
|
`intake`.
|
||||||
|
|
||||||
|
#### Scenario: Файл забирают по ссылке
|
||||||
|
|
||||||
|
- **GIVEN** запись принята и её файл лежит в хранилище
|
||||||
|
- **AND** забирающий предъявил сессию и взял по ней токен файла
|
||||||
|
- **WHEN** ссылку на файл запрашивают с этим токеном
|
||||||
|
- **THEN** приходит тот же файл, и его длина совпадает с длиной принятого
|
||||||
|
|
||||||
|
#### Scenario: Без сессии файл не отдаётся
|
||||||
|
|
||||||
|
- **GIVEN** запись принята и её файл лежит в хранилище
|
||||||
|
- **WHEN** ссылку на файл запрашивают без сессии
|
||||||
|
- **THEN** приходит отказ, а содержимого записи в ответе нет
|
||||||
|
|
||||||
|
#### Scenario: Конвейер читает файл без сессии
|
||||||
|
|
||||||
|
- **GIVEN** запись принята и ждёт расшифровки
|
||||||
|
- **WHEN** шаг конвейера берётся за неё
|
||||||
|
- **THEN** файл читается из файловой системы хранилища и шаг проходит
|
||||||
|
|
||||||
|
#### Scenario: Ссылка ведёт в никуда
|
||||||
|
|
||||||
|
- **WHEN** запрашивают ссылку на запись, которой нет
|
||||||
|
- **THEN** приходит отказ, а не пустой ответ
|
||||||
|
|
||||||
|
#### Scenario: По журналу ссылку не собрать
|
||||||
|
|
||||||
|
- **GIVEN** запись принята и прошла конвейер
|
||||||
|
- **WHEN** читают журнал сервиса целиком
|
||||||
|
- **THEN** имени, под которым файл лёг в хранилище, в нём нет
|
||||||
|
|
||||||
|
#### Scenario: Отказ чтения файла не называет его ключ
|
||||||
|
|
||||||
|
- **GIVEN** файл записи не читается из хранилища
|
||||||
|
- **WHEN** шаг конвейера берётся за эту запись и отказывает
|
||||||
|
- **THEN** отказ называет запись её идентификатором и не несёт имени файла
|
||||||
@@ -0,0 +1,130 @@
|
|||||||
|
## Критерии приёмки задачи
|
||||||
|
|
||||||
|
Дословно из `tasks/items/record-ownership.md`. Файл задачи закрытие удалит —
|
||||||
|
критерии обязаны его пережить.
|
||||||
|
|
||||||
|
- Запрос чужой записи по её идентификатору возвращает «не найдено», а не
|
||||||
|
содержимое и не «доступ запрещён». Оракул — тест: две сессии, задача первой
|
||||||
|
запрашивается второй, ответ 404 и пустое тело.
|
||||||
|
*Расхождение:* «пустое тело» разошлось с нетронутым сценарием спеки, где ответ
|
||||||
|
по неизвестному идентификатору несёт сообщение о ненайденной задаче. Читается
|
||||||
|
как «то же тело, что и у неизвестного идентификатора, и без состояния и текста
|
||||||
|
расшифровки» — иначе реализация по букве критерия молча снимет действующее
|
||||||
|
требование о сообщении.
|
||||||
|
- Запись, заведённая из веба, принадлежит вошедшему. Оракул — тест приёма по
|
||||||
|
HTTP со сверкой колонки владельца.
|
||||||
|
- Выборка воркера владельцем **не** сужается: конвейер обрабатывает записи всех.
|
||||||
|
Оракул — тест: задачи двух владельцев проходят конвейер одним воркером.
|
||||||
|
- База заводится с чистого листа, колонка владельца обязательна и без умолчания.
|
||||||
|
Оракул — прогон миграций на пустой базе и попытка вставки без владельца.
|
||||||
|
|
||||||
|
**Четвёртый критерий разошёлся с рамками той же задачи** и правится решением
|
||||||
|
человека на чекпоинте: рамки запрещают назначать владельца записям из Telegram, а
|
||||||
|
обязательная колонка такую запись вставить не даст. Разобрано в `design.md`,
|
||||||
|
«Open Questions»; ниже план написан по рекомендованному способу — колонка без
|
||||||
|
умолчания, допускающая пустое значение, обязательность приёма по HTTP держит код.
|
||||||
|
|
||||||
|
## Рубрика ревью дизайна — приёмочные критерии
|
||||||
|
|
||||||
|
Порождена проходом `rubric` до чтения артефактов. Приёмка судится по одному
|
||||||
|
списку: этот блок и блок выше.
|
||||||
|
|
||||||
|
1. Чужая запись неотличима от несуществующей на всех наблюдаемых осях: тот же
|
||||||
|
код, то же тело, та же форма ответа.
|
||||||
|
2. Сужение живёт в одном месте пути чтения и обязательно к употреблению: второго
|
||||||
|
читающего входа, у которого сужение можно не позвать, нет.
|
||||||
|
3. Пустой владелец на входе чтения не совпадает ни с одной записью — своей,
|
||||||
|
чужой и ничьей.
|
||||||
|
4. Записи без владельца недостижимы сужённым путём никому, и недостижимость
|
||||||
|
выведена из правила, а не из того, что таких записей мало.
|
||||||
|
5. Владелец назначается сервером из сессии: поле владельца, пришедшее запросом,
|
||||||
|
на результат не влияет.
|
||||||
|
6. Владелец появляется в той же операции, что и запись; отказ по отсутствию
|
||||||
|
владельца не оставляет ни задачи, ни файла, и способ этого назван.
|
||||||
|
7. Неизменность владельца держит механизм, а не обещание: назван каждый путь,
|
||||||
|
которым владельца можно переписать, и что его удерживает.
|
||||||
|
8. Колонка прочитана всеми четырьмя местами правки колонок очереди плюс шагом
|
||||||
|
схемы.
|
||||||
|
9. Воркер владельцем не сужается, и это записано нормой, а не оставлено
|
||||||
|
умолчанию.
|
||||||
|
10. Судьба записи при исчезновении владельца определена.
|
||||||
|
11. Шаг схемы: одно представление «владельца нет», откат не требует переписывания
|
||||||
|
применённого шага.
|
||||||
|
12. Проверка владельца и чтение состояния берутся из одного чтения записи, а не
|
||||||
|
двумя раздельными.
|
||||||
|
13. Ответ отправителю адресуется по источнику записи, а не по владельцу: запись
|
||||||
|
без владельца получает свой ответ в чат.
|
||||||
|
14. Ни ответ, ни журнал не выдают того, что прячет разграничение; различение
|
||||||
|
чужой и несуществующей в журнале допустимо.
|
||||||
|
|
||||||
|
## 1. Схема хранилища
|
||||||
|
|
||||||
|
- [x] 1.1 Новый шаг схемы `202608140001_record_owner.go`: колонка `owner` в
|
||||||
|
таблице задач связью с коллекцией `users`, без умолчания, пустое значение
|
||||||
|
допустимо, каскадное удаление выключено. Имя колонки — `owner`, поле
|
||||||
|
сущности — `OwnerID`; имена названы здесь, потому что разойтись им есть где
|
||||||
|
— семь мест плюс подпись метода
|
||||||
|
- [x] 1.2 Тем же шагом — колонка `owner` в таблице файлов, теми же свойствами;
|
||||||
|
правило просмотра коллекции файлов сужается владельцем вместо прежнего
|
||||||
|
«всякий вошедший»
|
||||||
|
- [x] 1.3 Тем же шагом — запрет удаления учётной записи, у которой остались
|
||||||
|
задачи: отказ с причиной, а не снятие ссылки
|
||||||
|
- [x] 1.4 Прежние шаги схемы не тронуты — проверяется шагом гейта `migrations`
|
||||||
|
- [x] 1.5 `docs/database.md`: строки колонок в обеих таблицах, правило выборки по
|
||||||
|
владельцу, новое правило просмотра файлов и запрет удаления учётной записи
|
||||||
|
|
||||||
|
## 2. Сущность и контракт
|
||||||
|
|
||||||
|
- [x] 2.1 `internal/entity`: у задачи расшифровки появляется владелец
|
||||||
|
- [x] 2.2 `internal/contract`: читающий метод репозитория задач принимает
|
||||||
|
владельца; второго читающего метода не заводится
|
||||||
|
|
||||||
|
## 3. Хранилище задач
|
||||||
|
|
||||||
|
- [x] 3.1 `applyToRecord` кладёт владельца при заведении
|
||||||
|
- [x] 3.2 `recordToJob` читает владельца
|
||||||
|
- [x] 3.3 `acquireColumns` и `acquiredRow` читают колонку владельца
|
||||||
|
- [x] 3.4 `applyOwnedByPipeline` владельца **не** трогает
|
||||||
|
- [x] 3.5 Чтение задачи сужено владельцем: задача другого владельца и задача без
|
||||||
|
владельца отдают ту же ошибку, что и несуществующая
|
||||||
|
|
||||||
|
## 4. Приём и опрос
|
||||||
|
|
||||||
|
- [x] 4.1 `internal/service`: метод заведения задачи из веба принимает владельца
|
||||||
|
и отказывает при пустом; метод заведения из Telegram владельца не
|
||||||
|
назначает. Владелец кладётся и на файл, заводимый при приёме
|
||||||
|
- [x] 4.2 `internal/controller/http`: приём берёт владельца из предъявленной
|
||||||
|
сессии
|
||||||
|
- [x] 4.3 `internal/controller/http`: опрос готовности передаёт владельца в
|
||||||
|
хранилище и отвечает `404` с прежним телом на чужую и на ничью задачу
|
||||||
|
|
||||||
|
## 5. Проверки
|
||||||
|
|
||||||
|
- [x] 5.1 Тест: две сессии, задача первой запрашивается второй — `404`, тело без
|
||||||
|
состояния и текста
|
||||||
|
- [x] 5.2 Тест: приём по HTTP заводит задачу с владельцем-предъявителем
|
||||||
|
- [x] 5.3 Тест: приём по HTTP без узнанной учётной записи задачи не заводит
|
||||||
|
- [x] 5.4 Тест: задачи двух владельцев и задача без владельца проходят конвейер
|
||||||
|
одним воркером
|
||||||
|
- [x] 5.5 Тест: шаг конвейера, сохраняющий результат, владельца не затирает
|
||||||
|
- [x] 5.6 Тест: миграции на пустой базе заводят колонку без умолчания
|
||||||
|
- [x] 5.7 Тест: чтение с пустым владельцем не отдаёт ни своей, ни чужой, ни
|
||||||
|
ничьей задачи
|
||||||
|
- [x] 5.8 Тест: сессия без учётной записи пользователя получает `403` на приёме,
|
||||||
|
файла и задачи не заводится — узнан, но не запись коллекции пользователей
|
||||||
|
- [x] 5.9 Тест: вошедший просит токен чужого файла — отказ; своего — успех
|
||||||
|
- [x] 5.10 Тест: удаление учётной записи с задачами отвергается, без задач —
|
||||||
|
проходит
|
||||||
|
- [x] 5.11 `task gate` зелёный
|
||||||
|
|
||||||
|
## 6. Архивация
|
||||||
|
|
||||||
|
- [ ] 6.1 На архивации выправить `## Purpose` спеки `access`: преамбула
|
||||||
|
переживает слияние дельт дословно и сегодня утверждает, что разграничения
|
||||||
|
по владельцу нет. Валидатор преамбулу не судит — вспомнить об этом больше
|
||||||
|
некому
|
||||||
|
- [ ] 6.2 Сверить `docs/security.md`, `docs/passport.md` и **`docs/architecture.md`**
|
||||||
|
(строка «Разграничения записей по владельцу здесь нет»): все три обещают
|
||||||
|
закрытие разграничением именно этой задачей. Адрес в `architecture.md`
|
||||||
|
назван отдельно — его нашло ревью, и без него обзор архитектуры отправлял
|
||||||
|
бы следующего читателя чинить уже закрытое
|
||||||
@@ -6,15 +6,17 @@
|
|||||||
OIDC, чем предъявляется сессия, что её прекращает и какие адреса остаются
|
OIDC, чем предъявляется сессия, что её прекращает и какие адреса остаются
|
||||||
открытыми.
|
открытыми.
|
||||||
|
|
||||||
Разграничения записей по владельцу здесь **нет**: всякий вошедший видит ровно
|
Здесь же разграничение записей по владельцу: с 2026-08-14 сессия отвечает не
|
||||||
то же, что видел прежде аноним. Его заводит отдельная задача, и до неё сессия
|
только на вопрос «узнан ли пришедший», но и на «чьё он смотрит». Запись из веба
|
||||||
отвечает только на вопрос «узнан ли пришедший», а не «чьё он смотрит».
|
принадлежит тому, кто её принёс, и чужая неотличима от несуществующей.
|
||||||
|
|
||||||
|
Записи, принятые ботом, владельца не имеют вовсе и по API не достаются никому:
|
||||||
|
связи чата Telegram с учётной записью приложения сервис не ведёт, её заводит
|
||||||
|
отдельная задача.
|
||||||
|
|
||||||
Вход из Telegram эта capability не нормирует: бот проверяет отправителя своим
|
Вход из Telegram эта capability не нормирует: бот проверяет отправителя своим
|
||||||
белым списком, и с учётной записью приложения тот список не связан.
|
белым списком, и с учётной записью приложения тот список не связан.
|
||||||
|
|
||||||
## Requirements
|
## Requirements
|
||||||
|
|
||||||
### Requirement: Вход через внешнего провайдера
|
### Requirement: Вход через внешнего провайдера
|
||||||
|
|
||||||
Сервис SHALL заводить сессию только по итогу входа у внешнего провайдера OIDC.
|
Сервис SHALL заводить сессию только по итогу входа у внешнего провайдера OIDC.
|
||||||
@@ -345,3 +347,64 @@ MUST не делать. Кто допущен, определяет правил
|
|||||||
- **WHEN** сервис поднимается с пустым или негодным ключом секции входа
|
- **WHEN** сервис поднимается с пустым или негодным ключом секции входа
|
||||||
- **THEN** старт кончается отказом, а отказ называет имена ключей
|
- **THEN** старт кончается отказом, а отказ называет имена ключей
|
||||||
- **AND** значений этих ключей в отказе нет
|
- **AND** значений этих ключей в отказе нет
|
||||||
|
|
||||||
|
### Requirement: У записи есть владелец, и чужую ей не отдают
|
||||||
|
|
||||||
|
Сервис SHALL заводить у каждой записи, принятой **по HTTP**, — владельца, то
|
||||||
|
есть учётную запись, от имени которой запись принята, — и MUST отдавать данные
|
||||||
|
такой записи только её владельцу. Владелец назначается один раз, при приёме, и
|
||||||
|
MUST не меняться у записи, у которой владелец есть: совместного доступа, ролей и
|
||||||
|
передачи записи другому сервис не знает. Оговорка не случайна — назначить
|
||||||
|
владельца записи, у которой его нет, вправе задача, заводящая связь чата
|
||||||
|
Telegram с учётной записью.
|
||||||
|
|
||||||
|
Владелец MUST браться из предъявленной сессии и ниоткуда больше. Владелец,
|
||||||
|
пришедший полем запроса, дал бы всякому вошедшему право завести запись на чужое
|
||||||
|
имя.
|
||||||
|
|
||||||
|
Обращение к чужой записи MUST быть неотличимо от обращения к несуществующей.
|
||||||
|
Отдельный отказ «доступ запрещён» превращает опрос в перебор — по разнице
|
||||||
|
ответов считывается, какие записи заведены, а идентификатор записи и есть то,
|
||||||
|
что разграничение прячет. Каким именно ответом это выражено, нормирует
|
||||||
|
capability `intake`: там живёт адрес опроса, и держатель нормы обязан быть один.
|
||||||
|
|
||||||
|
Пустой владелец MUST не совпадать ни с одной записью — ни со своей, ни с чужой,
|
||||||
|
ни с ничьей. Правило записано со стороны **спрашивающего**, а не со стороны
|
||||||
|
записи: обязательность владельца, которую держит одна лишь подпись метода, пустую
|
||||||
|
строку пропускает, и первый же вызывающий без учётной записи получил бы ровно
|
||||||
|
множество записей без владельца, то есть все записи бота.
|
||||||
|
|
||||||
|
Записи, принятые из Telegram, владельца не имеют: связи чата с учётной записью
|
||||||
|
приложения сервис не ведёт. Такая запись MUST не доставаться по API никому —
|
||||||
|
ответ на неё тот же, что и на несуществующую, — а её расшифровка уезжает
|
||||||
|
отправителю в чат, как и прежде.
|
||||||
|
|
||||||
|
#### Scenario: Своя запись доступна
|
||||||
|
|
||||||
|
- **GIVEN** человек вошёл и принял запись
|
||||||
|
- **WHEN** он спрашивает состояние этой записи своей сессией
|
||||||
|
- **THEN** ответ несёт состояние записи
|
||||||
|
|
||||||
|
#### Scenario: Чужая запись неотличима от несуществующей
|
||||||
|
|
||||||
|
- **GIVEN** запись принята одним вошедшим
|
||||||
|
- **WHEN** её состояние спрашивает другой вошедший
|
||||||
|
- **THEN** ответ тот же, что и на неизвестный идентификатор, — и кодом, и телом
|
||||||
|
|
||||||
|
#### Scenario: Владельца не задают запросом
|
||||||
|
|
||||||
|
- **WHEN** запрос на приём записи несёт своё значение владельца
|
||||||
|
- **THEN** владельцем принятой записи становится предъявитель сессии
|
||||||
|
|
||||||
|
#### Scenario: Запись из Telegram не достаётся по API
|
||||||
|
|
||||||
|
- **GIVEN** запись принята ботом
|
||||||
|
- **WHEN** её состояние спрашивает вошедший человек
|
||||||
|
- **THEN** ответ тот же, что и на неизвестный идентификатор
|
||||||
|
|
||||||
|
#### Scenario: Пустой владелец не открывает ничего
|
||||||
|
|
||||||
|
- **GIVEN** заведены три задачи: своя, чужая и принятая ботом
|
||||||
|
- **WHEN** состояние каждой спрашивают с пустым владельцем
|
||||||
|
- **THEN** ответ на все три тот же, что и на неизвестный идентификатор
|
||||||
|
|
||||||
|
|||||||
@@ -36,8 +36,24 @@ Telegram, дописывает его сюда.
|
|||||||
Куда именно ложится принятая запись, приёму не принадлежит: раскладку выбирает
|
Куда именно ложится принятая запись, приёму не принадлежит: раскладку выбирает
|
||||||
хранилище, и нормирует её capability `storage`.
|
хранилище, и нормирует её capability `storage`.
|
||||||
|
|
||||||
Владельца у принятой записи приём не заводит: после входа видно ровно то же, что
|
Владельцем принятой записи приём SHALL назначать предъявителя сессии. Проверка
|
||||||
видно было анонимно.
|
стоит здесь, а не только в схеме хранилища: колонка владельца допускает пустое
|
||||||
|
значение ради записей из Telegram, и приём по HTTP — то место, где
|
||||||
|
обязательность держится.
|
||||||
|
|
||||||
|
Предъявитель, чья сессия не даёт учётной записи пользователя, MUST получать
|
||||||
|
отказ `403` и MUST получать его **до чтения тела** — там же, где стоит отказ по
|
||||||
|
отсутствию сессии. Сессия владельца панели — именно такой случай: узнан он всё
|
||||||
|
же узнан, а записи в коллекции пользователей у него нет, и владельцем записи он
|
||||||
|
стать не может.
|
||||||
|
|
||||||
|
Код здесь другой, чем у запроса без сессии, и это не оплошность: `401` значит
|
||||||
|
«предъяви себя», а предъявитель себя предъявил. Утечки по разнице кодов нет —
|
||||||
|
оба ответа говорят о самом спрашивающем, а не о том, какие записи заведены.
|
||||||
|
|
||||||
|
Отказ **после** укладки записи потребовал бы убрать уже сохранённый файл, а
|
||||||
|
уборки файлов сервис не умеет вовсе: норма, обязывающая к недостижимому, не
|
||||||
|
пишется.
|
||||||
|
|
||||||
#### Scenario: Запись принята
|
#### Scenario: Запись принята
|
||||||
|
|
||||||
@@ -47,6 +63,14 @@ Telegram, дописывает его сюда.
|
|||||||
- **THEN** ответ имеет код `201`, а в теле лежат непустой `job_id` и `status`
|
- **THEN** ответ имеет код `201`, а в теле лежат непустой `job_id` и `status`
|
||||||
со значением `created`
|
со значением `created`
|
||||||
- **AND** содержимое записи целиком лежит в хранилище одним файлом
|
- **AND** содержимое записи целиком лежит в хранилище одним файлом
|
||||||
|
- **AND** владельцем заведённой задачи стоит предъявитель сессии
|
||||||
|
|
||||||
|
#### Scenario: Сессия не даёт учётной записи пользователя
|
||||||
|
|
||||||
|
- **GIVEN** предъявлена сессия владельца панели
|
||||||
|
- **WHEN** он шлёт `POST /api/audio` с полем `audio`
|
||||||
|
- **THEN** ответ имеет код `403`
|
||||||
|
- **AND** ни файла, ни задачи не заводится
|
||||||
|
|
||||||
#### Scenario: Сессии нет
|
#### Scenario: Сессии нет
|
||||||
|
|
||||||
@@ -214,24 +238,24 @@ Telegram, дописывает его сюда.
|
|||||||
### Requirement: Опрос готовности задачи
|
### Requirement: Опрос готовности задачи
|
||||||
|
|
||||||
Сервис SHALL отдавать состояние задачи расшифровки по запросу
|
Сервис SHALL отдавать состояние задачи расшифровки по запросу
|
||||||
`GET /api/status/:id` **только узнанному отправителю**. Запрос без сессии MUST
|
`GET /api/status/:id` **только её владельцу**. Запрос без сессии MUST получать
|
||||||
получать код `401`, и тело такого ответа MUST не нести ни состояния задачи, ни
|
код `401`, и тело такого ответа MUST не нести ни состояния задачи, ни текста
|
||||||
текста расшифровки. Ответ узнанному отправителю MUST нести идентификатор полем
|
расшифровки. Ответ владельцу MUST нести идентификатор полем `job_id`, состояние
|
||||||
`job_id`, состояние полем `status` и время заведения полем `created_at`, а текст
|
полем `status` и время заведения полем `created_at`, а текст расшифровки полем
|
||||||
расшифровки полем `transcription_text`, и это поле MUST отсутствовать в ответе,
|
`transcription_text`, и это поле MUST отсутствовать в ответе, пока текста нет:
|
||||||
пока текста нет: пустая строка на месте отсутствующего текста читается как
|
пустая строка на месте отсутствующего текста читается как «расшифровка пуста».
|
||||||
«расшифровка пуста».
|
|
||||||
|
|
||||||
Отказ без сессии MUST не зависеть от того, есть такая задача или нет: иначе по
|
Отказ без сессии MUST не зависеть от того, есть такая задача или нет: иначе по
|
||||||
кодам ответа перебирается список заведённых задач.
|
кодам ответа перебирается список заведённых задач.
|
||||||
|
|
||||||
Выборку по владельцу опрос не сужает: узнанный отправитель видит любую задачу по
|
Задача, принадлежащая другому, MUST отвечать тем же, чем отвечает неизвестный
|
||||||
её идентификатору ровно как прежде. Сужение придёт отдельной задачей.
|
идентификатор, — кодом `404` и тем же телом. То же MUST относиться к задаче без
|
||||||
|
владельца: запись, принятая ботом, по этому адресу не достаётся никому.
|
||||||
|
|
||||||
#### Scenario: Задача найдена
|
#### Scenario: Задача найдена
|
||||||
|
|
||||||
- **GIVEN** отправитель предъявил сессию
|
- **GIVEN** отправитель предъявил сессию
|
||||||
- **WHEN** программа спрашивает состояние заведённой задачи
|
- **WHEN** он спрашивает состояние своей задачи
|
||||||
- **THEN** ответ имеет код `200` и несёт `job_id`, `status` и `created_at`
|
- **THEN** ответ имеет код `200` и несёт `job_id`, `status` и `created_at`
|
||||||
|
|
||||||
#### Scenario: Сессии нет
|
#### Scenario: Сессии нет
|
||||||
@@ -246,10 +270,18 @@ Telegram, дописывает его сюда.
|
|||||||
состояние по неизвестному идентификатору
|
состояние по неизвестному идентификатору
|
||||||
- **THEN** оба ответа имеют код `401`
|
- **THEN** оба ответа имеют код `401`
|
||||||
|
|
||||||
|
#### Scenario: Чужая задача неотличима от неизвестной
|
||||||
|
|
||||||
|
- **GIVEN** задача заведена одним вошедшим
|
||||||
|
- **WHEN** её состояние спрашивает другой вошедший
|
||||||
|
- **THEN** ответ имеет код `404` и то же тело, что и ответ по неизвестному
|
||||||
|
идентификатору
|
||||||
|
- **AND** тело ответа не несёт ни состояния задачи, ни текста расшифровки
|
||||||
|
|
||||||
#### Scenario: Расшифровки ещё нет
|
#### Scenario: Расшифровки ещё нет
|
||||||
|
|
||||||
- **GIVEN** отправитель предъявил сессию
|
- **GIVEN** отправитель предъявил сессию
|
||||||
- **WHEN** программа спрашивает состояние задачи, которая ещё не дошла до текста
|
- **WHEN** он спрашивает состояние своей задачи, которая ещё не дошла до текста
|
||||||
- **THEN** поля `transcription_text` в ответе нет вовсе
|
- **THEN** поля `transcription_text` в ответе нет вовсе
|
||||||
|
|
||||||
#### Scenario: Задачи с таким идентификатором нет
|
#### Scenario: Задачи с таким идентификатором нет
|
||||||
|
|||||||
@@ -15,7 +15,6 @@ done | failed`, отмена контекста посреди шага и ос
|
|||||||
требования на него не написаны, потому что требование без проверки —
|
требования на него не написаны, потому что требование без проверки —
|
||||||
предположение, а не норма. Первая задача, которая трогает любое из
|
предположение, а не норма. Первая задача, которая трогает любое из
|
||||||
перечисленного, дописывает его сюда.
|
перечисленного, дописывает его сюда.
|
||||||
|
|
||||||
## Requirements
|
## Requirements
|
||||||
### Requirement: Пустой прогон воркера — не отказ
|
### Requirement: Пустой прогон воркера — не отказ
|
||||||
|
|
||||||
@@ -325,3 +324,35 @@ MUST расти с числом её попыток до объявленног
|
|||||||
- **GIVEN** задача принята по HTTP
|
- **GIVEN** задача принята по HTTP
|
||||||
- **WHEN** шаг конвейера доходит до ответа отправителю
|
- **WHEN** шаг конвейера доходит до ответа отправителю
|
||||||
- **THEN** шаг завершается без отказа и без записи о недоставке
|
- **THEN** шаг завершается без отказа и без записи о недоставке
|
||||||
|
|
||||||
|
### Requirement: Выборка воркера владельцем не сужается
|
||||||
|
|
||||||
|
Воркер SHALL брать задачи всех владельцев подряд и MUST не учитывать владельца
|
||||||
|
при выборе очередной задачи. Задача без владельца — принятая ботом — MUST
|
||||||
|
обрабатываться наравне с прочими.
|
||||||
|
|
||||||
|
Владелец решает, кому запись показывать, а не кому её считать. Сужение выборки
|
||||||
|
владельцем остановило бы расшифровку записей бота вовсе, а записи остальных
|
||||||
|
поставило бы в зависимость от того, кто первым завёл учётную запись.
|
||||||
|
|
||||||
|
Владелец задачи MUST переживать работу конвейера: шаг, сохраняющий свой
|
||||||
|
результат, владельца не трогает и не затирает.
|
||||||
|
|
||||||
|
#### Scenario: Задачи двух владельцев проходят одним воркером
|
||||||
|
|
||||||
|
- **GIVEN** заведены задачи двух разных владельцев в одном состоянии
|
||||||
|
- **WHEN** воркер забирает задачи этого состояния
|
||||||
|
- **THEN** ему достаются обе, в порядке заведения
|
||||||
|
|
||||||
|
#### Scenario: Задача без владельца обрабатывается
|
||||||
|
|
||||||
|
- **GIVEN** заведена задача, принятая ботом, — без владельца
|
||||||
|
- **WHEN** воркер забирает задачи её состояния
|
||||||
|
- **THEN** она достаётся ему наравне с прочими
|
||||||
|
|
||||||
|
#### Scenario: Шаг конвейера владельца не затирает
|
||||||
|
|
||||||
|
- **GIVEN** задача с владельцем прошла шаг конвейера
|
||||||
|
- **WHEN** шаг сохраняет свой результат
|
||||||
|
- **THEN** владелец задачи остаётся прежним
|
||||||
|
|
||||||
|
|||||||
@@ -10,7 +10,6 @@
|
|||||||
Сознательно не описаны: перенос прежних данных — его нет по решению задачи
|
Сознательно не описаны: перенос прежних данных — его нет по решению задачи
|
||||||
`pocketbase-storage`; удаление записей и файлов — сервис объявлен архивом
|
`pocketbase-storage`; удаление записей и файлов — сервис объявлен архивом
|
||||||
2026-08-11, а удаление приносит задача `delete-record`.
|
2026-08-11, а удаление приносит задача `delete-record`.
|
||||||
|
|
||||||
## Requirements
|
## Requirements
|
||||||
### Requirement: Сервис поднимается на чистом каталоге данных
|
### Requirement: Сервис поднимается на чистом каталоге данных
|
||||||
|
|
||||||
@@ -106,9 +105,14 @@ MUST завести свою схему и принимать записи об
|
|||||||
|
|
||||||
Одной пометки мало: защищённый файл судится **коротким токеном файла**, который
|
Одной пометки мало: защищённый файл судится **коротким токеном файла**, который
|
||||||
узнанный отправитель берёт у хранилища, предъявив сессию, — и правилом просмотра
|
узнанный отправитель берёт у хранилища, предъявив сессию, — и правилом просмотра
|
||||||
коллекции. Правило MUST пускать всякого узнанного: незаданное означает «только
|
коллекции. Правило MUST пускать только владельца файла: незаданное означает
|
||||||
владелец панели», и тогда файла не получит и вошедший. Сужения по владельцу
|
«только владелец панели», и тогда файла не получит и вошедший, а прежнее «всякий
|
||||||
здесь нет — его заводит отдельная задача.
|
узнанный» отдавало чужое аудио тому, кто знает идентификатор записи.
|
||||||
|
|
||||||
|
Токен файла хранилище выдаёт **на предъявителя**, а не на файл, и о файле при
|
||||||
|
выдаче не спрашивает. Значит владельца судит переход по ссылке, а не выдача
|
||||||
|
токена: отказ наступает там, и требовать его от выдачи значит требовать
|
||||||
|
механизма, которого нет.
|
||||||
|
|
||||||
Отсюда порядок для потребителя: сессия → токен файла → ссылка с этим токеном.
|
Отсюда порядок для потребителя: сессия → токен файла → ссылка с этим токеном.
|
||||||
Браузер с одной лишь кукой файла не получит, и это свойство хранилища, а не
|
Браузер с одной лишь кукой файла не получит, и это свойство хранилища, а не
|
||||||
@@ -272,3 +276,118 @@ MUST завести свою схему и принимать записи об
|
|||||||
- **WHEN** сервис запускается снова
|
- **WHEN** сервис запускается снова
|
||||||
- **THEN** приглашения завести владельца в журнале нет
|
- **THEN** приглашения завести владельца в журнале нет
|
||||||
|
|
||||||
|
### Requirement: Владелец задачи лежит связью с учётной записью
|
||||||
|
|
||||||
|
Хранилище SHALL держать владельца задачи расшифровки отдельной колонкой — связью
|
||||||
|
с учётной записью, — и эта колонка MUST не иметь умолчания: запись, чей владелец
|
||||||
|
не назван, не достаётся никому по недосмотру схемы.
|
||||||
|
|
||||||
|
Колонка MUST допускать пустое значение, и это решение с названной ценой: записи,
|
||||||
|
принятые ботом, владельца не имеют, потому что связи чата Telegram с учётной
|
||||||
|
записью сервис не ведёт. Обязательность для приёма по HTTP держит сама
|
||||||
|
capability `intake`, а не схема.
|
||||||
|
|
||||||
|
Колонка приезжает **новым шагом схемы**: применённый шаг не переписывается.
|
||||||
|
Записей, заведённых до этого шага, сервис не переносит — проект заводится с
|
||||||
|
чистого листа.
|
||||||
|
|
||||||
|
#### Scenario: Колонка появляется на пустой базе
|
||||||
|
|
||||||
|
- **WHEN** сервис поднимается на чистом каталоге данных
|
||||||
|
- **THEN** у таблицы задач есть колонка владельца
|
||||||
|
- **AND** умолчания у неё нет
|
||||||
|
|
||||||
|
### Requirement: Файл записи сужается владельцем наравне с задачей
|
||||||
|
|
||||||
|
Хранилище SHALL держать владельца и у файла записи — той же связью с учётной
|
||||||
|
записью, тем же шагом схемы, — и правило просмотра файлов MUST пускать к файлу
|
||||||
|
только его владельца. Прежнее правило пускало всякого узнанного, и знание
|
||||||
|
идентификатора файловой записи равнялось праву скачать чужое аудио.
|
||||||
|
|
||||||
|
Без этого требования разграничение закрывает метаданные задачи и оставляет
|
||||||
|
открытым содержимое — то самое, что оно и заведено прятать. Хуже самой дыры была
|
||||||
|
бы отметка о закрытии: паспорт и модель угроз называют исполнителем этой работы
|
||||||
|
именно эту задачу, и слово «закрыто» скрыло бы открытый путь.
|
||||||
|
|
||||||
|
Владелец файла MUST назначаться там же, где владелец задачи, — при приёме, из
|
||||||
|
предъявленной сессии, — и MUST оставаться пустым у файлов, заведённых конвейером
|
||||||
|
для записи без владельца.
|
||||||
|
|
||||||
|
Ссылка на файл в задаче переставляется каждым шагом конвейера, поэтому владелец
|
||||||
|
файла MUST лежать своей колонкой, а не выводиться через задачу: исходная копия
|
||||||
|
после конвертации не связана с задачей ничем.
|
||||||
|
|
||||||
|
Отказ наступает **на переходе по ссылке**, а не на выдаче токена файла: токен
|
||||||
|
хранилище выдаёт на предъявителя, а не на файл, и о файле при выдаче не
|
||||||
|
спрашивает вовсе. Требовать отказа при выдаче значит требовать механизма,
|
||||||
|
которого нет, — а проверка, написанная под такое требование, зеленела бы, не
|
||||||
|
касаясь пути, по которому аудио и уходит.
|
||||||
|
|
||||||
|
#### Scenario: Чужой файл не отдаётся
|
||||||
|
|
||||||
|
- **GIVEN** запись принята одним вошедшим
|
||||||
|
- **WHEN** другой вошедший идёт по ссылке на файл этой записи со своим токеном
|
||||||
|
- **THEN** содержимого он не получает
|
||||||
|
|
||||||
|
#### Scenario: Свой файл отдаётся
|
||||||
|
|
||||||
|
- **GIVEN** человек принял запись
|
||||||
|
- **WHEN** он идёт по ссылке на файл своей записи со своим токеном
|
||||||
|
- **THEN** содержимое отдаётся
|
||||||
|
|
||||||
|
#### Scenario: Файл записи из Telegram не отдаётся по API
|
||||||
|
|
||||||
|
- **GIVEN** запись принята ботом, и владельца у неё нет
|
||||||
|
- **WHEN** вошедший человек идёт по ссылке на её файл со своим токеном
|
||||||
|
- **THEN** содержимого он не получает
|
||||||
|
|
||||||
|
### Requirement: Учётная запись с записями не удаляется
|
||||||
|
|
||||||
|
Хранилище SHALL отвергать удаление учётной записи, у которой остались задачи
|
||||||
|
расшифровки **либо файлы**. Отказ MUST называть причину, и MUST доезжать до
|
||||||
|
спрашивающего: хранилище пропускает наружу только свою ошибку роутера, а всякую
|
||||||
|
другую подменяет сообщением про обязательную связь — подсказкой, по которой
|
||||||
|
владелец панели пойдёт удалять записи руками.
|
||||||
|
|
||||||
|
Считаются обе коллекции с владельцем. Файл переживает свою задачу: шаг конвейера
|
||||||
|
заводит его до сохранения задачи, и потерянный захват оставляет файл с владельцем
|
||||||
|
и без ссылки.
|
||||||
|
|
||||||
|
Запрет MUST ставить сама сборка хранилища, а не вызывающий: сборка, забывшая его
|
||||||
|
позвать, теряет защиту молча — и теряла, пока запрет вешался отдельной строкой
|
||||||
|
запуска, а окружение проверок его не ставило вовсе.
|
||||||
|
|
||||||
|
Удаление при этом не только панельное: умолчание библиотеки разрешает вошедшему
|
||||||
|
удалить **свою** учётную запись запросом, так что запрет закрывает и публичную
|
||||||
|
поверхность.
|
||||||
|
|
||||||
|
Требование заведено вместо прежнего «удаление не уносит задачи следом»: оно
|
||||||
|
выглядело выполненным, а на деле хранилище при выключенном каскаде **снимает
|
||||||
|
ссылку** — задачи остаются, но становятся ничьими, а ничья задача не достаётся
|
||||||
|
по API никому. Архив человека исчезал бы молча и восстановлению не подлежал:
|
||||||
|
прежнего владельца не остаётся нигде.
|
||||||
|
|
||||||
|
Цена требования названа прямо: владелец панели упирается в отказ, а способа
|
||||||
|
удалить записи в сервисе пока нет вовсе — его приносит задача про удаление
|
||||||
|
записи. До неё удаление учётной записи с записями невозможно, и это осознанный
|
||||||
|
тупик, а не недосмотр.
|
||||||
|
|
||||||
|
#### Scenario: Удаление учётной записи с записями отвергается
|
||||||
|
|
||||||
|
- **GIVEN** у учётной записи есть задачи расшифровки
|
||||||
|
- **WHEN** её удаляют
|
||||||
|
- **THEN** удаление не проходит, а отказ называет причину
|
||||||
|
- **AND** задачи и их владелец остаются прежними
|
||||||
|
|
||||||
|
#### Scenario: Учётная запись с одними файлами тоже не удаляется
|
||||||
|
|
||||||
|
- **GIVEN** у учётной записи остались файлы, но задач нет
|
||||||
|
- **WHEN** её удаляют
|
||||||
|
- **THEN** удаление не проходит, а владелец файлов остаётся прежним
|
||||||
|
|
||||||
|
#### Scenario: Учётная запись без записей удаляется
|
||||||
|
|
||||||
|
- **GIVEN** у учётной записи нет ни задач, ни файлов
|
||||||
|
- **WHEN** её удаляют
|
||||||
|
- **THEN** удаление проходит
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user