хранилище переехало с PocketBase на SQLite со своим каталогом файлов
- база своя: два пула, захват одним UPDATE ... RETURNING, шаги схемы на goose под файловым замком, одна миграция начальной схемы вместо семи прежних - транспорт переписан на net/http: свои слои, свой ограничитель частоты, отдача файла с проверкой владельца; панель /_/ и пространство /api/ исчезли - по находкам ревью: журнал не пишет путь под корнем приложения, ключ бюджета читается справа налево, узнавание известного идёт читающим пулом
This commit is contained in:
+272
-167
@@ -11,25 +11,18 @@ import (
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/pocketbase/pocketbase/apis"
|
||||
"github.com/pocketbase/pocketbase/core"
|
||||
"github.com/pocketbase/pocketbase/tools/router"
|
||||
"github.com/pocketbase/pocketbase/tools/types"
|
||||
|
||||
"git.vakhrushev.me/av/transcriber/internal/adapter/repo/pocketbase/migrations"
|
||||
"git.vakhrushev.me/av/transcriber/internal/contract"
|
||||
"git.vakhrushev.me/av/transcriber/internal/entity"
|
||||
"git.vakhrushev.me/av/transcriber/internal/ident"
|
||||
"git.vakhrushev.me/av/transcriber/internal/metrics"
|
||||
"git.vakhrushev.me/av/transcriber/internal/service"
|
||||
)
|
||||
|
||||
// AppRoot — корень адресов приложения.
|
||||
//
|
||||
// Приложение живёт своим пространством, а не в общем `/api/`: последнее
|
||||
// принадлежит хранилищу, оно вешает туда собственные наборы адресов, и поменять
|
||||
// этот префикс нельзя — он литерал библиотеки, а не настройка. Свободных имён
|
||||
// сегодня хватает, но обновление библиотеки вправе занять новое имя рядом с
|
||||
// нашим, и разойдутся они молча.
|
||||
// Корень остался **один**: пространства `/api/`, принадлежавшего встроенному
|
||||
// хранилищу, и адреса панели `/_/` больше не существует — сервис их не занимает.
|
||||
// Соседство, ради которого корень был выбран, кончилось вместе с соседом.
|
||||
const AppRoot = "/app"
|
||||
|
||||
// Пределы страницы. Умолчание — столько, сколько помещается на экран телефона
|
||||
@@ -55,17 +48,23 @@ const pollBudgetShare = 8
|
||||
// задаётся своей константой: иначе приложение, честно опрашивающее карточку с
|
||||
// объявленной частотой, упирается в ограничитель сервиса — и получает отказ,
|
||||
// которого сервис сам же ему обещал избежать.
|
||||
//
|
||||
// Прежде вывод давал **весь** бюджет целиком, и запаса не оставалось ни на один
|
||||
// соседний запрос: любой второй в ту же секунду отвергался. Теперь объявленная
|
||||
// частота — доля бюджета, и неравенство «объявленное меньше применяемого»
|
||||
// выполняется с запасом.
|
||||
const PollIntervalMs = int64(appRateWindowSec * 1000 * pollBudgetShare / appRateMaxRequests)
|
||||
|
||||
// Значения параметра `copy` у адреса файла записи. Перечень закрыт, и каждое
|
||||
// значение называет ровно одну хранимую вещь: имя параметра нормативно наравне
|
||||
// со значениями — разбирает его каждый экран, и выбранное кодом оно стало бы
|
||||
// публичным контрактом молча.
|
||||
const (
|
||||
CopyParam = "copy"
|
||||
CopyOriginal = "original"
|
||||
CopyNormalized = "normalized"
|
||||
)
|
||||
|
||||
type AppHandler struct {
|
||||
recordRepo contract.AudioRecordRepository
|
||||
textRepo contract.TextRepository
|
||||
structureRepo contract.StructureRepository
|
||||
fileRepo contract.FileRepository
|
||||
trsService *service.TranscribeService
|
||||
logger *slog.Logger
|
||||
}
|
||||
@@ -74,6 +73,7 @@ func NewAppHandler(
|
||||
recordRepo contract.AudioRecordRepository,
|
||||
textRepo contract.TextRepository,
|
||||
structureRepo contract.StructureRepository,
|
||||
fileRepo contract.FileRepository,
|
||||
trsService *service.TranscribeService,
|
||||
logger *slog.Logger,
|
||||
) *AppHandler {
|
||||
@@ -84,6 +84,7 @@ func NewAppHandler(
|
||||
recordRepo: recordRepo,
|
||||
textRepo: textRepo,
|
||||
structureRepo: structureRepo,
|
||||
fileRepo: fileRepo,
|
||||
trsService: trsService,
|
||||
logger: logger,
|
||||
}
|
||||
@@ -95,8 +96,11 @@ func NewAppHandler(
|
||||
//
|
||||
// Машинного текста отказа здесь нет: он принадлежит журналу владельца сервиса.
|
||||
// Причина остановки — значение из закрытого перечня, и она не он: без причины
|
||||
// признак остановки не говорит человеку, чего ждать. Русскую фразу из значения
|
||||
// делает приложение — второй словарь фраз на сервере разошёлся бы с экраном.
|
||||
// признак остановки не говорит человеку, чего ждать.
|
||||
//
|
||||
// Перечня доступных копий файла здесь нет намеренно: копий две, и каждая
|
||||
// выводится из рубежа записи, который карточка несёт и так. Второе поле
|
||||
// повторяло бы рубеж и разошлось бы с ним молча.
|
||||
type RecordView struct {
|
||||
ID string `json:"id"`
|
||||
Title *string `json:"title"`
|
||||
@@ -120,11 +124,6 @@ type RecordView struct {
|
||||
}
|
||||
|
||||
// IntakeItem — элемент ответа приёма: карточка плюс признак повторного файла.
|
||||
//
|
||||
// Место под признак заведено вперёд и заполняется другой задачей. Форма
|
||||
// согласована один раз: приём, отдающий одну запись, пришлось бы переписывать
|
||||
// вместе с приёмом нескольких файлов, а экран загрузки — переделывать под вторую
|
||||
// форму.
|
||||
type IntakeItem struct {
|
||||
RecordView
|
||||
Duplicate bool `json:"duplicate"`
|
||||
@@ -161,68 +160,134 @@ type ReplicaView struct {
|
||||
Text string `json:"text"`
|
||||
}
|
||||
|
||||
// Register вешает адреса приложения на роутер хранилища. Порт у сервиса и у
|
||||
// панели один, поэтому и роутер один.
|
||||
func (h *AppHandler) Register(r *router.Router[*core.RequestEvent]) {
|
||||
app := r.Group(AppRoot)
|
||||
// Routes — адреса приложения одним обработчиком.
|
||||
//
|
||||
// Слоёв здесь нет: ограничитель частоты, узнавание и требование учётной записи
|
||||
// вешаются на **всю** цепочку корня приложения, а корень берётся из перечня
|
||||
// адресного пространства. Так область их действия выводится из объявленного
|
||||
// пространства, а не перечисляется вторым списком.
|
||||
//
|
||||
// Метод разбирается обработчиком, а не образцом маршрута: отказ маршрутизатора
|
||||
// на неверный метод ушёл бы его формой тела, а форма отказа под корнем
|
||||
// приложения одна.
|
||||
func (h *AppHandler) Routes() http.Handler {
|
||||
mux := http.NewServeMux()
|
||||
|
||||
// Слой формы отказа стоит первым и снаружи всех: отказы, рождённые ниже —
|
||||
// предел тела, ограничитель частоты, неизвестный путь под нашим корнем, —
|
||||
// иначе ушли бы телом библиотеки, мимо единой формы.
|
||||
//
|
||||
// Слоя узнавания здесь нет: он вешается корневым, потому что накрывает ещё и
|
||||
// адрес выдачи файлового токена из пространства хранилища. Область его
|
||||
// действия при этом выводится из **этого же** перечня адресного
|
||||
// пространства — см. `underIdentifiedArea`.
|
||||
app.Bind(OneErrorForm())
|
||||
app.Bind(RequireUser(migrations.UsersCollection))
|
||||
for _, pattern := range AppRoutePatterns {
|
||||
mux.HandleFunc(pattern, h.handlerOf(pattern))
|
||||
}
|
||||
|
||||
app.GET("/me", h.Me)
|
||||
app.GET("/config", h.Config)
|
||||
// Перехват «под нашим корнем такого адреса нет». Голый корень попадает сюда
|
||||
// же: он принадлежит корню приложения, адресом приложения не является и
|
||||
// потому отвечает как неизвестный путь под ним.
|
||||
//
|
||||
// Оба образца обязательны: без точного `/app` маршрутизатор увёл бы его
|
||||
// перенаправлением на `/app/`, а перенаправления норма не заказывала. В
|
||||
// закрытый перечень образцов они не входят: под них подходит **всё**, что
|
||||
// накрыто корнем, а значит путь под ними выбирает спрашивающий.
|
||||
mux.HandleFunc(AppRoot+"/", h.notFound)
|
||||
mux.HandleFunc(AppRoot, h.notFound)
|
||||
|
||||
// Приём стоит тем же адресом, что и список, и отличается только методом: он
|
||||
// заводит аудиозапись, а не кладёт файл.
|
||||
//
|
||||
// Умолчание роутера хранилища — 32 МиБ на тело, и оно отсекало бы запись
|
||||
// раньше обработчика, без строки в журнале приёма. Предел тела равен потолку
|
||||
// самой записи, а отказ по нему уходит нашей формой.
|
||||
app.POST("/audiorecords", h.CreateRecord).Bind(apis.BodyLimit(entity.MaxRecordSize))
|
||||
app.GET("/audiorecords", h.ListRecords)
|
||||
app.GET("/audiorecords/{id}", h.GetRecord)
|
||||
app.GET("/audiorecords/{id}/text", h.GetRecordText)
|
||||
|
||||
// Перехват «под нашим корнем такого адреса нет».
|
||||
//
|
||||
// Слой единой формы его не покрывает, и это не оплошность приоритета: отказ
|
||||
// «ничего не совпало» рождается маршрутом **корневой** группы, к которому
|
||||
// слои группы `/app` не привязаны вовсе. Без своего перехвата неизвестный
|
||||
// путь и неверный метод отвечали бы телом библиотеки — то есть форм отказа
|
||||
// под корнем приложения было бы две.
|
||||
//
|
||||
// Маршрут стоит за слоем предъявления, поэтому неизвестный путь без сессии
|
||||
// отвечает `401`, а не `404`, — ровно так же, как отвечают все прочие адреса
|
||||
// приложения, и по той же причине: сперва «кто спрашивает», потом «что».
|
||||
app.Any("/{path...}", func(e *core.RequestEvent) error {
|
||||
return fail(e, errWithMessage(contract.ErrNotFound, "Адрес не найден"))
|
||||
})
|
||||
return mux
|
||||
}
|
||||
|
||||
func (h *AppHandler) Me(e *core.RequestEvent) error {
|
||||
// Образцы адресов приложения. Перечень закрытый и **единственный**: из него
|
||||
// вешаются обработчики, и из него же берётся значение `http.route` для журнала.
|
||||
// Второй список образцов разошёлся бы с первым молча, и разошёлся бы в сторону
|
||||
// журнала — путь, не попавший в перечень, уехал бы в строку дословно.
|
||||
const (
|
||||
AppRouteMe = AppRoot + "/me"
|
||||
AppRouteConfig = AppRoot + "/config"
|
||||
AppRouteRecords = AppRoot + "/audiorecords"
|
||||
AppRouteRecord = AppRoot + "/audiorecords/{id}"
|
||||
AppRouteRecordText = AppRoot + "/audiorecords/{id}/text"
|
||||
AppRouteRecordFile = AppRoot + "/audiorecords/{id}/file"
|
||||
)
|
||||
|
||||
// AppRoutePatterns — тот самый перечень. Порядок значения не имеет:
|
||||
// маршрутизатор выбирает образец по точности, а не по месту в списке.
|
||||
var AppRoutePatterns = []string{
|
||||
AppRouteMe,
|
||||
AppRouteConfig,
|
||||
AppRouteRecords,
|
||||
AppRouteRecord,
|
||||
AppRouteRecordText,
|
||||
AppRouteRecordFile,
|
||||
}
|
||||
|
||||
// handlerOf выдаёт обработчик образца.
|
||||
//
|
||||
// Ветка на каждый образец, а не карта рядом с перечнем: недостающий образец
|
||||
// здесь — отказ на подъёме, а не тихо не заведённый адрес.
|
||||
func (h *AppHandler) handlerOf(pattern string) http.HandlerFunc {
|
||||
switch pattern {
|
||||
case AppRouteMe:
|
||||
return only(h.Me, http.MethodGet)
|
||||
case AppRouteConfig:
|
||||
return only(h.Config, http.MethodGet)
|
||||
// Приём стоит тем же адресом, что и список, и отличается только методом: он
|
||||
// заводит аудиозапись, а не кладёт файл.
|
||||
case AppRouteRecords:
|
||||
return h.records
|
||||
case AppRouteRecord:
|
||||
return only(h.GetRecord, http.MethodGet)
|
||||
case AppRouteRecordText:
|
||||
return only(h.GetRecordText, http.MethodGet)
|
||||
case AppRouteRecordFile:
|
||||
return only(h.GetRecordFile, http.MethodGet, http.MethodHead)
|
||||
}
|
||||
|
||||
panic("адрес приложения " + pattern + " объявлен перечнем, но обработчика у него нет")
|
||||
}
|
||||
|
||||
// only ограничивает адрес перечнем методов.
|
||||
//
|
||||
// Неверный метод отвечает «адреса нет»: код отказа принадлежит закрытому
|
||||
// перечню, и своего значения у «метод не тот» в нём не заведено — адрес,
|
||||
// которого нет для этого метода, и есть ненайденный адрес.
|
||||
func only(handler http.HandlerFunc, methods ...string) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
for _, method := range methods {
|
||||
if r.Method == method {
|
||||
handler(w, r)
|
||||
return
|
||||
}
|
||||
}
|
||||
fail(w, errWithMessage(contract.ErrNotFound, "Адрес не найден"))
|
||||
}
|
||||
}
|
||||
|
||||
func (h *AppHandler) notFound(w http.ResponseWriter, _ *http.Request) {
|
||||
fail(w, errWithMessage(contract.ErrNotFound, "Адрес не найден"))
|
||||
}
|
||||
|
||||
// records — приём и список одним адресом: разница только в методе.
|
||||
func (h *AppHandler) records(w http.ResponseWriter, r *http.Request) {
|
||||
switch r.Method {
|
||||
case http.MethodGet:
|
||||
h.ListRecords(w, r)
|
||||
case http.MethodPost:
|
||||
h.CreateRecord(w, r)
|
||||
default:
|
||||
fail(w, errWithMessage(contract.ErrNotFound, "Адрес не найден"))
|
||||
}
|
||||
}
|
||||
|
||||
func (h *AppHandler) Me(w http.ResponseWriter, r *http.Request) {
|
||||
account, _ := AccountOf(r)
|
||||
|
||||
// Адрес почты в ответ не идёт: он приходит от провайдера и принадлежит
|
||||
// человеку, а не сервису. Логин у провайдера — тоже: это его имя у
|
||||
// провайдера, и правило о непечатаемых значениях запрещает ему выходить
|
||||
// наружу наравне с журналом.
|
||||
return e.JSON(http.StatusOK, MeView{
|
||||
ID: e.Auth.Id,
|
||||
Name: e.Auth.GetString("name"),
|
||||
})
|
||||
writeJSON(w, http.StatusOK, MeView{ID: account.ID, Name: account.Name})
|
||||
}
|
||||
|
||||
func (h *AppHandler) Config(e *core.RequestEvent) error {
|
||||
func (h *AppHandler) Config(w http.ResponseWriter, _ *http.Request) {
|
||||
// Каждый предел — то же значение, которое сервис применяет, а не его копия.
|
||||
// Приложение, знающее предел своей константой, расходится с сервером молча —
|
||||
// до первого отказа на записи, которую человек уже успел отправить.
|
||||
return e.JSON(http.StatusOK, ConfigView{
|
||||
writeJSON(w, http.StatusOK, ConfigView{
|
||||
MaxRecordSizeBytes: entity.MaxRecordSize,
|
||||
MaxPageSize: MaxPageLimit,
|
||||
PollIntervalMs: PollIntervalMs,
|
||||
@@ -231,17 +296,35 @@ func (h *AppHandler) Config(e *core.RequestEvent) error {
|
||||
})
|
||||
}
|
||||
|
||||
func (h *AppHandler) CreateRecord(e *core.RequestEvent) error {
|
||||
file, header, err := e.Request.FormFile("audio")
|
||||
func (h *AppHandler) CreateRecord(w http.ResponseWriter, r *http.Request) {
|
||||
account, _ := AccountOf(r)
|
||||
|
||||
// Предел тела назван числом: умолчания здесь не «без предела», а величины на
|
||||
// два-три порядка меньше нужного, и оставленные как есть они отвергли бы
|
||||
// штатную запись сервиса. Отказ по нему уходит нашей формой тела.
|
||||
//
|
||||
// Ловится он **дважды**, и это не избыточность. Объявленная длина судится
|
||||
// заранее: запись, за которую сервис платить не станет, не должна попасть
|
||||
// даже в память. Необъявленная и солгавшая ловятся на чтении — объявленной
|
||||
// длины у запроса с кусочной передачей нет вовсе.
|
||||
if r.ContentLength > entity.MaxRecordSize {
|
||||
fail(w, contract.ErrRecordTooLarge)
|
||||
return
|
||||
}
|
||||
r.Body = http.MaxBytesReader(w, r.Body, entity.MaxRecordSize)
|
||||
|
||||
file, header, err := r.FormFile("audio")
|
||||
if err != nil {
|
||||
// Предел тела ловит объявленную длину заранее, слоем; необъявленную —
|
||||
// на чтении, уже здесь. Не различив эти два отказа, приём сказал бы
|
||||
// человеку «вы не приложили файл» о записи, которую он приложил и
|
||||
// которая просто больше потолка.
|
||||
if errors.Is(err, apis.ErrRequestEntityTooLarge) {
|
||||
return fail(e, contract.ErrRecordTooLarge)
|
||||
// Предел тела ловит запись на чтении. Не различив этот отказ и
|
||||
// отсутствующее поле, приём сказал бы человеку «вы не приложили файл» о
|
||||
// записи, которую он приложил и которая просто больше потолка.
|
||||
var tooLarge *http.MaxBytesError
|
||||
if errors.As(err, &tooLarge) {
|
||||
fail(w, contract.ErrRecordTooLarge)
|
||||
return
|
||||
}
|
||||
return fail(e, errWithMessage(contract.ErrBadRequest, "Запись не приложена к запросу"))
|
||||
fail(w, errWithMessage(contract.ErrBadRequest, "Запись не приложена к запросу"))
|
||||
return
|
||||
}
|
||||
defer func() {
|
||||
if err := file.Close(); err != nil {
|
||||
@@ -252,71 +335,78 @@ func (h *AppHandler) CreateRecord(e *core.RequestEvent) error {
|
||||
// Запись доехала целиком, поэтому она заводится независимо от того, дождётся
|
||||
// ли отправитель ответа: на контексте запроса приём терял бы полностью
|
||||
// загруженную запись от одного обрыва соединения, а забрать результат он
|
||||
// может и позже — карточкой записи. Значения контекста (журнал запроса,
|
||||
// сессия) при этом сохраняются, теряется только отмена.
|
||||
ctx := context.WithoutCancel(e.Request.Context())
|
||||
// может и позже — карточкой записи.
|
||||
ctx := context.WithoutCancel(r.Context())
|
||||
|
||||
// Владелец берётся из предъявленной сессии и ниоткуда больше: владелец,
|
||||
// пришедший полем запроса, дал бы всякому вошедшему право завести запись на
|
||||
// Владелец берётся из узнанного предъявителя и ниоткуда больше: владелец,
|
||||
// пришедший полем запроса, дал бы всякому узнанному право завести запись на
|
||||
// чужое имя.
|
||||
record, err := h.trsService.CreateJobFromApi(ctx, file, header.Filename, e.Auth.Id)
|
||||
record, err := h.trsService.CreateJobFromApi(ctx, file, header.Filename, account.ID)
|
||||
if err != nil {
|
||||
// Второй раз отказ не логируем: приём назван конвенцией логирующей
|
||||
// границей и уже написал о нём. Транспорт переводит ошибку в ответ, и
|
||||
// делает это одним местом — по причине отказа, а не по месту.
|
||||
return fail(e, err)
|
||||
fail(w, err)
|
||||
return
|
||||
}
|
||||
|
||||
// Ответ списком, даже когда файл в запросе один: форма согласована вперёд,
|
||||
// чтобы приём нескольких файлов и распознавание повтора её не переписывали.
|
||||
return e.JSON(http.StatusCreated, []IntakeItem{{
|
||||
writeJSON(w, http.StatusCreated, []IntakeItem{{
|
||||
// Свежая запись текстов не имеет, но поле обязано быть на проводе:
|
||||
// отсутствие поля и пустой перечень приложение не различит.
|
||||
RecordView: h.viewOf(record, nil, &[]string{}),
|
||||
}})
|
||||
}
|
||||
|
||||
func (h *AppHandler) ListRecords(e *core.RequestEvent) error {
|
||||
q := contract.RecordQuery{OwnerID: e.Auth.Id, Limit: DefaultPageLimit}
|
||||
func (h *AppHandler) ListRecords(w http.ResponseWriter, r *http.Request) {
|
||||
account, _ := AccountOf(r)
|
||||
|
||||
if raw := e.Request.URL.Query().Get("limit"); raw != "" {
|
||||
q := contract.RecordQuery{OwnerID: account.ID, Limit: DefaultPageLimit}
|
||||
|
||||
if raw := r.URL.Query().Get("limit"); raw != "" {
|
||||
limit, err := strconv.Atoi(raw)
|
||||
if err != nil || limit <= 0 {
|
||||
return fail(e, errWithMessage(contract.ErrBadRequest, "Размер страницы должен быть положительным числом"))
|
||||
fail(w, errWithMessage(contract.ErrBadRequest, "Размер страницы должен быть положительным числом"))
|
||||
return
|
||||
}
|
||||
// Сверх потолка — усечение, а не отказ: человек попросил больше, чем
|
||||
// сервис отдаёт, но просьба сама по себе не негодна.
|
||||
q.Limit = min(limit, MaxPageLimit)
|
||||
}
|
||||
|
||||
if raw := e.Request.URL.Query().Get("filter"); raw != "" {
|
||||
if raw := r.URL.Query().Get("filter"); raw != "" {
|
||||
filter, ok := entity.ParseListFilter(raw)
|
||||
if !ok {
|
||||
return fail(e, errWithMessage(contract.ErrBadRequest, "Неизвестное состояние отбора"))
|
||||
fail(w, errWithMessage(contract.ErrBadRequest, "Неизвестное состояние отбора"))
|
||||
return
|
||||
}
|
||||
q.Filter = &filter
|
||||
}
|
||||
|
||||
if raw := e.Request.URL.Query().Get("cursor"); raw != "" {
|
||||
if raw := r.URL.Query().Get("cursor"); raw != "" {
|
||||
cursor, err := decodeCursor(raw)
|
||||
if err != nil {
|
||||
// Молчаливая отдача первой страницы вместо отказа дала бы человеку
|
||||
// архив, листающийся по кругу, и ни строки в журнале.
|
||||
return fail(e, errWithMessage(contract.ErrBadRequest, "Ключ страницы не читается"))
|
||||
fail(w, errWithMessage(contract.ErrBadRequest, "Ключ страницы не читается"))
|
||||
return
|
||||
}
|
||||
q.Cursor = cursor
|
||||
}
|
||||
|
||||
page, err := h.recordRepo.List(q)
|
||||
if err != nil {
|
||||
h.logger.Error("Failed to list audio records", "error", err, "owner_id", e.Auth.Id)
|
||||
return fail(e, err)
|
||||
h.logger.Error("Failed to list audio records", "error", err, "owner_id", account.ID)
|
||||
fail(w, err)
|
||||
return
|
||||
}
|
||||
|
||||
names, err := h.topicNames(e.Auth.Id, page.Items)
|
||||
names, err := h.topicNames(account.ID, page.Items)
|
||||
if err != nil {
|
||||
h.logger.Error("Failed to resolve topics", "error", err, "owner_id", e.Auth.Id)
|
||||
return fail(e, err)
|
||||
h.logger.Error("Failed to resolve topics", "error", err, "owner_id", account.ID)
|
||||
fail(w, err)
|
||||
return
|
||||
}
|
||||
|
||||
view := PageView{Items: make([]RecordView, 0, len(page.Items)), TotalItems: page.TotalItems}
|
||||
@@ -331,55 +421,71 @@ func (h *AppHandler) ListRecords(e *core.RequestEvent) error {
|
||||
view.NextCursor = &encoded
|
||||
}
|
||||
|
||||
return e.JSON(http.StatusOK, view)
|
||||
writeJSON(w, http.StatusOK, view)
|
||||
}
|
||||
|
||||
func (h *AppHandler) GetRecord(e *core.RequestEvent) error {
|
||||
record, err := h.readOwn(e)
|
||||
func (h *AppHandler) GetRecord(w http.ResponseWriter, r *http.Request) {
|
||||
account, _ := AccountOf(r)
|
||||
|
||||
record, err := h.readOwn(r, account.ID)
|
||||
if err != nil {
|
||||
return fail(e, err)
|
||||
fail(w, err)
|
||||
return
|
||||
}
|
||||
|
||||
names, err := h.topicNames(e.Auth.Id, []*entity.AudioRecord{record})
|
||||
names, err := h.topicNames(account.ID, []*entity.AudioRecord{record})
|
||||
if err != nil {
|
||||
h.logger.Error("Failed to resolve topics", "error", err, "record_id", record.Id)
|
||||
return fail(e, err)
|
||||
fail(w, err)
|
||||
return
|
||||
}
|
||||
|
||||
views := h.availableViews(record)
|
||||
return e.JSON(http.StatusOK, h.viewOf(record, names, &views))
|
||||
writeJSON(w, http.StatusOK, h.viewOf(record, names, &views))
|
||||
}
|
||||
|
||||
func (h *AppHandler) GetRecordText(e *core.RequestEvent) error {
|
||||
view := e.Request.URL.Query().Get("view")
|
||||
func (h *AppHandler) GetRecordText(w http.ResponseWriter, r *http.Request) {
|
||||
account, _ := AccountOf(r)
|
||||
|
||||
view := r.URL.Query().Get("view")
|
||||
if !entity.IsKnownTextView(view) {
|
||||
return fail(e, errWithMessage(contract.ErrBadRequest, "Неизвестный вид текста"))
|
||||
fail(w, errWithMessage(contract.ErrBadRequest, "Неизвестный вид текста"))
|
||||
return
|
||||
}
|
||||
|
||||
record, err := h.readOwn(e)
|
||||
record, err := h.readOwn(r, account.ID)
|
||||
if err != nil {
|
||||
return fail(e, err)
|
||||
fail(w, err)
|
||||
return
|
||||
}
|
||||
|
||||
if view == entity.TextViewReplicas {
|
||||
return h.replicasOf(e, record)
|
||||
h.replicasOf(w, record)
|
||||
return
|
||||
}
|
||||
return h.plainTextOf(e, record, view)
|
||||
h.plainTextOf(w, record, view)
|
||||
}
|
||||
|
||||
// readOwn читает запись спрашивающего. Чужая, ничья и несуществующая отвечают
|
||||
// одним и тем же: по разнице ответов иначе перебирается список заведённых
|
||||
// записей.
|
||||
func (h *AppHandler) readOwn(e *core.RequestEvent) (*entity.AudioRecord, error) {
|
||||
recordID := e.Request.PathValue("id")
|
||||
// readOwn читает запись спрашивающего. Чужая, ничья, несуществующая и
|
||||
// нечитаемая по виду идентификатора отвечают одним и тем же: по разнице ответов
|
||||
// иначе перебирается список заведённых записей.
|
||||
func (h *AppHandler) readOwn(r *http.Request, ownerID string) (*entity.AudioRecord, error) {
|
||||
// Идентификатор разбирается на границе: он приходит от спрашивающего, а
|
||||
// сравнение в базе побайтово — запись в верхнем регистре не совпала бы ни с
|
||||
// одной строкой. Негодный по виду считается несуществующим и до базы не
|
||||
// доходит вовсе.
|
||||
recordID, ok := ident.Parse(r.PathValue("id"))
|
||||
if !ok {
|
||||
return nil, &contract.JobNotFoundError{Message: "record not found"}
|
||||
}
|
||||
|
||||
record, err := h.recordRepo.GetByID(recordID, e.Auth.Id)
|
||||
record, err := h.recordRepo.GetByID(recordID, ownerID)
|
||||
if err != nil {
|
||||
// Наружу ответ один на все исходы, а в журнал они идут по-разному.
|
||||
// «Записи нет» и «запись чужая» — штатная работа разграничения, о ней
|
||||
// писать нечего; всё прочее — отказ хранилища, и без этой строки он
|
||||
// приходит отправителю как «вашей записи нет», а владелец сервиса об
|
||||
// аварии не узнаёт ниоткуда.
|
||||
// писать нечего; всё прочее — отказ базы, и без этой строки он приходит
|
||||
// отправителю как «вашей записи нет», а владелец сервиса об аварии не
|
||||
// узнаёт ниоткуда.
|
||||
var notFound *contract.JobNotFoundError
|
||||
if !errors.As(err, ¬Found) {
|
||||
h.logger.Error("Failed to read audio record", "error", err, "record_id", recordID)
|
||||
@@ -389,52 +495,62 @@ func (h *AppHandler) readOwn(e *core.RequestEvent) (*entity.AudioRecord, error)
|
||||
return record, nil
|
||||
}
|
||||
|
||||
func (h *AppHandler) plainTextOf(e *core.RequestEvent, record *entity.AudioRecord, view string) error {
|
||||
func (h *AppHandler) plainTextOf(w http.ResponseWriter, record *entity.AudioRecord, view string) {
|
||||
textID := record.TranscriptTextID
|
||||
if view == entity.TextViewLiterary {
|
||||
textID = record.LiteraryTextID
|
||||
}
|
||||
|
||||
if textID == nil {
|
||||
return fail(e, contract.ErrTextNotReady)
|
||||
fail(w, contract.ErrTextNotReady)
|
||||
return
|
||||
}
|
||||
|
||||
text, err := h.textRepo.GetByID(*textID)
|
||||
if err != nil {
|
||||
h.logger.Error("Failed to read text", "error", err, "record_id", record.Id)
|
||||
return fail(e, err)
|
||||
fail(w, err)
|
||||
return
|
||||
}
|
||||
if text.Contents == "" {
|
||||
return fail(e, contract.ErrTextNotReady)
|
||||
fail(w, contract.ErrTextNotReady)
|
||||
return
|
||||
}
|
||||
|
||||
return e.JSON(http.StatusOK, TextView{View: view, Contents: text.Contents})
|
||||
writeJSON(w, http.StatusOK, TextView{View: view, Contents: text.Contents})
|
||||
}
|
||||
|
||||
func (h *AppHandler) replicasOf(e *core.RequestEvent, record *entity.AudioRecord) error {
|
||||
func (h *AppHandler) replicasOf(w http.ResponseWriter, record *entity.AudioRecord) {
|
||||
if record.StructureID == nil {
|
||||
return fail(e, contract.ErrTextNotReady)
|
||||
fail(w, contract.ErrTextNotReady)
|
||||
return
|
||||
}
|
||||
|
||||
structure, err := h.structureRepo.GetByID(*record.StructureID)
|
||||
if err != nil {
|
||||
h.logger.Error("Failed to read structure", "error", err, "record_id", record.Id)
|
||||
return fail(e, err)
|
||||
fail(w, err)
|
||||
return
|
||||
}
|
||||
if len(structure.Replicas) == 0 {
|
||||
return fail(e, contract.ErrTextNotReady)
|
||||
fail(w, contract.ErrTextNotReady)
|
||||
return
|
||||
}
|
||||
|
||||
replicas := make([]ReplicaView, 0, len(structure.Replicas))
|
||||
for _, r := range structure.Replicas {
|
||||
replicas = append(replicas, ReplicaView{StartMs: r.StartMs, EndMs: r.EndMs, Text: r.Text})
|
||||
for _, replica := range structure.Replicas {
|
||||
replicas = append(replicas, ReplicaView{
|
||||
StartMs: replica.StartMs,
|
||||
EndMs: replica.EndMs,
|
||||
Text: replica.Text,
|
||||
})
|
||||
}
|
||||
|
||||
return e.JSON(http.StatusOK, TextView{View: entity.TextViewReplicas, Replicas: replicas})
|
||||
writeJSON(w, http.StatusOK, TextView{View: entity.TextViewReplicas, Replicas: replicas})
|
||||
}
|
||||
|
||||
// topicNames разрешает темы всех записей страницы **одним** запросом: страница в
|
||||
// сотню записей иначе стоила бы сотни обращений к хранилищу.
|
||||
// сотню записей иначе стоила бы сотни обращений к базе.
|
||||
func (h *AppHandler) topicNames(ownerID string, records []*entity.AudioRecord) (map[string]string, error) {
|
||||
seen := map[string]bool{}
|
||||
ids := []string{}
|
||||
@@ -481,9 +597,7 @@ func (h *AppHandler) viewOf(record *entity.AudioRecord, names map[string]string,
|
||||
// Вид считается доступным по **содержимому**, а не по наличию ссылки. Ссылка
|
||||
// без содержимого — состояние штатное: пустой ответ распознавания проект признаёт
|
||||
// нормой и записывает его в журнал. Строй мы перечень по ссылкам, карточка
|
||||
// объявляла бы вид доступным, а адрес текста отвечал бы «ещё не готов» вечно:
|
||||
// приложение опрашивало бы его без конца, а человек видел бы завершённую запись,
|
||||
// из которой текст «вот-вот появится».
|
||||
// объявляла бы вид доступным, а адрес текста отвечал бы «ещё не готов» вечно.
|
||||
func (h *AppHandler) availableViews(record *entity.AudioRecord) []string {
|
||||
views := []string{}
|
||||
|
||||
@@ -501,8 +615,7 @@ func (h *AppHandler) availableViews(record *entity.AudioRecord) []string {
|
||||
|
||||
// hasText — есть ли у записи непустой текст этого вида. Отказ чтения читается
|
||||
// как «вида нет»: перечень доступных видов — подсказка приложению, и уронить
|
||||
// из-за неё карточку хуже, чем недосказать. Сам отказ виден владельцу сервиса
|
||||
// журналом, который пишет чтение текста.
|
||||
// из-за неё карточку хуже, чем недосказать.
|
||||
func (h *AppHandler) hasText(textID *string) bool {
|
||||
if textID == nil {
|
||||
return false
|
||||
@@ -531,11 +644,12 @@ func (h *AppHandler) hasReplicas(structureID *string) bool {
|
||||
// непрозрачной строкой: спрашивающему её содержимое не принадлежит, а
|
||||
// составлять ключ руками значило бы завязаться на порядок сортировки.
|
||||
//
|
||||
// Кодировка нужна и по существу: время заведения несёт пробел, и голая пара
|
||||
// разорвала бы строку запроса. Кодирование без набивки и в адресном алфавите —
|
||||
// ключ уезжает параметром, а не телом.
|
||||
// Кодирование без набивки и в адресном алфавите — ключ уезжает параметром, а не
|
||||
// телом.
|
||||
func encodeCursor(c *contract.RecordCursor) string {
|
||||
return base64.RawURLEncoding.EncodeToString([]byte(c.CreatedAt + "|" + c.ID))
|
||||
return base64.RawURLEncoding.EncodeToString(
|
||||
[]byte(c.CreatedAt.UTC().Format(time.RFC3339) + "|" + c.ID),
|
||||
)
|
||||
}
|
||||
|
||||
func decodeCursor(raw string) (*contract.RecordCursor, error) {
|
||||
@@ -545,32 +659,23 @@ func decodeCursor(raw string) (*contract.RecordCursor, error) {
|
||||
}
|
||||
|
||||
createdAt, id, ok := strings.Cut(string(decoded), "|")
|
||||
if !ok || createdAt == "" || id == "" {
|
||||
if !ok {
|
||||
return nil, errors.New("malformed cursor")
|
||||
}
|
||||
|
||||
// Время разбирается, а не берётся строкой: в запрос оно уходит побайтовым
|
||||
// сравнением, и вид, разошедшийся с тем, каким пишет хранилище, молча
|
||||
// обращает условие в постоянную истину или ложь — человек получает либо
|
||||
// пустой архив при непустом счётчике, либо ленту с начала.
|
||||
parsed, err := types.ParseDateTime(createdAt)
|
||||
if err != nil || parsed.IsZero() {
|
||||
// Обе половины ключа разбираются, а не берутся строкой: время уходит в
|
||||
// запрос сравнением, а идентификатор — точным совпадением, и негодная
|
||||
// половина дала бы человеку либо пустой архив при непустом счётчике, либо
|
||||
// ленту с начала.
|
||||
parsed, err := time.Parse(time.RFC3339, createdAt)
|
||||
if err != nil {
|
||||
return nil, errors.New("cursor carries no readable time")
|
||||
}
|
||||
|
||||
return &contract.RecordCursor{CreatedAt: parsed.String(), ID: id}, nil
|
||||
}
|
||||
recordID, valid := ident.Parse(id)
|
||||
if !valid {
|
||||
return nil, errors.New("cursor carries no readable record key")
|
||||
}
|
||||
|
||||
// errWithMessage приклеивает к признаку негодного ввода свой текст: причина у
|
||||
// всех одна, а сказать человеку надо разное.
|
||||
func errWithMessage(base error, message string) error {
|
||||
return &messagedError{base: base, message: message}
|
||||
return &contract.RecordCursor{CreatedAt: parsed.UTC(), ID: recordID}, nil
|
||||
}
|
||||
|
||||
type messagedError struct {
|
||||
base error
|
||||
message string
|
||||
}
|
||||
|
||||
func (e *messagedError) Error() string { return e.message }
|
||||
func (e *messagedError) Unwrap() error { return e.base }
|
||||
|
||||
@@ -7,16 +7,13 @@ import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/pocketbase/pocketbase/apis"
|
||||
"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/adapter/repo/pocketbase/migrations"
|
||||
"git.vakhrushev.me/av/transcriber/internal/entity"
|
||||
)
|
||||
|
||||
// Проверки этого файла судят допуск: кого пускают к приёму и опросу, чем
|
||||
// Проверки этого файла судят допуск: кого пускают к адресам приложения, чем
|
||||
// называется пришедший, кому верят и какие адреса остаются открытыми.
|
||||
|
||||
// TestApiRequiresIdentity — первый критерий приёмки. Запрос неузнанного получает
|
||||
@@ -33,31 +30,22 @@ func TestApiRequiresIdentity(t *testing.T) {
|
||||
assert.Equal(t, http.StatusUnauthorized, w.Code)
|
||||
assert.NotContains(t, w.Body.String(), "job_id")
|
||||
|
||||
// Ни файла, ни задачи: отказ наступает раньше, чем запись попадает в
|
||||
// хранилище.
|
||||
files, err := env.app.FindAllRecords(migrations.FilesCollection)
|
||||
require.NoError(t, err)
|
||||
assert.Empty(t, files)
|
||||
|
||||
jobs, err := env.app.FindAllRecords(migrations.RecordsCollection)
|
||||
require.NoError(t, err)
|
||||
assert.Empty(t, jobs)
|
||||
// Ни файла, ни записи: отказ наступает раньше, чем запись попадает в
|
||||
// каталог данных.
|
||||
assert.Equal(t, 0, countFiles(t, env))
|
||||
assert.Equal(t, 0, countJobs(t, env))
|
||||
})
|
||||
|
||||
t.Run("карточка записи неузнанному", func(t *testing.T) {
|
||||
req := httptest.NewRequest(http.MethodGet, "/app/audiorecords/anything", nil)
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
env.mux.ServeHTTP(w, req)
|
||||
w := env.get("/app/audiorecords/" + strings.Repeat("0", 26))
|
||||
|
||||
assert.Equal(t, http.StatusUnauthorized, w.Code)
|
||||
assert.NotContains(t, w.Body.String(), "transcription_text")
|
||||
assert.NotContains(t, w.Body.String(), "created_at")
|
||||
})
|
||||
}
|
||||
|
||||
// TestUnknownJobIsIndistinguishableWithoutIdentity: по кодам ответа неузнанному
|
||||
// не перебирается список заведённых задач.
|
||||
// не перебирается список заведённых записей.
|
||||
func TestUnknownJobIsIndistinguishableWithoutIdentity(t *testing.T) {
|
||||
env := setupTestEnv(t, readableMetaViewer())
|
||||
|
||||
@@ -65,24 +53,16 @@ func TestUnknownJobIsIndistinguishableWithoutIdentity(t *testing.T) {
|
||||
env.serve(created, createMultipartRequest(t, "test.mp3", []byte("audio")))
|
||||
require.Equal(t, http.StatusCreated, created.Code)
|
||||
|
||||
jobs, err := env.app.FindAllRecords(migrations.RecordsCollection)
|
||||
require.NoError(t, err)
|
||||
require.Len(t, jobs, 1)
|
||||
|
||||
existing := httptest.NewRecorder()
|
||||
env.mux.ServeHTTP(existing, httptest.NewRequest(http.MethodGet, "/app/audiorecords/"+jobs[0].Id, nil))
|
||||
|
||||
missing := httptest.NewRecorder()
|
||||
env.mux.ServeHTTP(missing, httptest.NewRequest(http.MethodGet, "/app/audiorecords/nosuchjobid", nil))
|
||||
existing := env.get("/app/audiorecords/" + intakeItemOf(t, created).ID)
|
||||
missing := env.get("/app/audiorecords/" + strings.Repeat("0", 26))
|
||||
|
||||
assert.Equal(t, http.StatusUnauthorized, existing.Code)
|
||||
assert.Equal(t, missing.Code, existing.Code)
|
||||
assert.Equal(t, missing.Body.String(), existing.Body.String())
|
||||
}
|
||||
|
||||
// TestFirstRequestCreatesAccountAndSecondReuses — **первый критерий приёмки**.
|
||||
//
|
||||
// Два запроса подряд с одним значением заголовка: учётная запись заводится
|
||||
// первым и находится вторым, а в хранилище её строка одна.
|
||||
// TestFirstRequestCreatesAccountAndSecondReuses — учётная запись заводится
|
||||
// первым обращением и находится вторым, а строка её в базе одна.
|
||||
func TestFirstRequestCreatesAccountAndSecondReuses(t *testing.T) {
|
||||
env := setupTestEnv(t, readableMetaViewer())
|
||||
|
||||
@@ -90,12 +70,10 @@ func TestFirstRequestCreatesAccountAndSecondReuses(t *testing.T) {
|
||||
|
||||
before := countAccounts(t, env)
|
||||
|
||||
first := httptest.NewRecorder()
|
||||
env.mux.ServeHTTP(first, asUser(httptest.NewRequest(http.MethodGet, "/app/me", nil), login))
|
||||
first := env.getAs(login, "/app/me")
|
||||
require.Equal(t, http.StatusOK, first.Code, "первое обращение узнано")
|
||||
|
||||
second := httptest.NewRecorder()
|
||||
env.mux.ServeHTTP(second, asUser(httptest.NewRequest(http.MethodGet, "/app/me", nil), login))
|
||||
second := env.getAs(login, "/app/me")
|
||||
require.Equal(t, http.StatusOK, second.Code)
|
||||
|
||||
assert.Equal(t, before+1, countAccounts(t, env),
|
||||
@@ -104,8 +82,8 @@ func TestFirstRequestCreatesAccountAndSecondReuses(t *testing.T) {
|
||||
"второе обращение попало в другую учётную запись")
|
||||
}
|
||||
|
||||
// TestUntrustedPeerIsNotIdentified — **второй критерий приёмки**. Тот же
|
||||
// заголовок с недоверенного адреса даёт отказ, а не вход под названным именем.
|
||||
// TestUntrustedPeerIsNotIdentified — тот же заголовок с недоверенного адреса
|
||||
// даёт отказ, а не вход под названным именем.
|
||||
func TestUntrustedPeerIsNotIdentified(t *testing.T) {
|
||||
env := setupTestEnv(t, readableMetaViewer())
|
||||
|
||||
@@ -123,118 +101,75 @@ func TestUntrustedPeerIsNotIdentified(t *testing.T) {
|
||||
"заголовок с недоверенного адреса завёл учётную запись")
|
||||
}
|
||||
|
||||
// TestStorageOwnLoginAddressesGiveNothing — **третий критерий приёмки**.
|
||||
// TestStorageAddressSpaceIsGone — **критерий приёмки**: пространства хранилища
|
||||
// не существует.
|
||||
//
|
||||
// Перечень собственных адресов входа хранилища: каждый отвечает отказом и
|
||||
// учётной записи не меняет. Перечень закрыт и назван поимённо — пока хоть один
|
||||
// из них работает, узнавание по заголовку обходится двумя запросами.
|
||||
func TestStorageOwnLoginAddressesGiveNothing(t *testing.T) {
|
||||
// Прежде под корнем `/api/` жила собственная поверхность встроенного хранилища:
|
||||
// собственные входы, перечисление коллекции пользователей, правка своей строки —
|
||||
// то есть путь захвата чужого имени. Хранилище ушло целиком, и адресов этих нет:
|
||||
// они отвечают тем же, чем отвечает всякий путь вне корней сервиса.
|
||||
//
|
||||
// Проверка судит **и то, что ответ прежний, и то, что ничего не произошло**:
|
||||
// число учётных записей после обхода то же самое.
|
||||
func TestStorageAddressSpaceIsGone(t *testing.T) {
|
||||
env := setupTestEnv(t, readableMetaViewer())
|
||||
|
||||
const usersRoot = "/api/collections/users"
|
||||
before := countAccounts(t, env)
|
||||
|
||||
cases := []struct {
|
||||
name string
|
||||
path string
|
||||
body string
|
||||
}{
|
||||
{
|
||||
name: "завести учётную запись самому",
|
||||
path: usersRoot + "/records",
|
||||
body: `{"email":"intruder@example.com","password":"12345678901","passwordConfirm":"12345678901"}`,
|
||||
},
|
||||
{
|
||||
name: "вход по паролю",
|
||||
path: usersRoot + "/auth-with-password",
|
||||
body: `{"identity":"person@example.com","password":"whatever"}`,
|
||||
},
|
||||
{
|
||||
name: "обмен кода у внешнего провайдера",
|
||||
path: usersRoot + "/auth-with-oauth2",
|
||||
body: `{"provider":"oidc","code":"whatever","codeVerifier":"whatever","redirectURL":"https://example.com/"}`,
|
||||
},
|
||||
{
|
||||
name: "вход по одноразовому коду",
|
||||
path: usersRoot + "/auth-with-otp",
|
||||
body: `{"otpId":"whatever","password":"whatever"}`,
|
||||
},
|
||||
{
|
||||
name: "запрос одноразового кода",
|
||||
path: usersRoot + "/request-otp",
|
||||
body: `{"email":"person@example.com"}`,
|
||||
},
|
||||
{
|
||||
name: "восстановление пароля",
|
||||
path: usersRoot + "/request-password-reset",
|
||||
body: `{"email":"person@example.com"}`,
|
||||
},
|
||||
{
|
||||
name: "продление сессии",
|
||||
path: usersRoot + "/auth-refresh",
|
||||
body: `{}`,
|
||||
},
|
||||
// Эталон: путь вне корней сервиса, за которым не стояло ничего никогда.
|
||||
reference := env.get("/nothing-was-ever-here")
|
||||
require.Equal(t, http.StatusOK, reference.Code)
|
||||
|
||||
cases := map[string]string{
|
||||
"перечисление учётных записей": "/api/collections/users/records",
|
||||
"вход по паролю": "/api/collections/users/auth-with-password",
|
||||
"обмен кода у провайдера": "/api/collections/users/auth-with-oauth2",
|
||||
"выдача токена файла": "/api/files/token",
|
||||
"адрес панели": "/_/",
|
||||
"адрес панели знаком кода": "/%5f/",
|
||||
}
|
||||
|
||||
for _, c := range cases {
|
||||
t.Run(c.name, func(t *testing.T) {
|
||||
before := countAccounts(t, env)
|
||||
for name, path := range cases {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
w := env.getOwn(path)
|
||||
|
||||
req := httptest.NewRequest(http.MethodPost, c.path, strings.NewReader(c.body))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
env.mux.ServeHTTP(w, req)
|
||||
|
||||
assert.GreaterOrEqual(t, w.Code, http.StatusBadRequest,
|
||||
"адрес %s ответил успехом: собственный вход хранилища открыт", c.path)
|
||||
assert.Equal(t, reference.Code, w.Code,
|
||||
"адрес %s отвечает не как всякий неизвестный путь", path)
|
||||
assert.Equal(t, reference.Body.String(), w.Body.String(),
|
||||
"адрес %s отвечает не тем же телом, что всякий неизвестный путь", path)
|
||||
assert.NotContains(t, w.Body.String(), `"token"`,
|
||||
"адрес %s выдал значение доступа", c.path)
|
||||
assert.Equal(t, before, countAccounts(t, env),
|
||||
"адрес %s изменил число учётных записей", c.path)
|
||||
"адрес %s выдал значение доступа", path)
|
||||
})
|
||||
}
|
||||
|
||||
assert.Equal(t, before, countAccounts(t, env),
|
||||
"обход прежнего пространства хранилища изменил число учётных записей")
|
||||
}
|
||||
|
||||
// TestUserRecordCannotBeEditedFromOutside — путь захвата чужого имени закрыт.
|
||||
// TestAccountKeyHasNoEditAddress: ключ учётной записи не правится ничем, кроме
|
||||
// заведения самим сервисом.
|
||||
//
|
||||
// Ключ учётной записи лежит обычной колонкой, а умолчание библиотеки открывает
|
||||
// владельцу записи правку собственной строки. Пока узнавание жило под корнем
|
||||
// приложения, до этой поверхности браузер не дотягивался вовсе; теперь она
|
||||
// достижима, и правка своей записи была бы захватом чужого имени: поставил себе
|
||||
// чужой логин — и первое обращение настоящего его владельца попало бы в твою
|
||||
// запись вместе со всем архивом.
|
||||
func TestUserRecordCannotBeEditedFromOutside(t *testing.T) {
|
||||
// Переписанный ключ отдаёт архив следующему, кто придёт с этим именем, а вернуть
|
||||
// его будет нечем. Держится это тем, что адреса правки учётной записи у сервиса
|
||||
// нет вовсе — своих экранов профиля он не заводит, а поверхности хранилища,
|
||||
// правившей запись библиотечным правилом, не осталось.
|
||||
func TestAccountKeyHasNoEditAddress(t *testing.T) {
|
||||
env := setupTestEnv(t, readableMetaViewer())
|
||||
|
||||
body := strings.NewReader(`{"` + migrations.ProviderLoginField + `":"victim"}`)
|
||||
req := httptest.NewRequest(http.MethodPatch, "/api/collections/users/records/"+env.account.Id, body)
|
||||
body := strings.NewReader(`{"provider_login":"victim"}`)
|
||||
req := httptest.NewRequest(http.MethodPatch, "/api/collections/users/records/"+env.account.ID, body)
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
env.mux.ServeHTTP(w, asUser(req, env.login))
|
||||
|
||||
assert.GreaterOrEqual(t, w.Code, http.StatusBadRequest, "правка своей учётной записи прошла")
|
||||
|
||||
after, err := env.app.FindRecordById(migrations.UsersCollection, env.account.Id)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, env.login, after.GetString(migrations.ProviderLoginField),
|
||||
// Метод правки до раздачи приложения не доходит: она открывает страницу
|
||||
// только на `GET` и `HEAD`.
|
||||
assert.Equal(t, http.StatusMethodNotAllowed, w.Code)
|
||||
assert.Equal(t, env.login, accountLogin(t, env, env.account.ID),
|
||||
"ключ учётной записи переписан снаружи")
|
||||
}
|
||||
|
||||
// TestUserRecordsCannotBeListed: перечисление коллекции пользователей закрыто.
|
||||
// Открытое, оно отдавало бы узнанному логины всех остальных — то есть ровно те
|
||||
// значения, которыми довольно назваться.
|
||||
func TestUserRecordsCannotBeListed(t *testing.T) {
|
||||
env := setupTestEnv(t, readableMetaViewer())
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
env.mux.ServeHTTP(w, asUser(
|
||||
httptest.NewRequest(http.MethodGet, "/api/collections/users/records", nil), env.login))
|
||||
|
||||
assert.GreaterOrEqual(t, w.Code, http.StatusBadRequest)
|
||||
assert.NotContains(t, w.Body.String(), env.login)
|
||||
}
|
||||
|
||||
// TestDegenerateHeaderIdentifiesNobody: вырожденное значение никого не узнаёт и
|
||||
// ничего не заводит.
|
||||
//
|
||||
@@ -248,7 +183,7 @@ func TestDegenerateHeaderIdentifiesNobody(t *testing.T) {
|
||||
"пустое значение": "",
|
||||
"одни пробелы": " ",
|
||||
"управляющий знак": "ali\x00ce",
|
||||
"длиннее предела": strings.Repeat("a", pbrepo.MaxProviderLoginLength+1),
|
||||
"длиннее предела": strings.Repeat("a", entity.MaxProviderLoginLength+1),
|
||||
}
|
||||
|
||||
for name, value := range cases {
|
||||
@@ -292,104 +227,66 @@ func TestTwoLoginHeadersIdentifyNobody(t *testing.T) {
|
||||
assert.Equal(t, before, countAccounts(t, env))
|
||||
}
|
||||
|
||||
// TestStorageTokenBeatsHeader: годный собственный токен хранилища побеждает
|
||||
// заголовок, а протухший узнаванию не мешает.
|
||||
// TestPresentedValueIsNotAccepted: предъявленного значения сервис не признаёт.
|
||||
//
|
||||
// Первая половина защищает владельца панели: подмена его учётной записью
|
||||
// пользователя отобрала бы у него панель посреди работы. Вторая — обычного
|
||||
// человека: негодный токен, оставшийся в браузере, не должен запирать его
|
||||
// снаружи.
|
||||
func TestStorageTokenBeatsHeader(t *testing.T) {
|
||||
// Собственных токенов у него не существует — ни выдаваемых, ни принимаемых, — и
|
||||
// пришедшим считается названный заголовком. Прежде такое значение било заголовок:
|
||||
// им пользовался владелец панели, а панели больше нет.
|
||||
func TestPresentedValueIsNotAccepted(t *testing.T) {
|
||||
env := setupTestEnv(t, readableMetaViewer())
|
||||
|
||||
stranger, strangerLogin := newSecondAccount(t, env.app)
|
||||
token, err := stranger.NewAuthToken()
|
||||
require.NoError(t, err)
|
||||
stranger := newSecondAccount(t, env)
|
||||
|
||||
t.Run("годный токен побеждает", func(t *testing.T) {
|
||||
req := httptest.NewRequest(http.MethodGet, "/app/me", nil)
|
||||
req.Header.Set("Authorization", token)
|
||||
req := httptest.NewRequest(http.MethodGet, "/app/me?token=whatever", nil)
|
||||
req.Header.Set("Authorization", "Bearer whatever")
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
env.mux.ServeHTTP(w, asUser(req, env.login))
|
||||
w := httptest.NewRecorder()
|
||||
env.mux.ServeHTTP(w, asUser(req, env.login))
|
||||
|
||||
require.Equal(t, http.StatusOK, w.Code)
|
||||
assert.Contains(t, w.Body.String(), stranger.Id,
|
||||
"заголовок победил предъявленный токен")
|
||||
_ = strangerLogin
|
||||
})
|
||||
|
||||
t.Run("протухший токен узнаванию не мешает", func(t *testing.T) {
|
||||
req := httptest.NewRequest(http.MethodGet, "/app/me", nil)
|
||||
req.Header.Set("Authorization", "not-a-token")
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
env.mux.ServeHTTP(w, asUser(req, env.login))
|
||||
|
||||
require.Equal(t, http.StatusOK, w.Code)
|
||||
assert.Contains(t, w.Body.String(), env.account.Id)
|
||||
})
|
||||
require.Equal(t, http.StatusOK, w.Code)
|
||||
assert.Contains(t, w.Body.String(), env.account.ID,
|
||||
"предъявленное значение победило заголовок")
|
||||
assert.NotContains(t, w.Body.String(), stranger.ID)
|
||||
}
|
||||
|
||||
// TestOpenAddressesDoNotIdentify: проба здоровья и метрики открыты
|
||||
// неузнанному, а заголовок на них учётной записи не заводит.
|
||||
// TestOpenAddressesDoNotIdentify: проба здоровья и метрики открыты неузнанному,
|
||||
// а заголовок на них учётной записи не заводит.
|
||||
//
|
||||
// Вторая половина важнее первой: узнавание сужено до области приложения именно
|
||||
// затем, чтобы запрос за каждой картинкой не стоил обращения к базе, а первый
|
||||
// такой запрос с новым именем — записи в неё.
|
||||
func TestOpenAddressesDoNotIdentify(t *testing.T) {
|
||||
app := newTestStorage(t)
|
||||
env := setupTestEnv(t, readableMetaViewer())
|
||||
|
||||
r, err := apis.NewRouter(app)
|
||||
require.NoError(t, err)
|
||||
before := countAccounts(t, env)
|
||||
|
||||
appHandler := NewAppHandler(nil, nil, nil, nil, nil)
|
||||
mounts := ServiceMounts(appHandler, http.NotFoundHandler())
|
||||
r.Bind(TrustedHeaderIdentity(app, mounts, testTrustedNetworks(t), nil))
|
||||
|
||||
r.GET(HealthPath, func(e *core.RequestEvent) error {
|
||||
return e.JSON(http.StatusOK, map[string]string{"status": "ok"})
|
||||
})
|
||||
r.GET(MetricsPath, func(e *core.RequestEvent) error {
|
||||
return e.String(http.StatusOK, "# metrics")
|
||||
})
|
||||
|
||||
mux, err := r.BuildMux()
|
||||
require.NoError(t, err)
|
||||
|
||||
for _, path := range []string{HealthPath, MetricsPath} {
|
||||
anonymous := httptest.NewRecorder()
|
||||
mux.ServeHTTP(anonymous, httptest.NewRequest(http.MethodGet, path, nil))
|
||||
for _, path := range []string{HealthPath, "/", "/assets/index-abc123.js"} {
|
||||
anonymous := env.get(path)
|
||||
assert.Equal(t, http.StatusOK, anonymous.Code, "адрес %s обязан отвечать неузнанному", path)
|
||||
|
||||
withHeader := httptest.NewRecorder()
|
||||
mux.ServeHTTP(withHeader, asUser(httptest.NewRequest(http.MethodGet, path, nil), "passerby"))
|
||||
withHeader := env.getAs("passerby", path)
|
||||
assert.Equal(t, http.StatusOK, withHeader.Code,
|
||||
"чужой заголовок изменил ответ адреса %s: наблюдение гасится строкой в запросе", path)
|
||||
}
|
||||
|
||||
records, err := app.FindAllRecords(migrations.UsersCollection)
|
||||
require.NoError(t, err)
|
||||
assert.Empty(t, records, "обращение к открытому адресу завело учётную запись")
|
||||
assert.Equal(t, before, countAccounts(t, env),
|
||||
"обращение к открытому адресу завело учётную запись")
|
||||
}
|
||||
|
||||
// TestServiceIssuesNothingThatOutlivesRequest: сервис не ставит браузеру куки.
|
||||
//
|
||||
// Проверка судит именно **отсутствие**: пока сервис выдавал значение на семь
|
||||
// суток, отозванный у провайдера человек работал до его истечения. Вернувшаяся
|
||||
// кука вернула бы и это.
|
||||
// суток, отозванный у провайдера человек работал до его истечения.
|
||||
func TestServiceIssuesNothingThatOutlivesRequest(t *testing.T) {
|
||||
env := setupTestEnv(t, readableMetaViewer())
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
env.serve(w, httptest.NewRequest(http.MethodGet, "/app/me", nil))
|
||||
w := env.getOwn("/app/me")
|
||||
require.Equal(t, http.StatusOK, w.Code)
|
||||
|
||||
assert.Empty(t, w.Result().Cookies(), "ответ поставил куку: значение переживёт запрос")
|
||||
}
|
||||
|
||||
// TestIdentityValuesAreNotLogged — **четвёртый критерий приёмки**: не
|
||||
// печатается ничто, что даёт доступ.
|
||||
// TestIdentityValuesAreNotLogged — не печатается ничто, что даёт доступ.
|
||||
//
|
||||
// Проверка ищет в журнале **значения**, а не имена полей: значение, уехавшее под
|
||||
// другим ключом, поиск по ключу не разбудил бы. Логин здесь наравне с почтой: им
|
||||
@@ -406,16 +303,12 @@ func TestIdentityValuesAreNotLogged(t *testing.T) {
|
||||
|
||||
assert.NotContains(t, journal, env.login,
|
||||
"логин в журнале: строкой довольно назваться, чтобы стать этим человеком")
|
||||
assert.NotContains(t, journal, env.account.Email(),
|
||||
assert.NotContains(t, journal, "person@example.com",
|
||||
"адрес почты в журнале: он приходит от провайдера и принадлежит человеку")
|
||||
}
|
||||
|
||||
// TestUntrustedPeerIsLogged: недоверенный источник виден владельцу журналом, и
|
||||
// виден **адресом пира**, а не значением заголовка.
|
||||
//
|
||||
// Без этой строки владелец, у которого никто не может войти, не отличит своей
|
||||
// поломки (перечень доверенных адресов) от поломки контура (прокси заголовка не
|
||||
// ставит) — а это разные поломки в разных местах.
|
||||
func TestUntrustedPeerIsLogged(t *testing.T) {
|
||||
env := setupTestEnv(t, readableMetaViewer())
|
||||
|
||||
@@ -434,110 +327,46 @@ func TestUntrustedPeerIsLogged(t *testing.T) {
|
||||
assert.NotContains(t, journal, intruder, "значение заголовка уехало в журнал")
|
||||
}
|
||||
|
||||
// TestRecordFileIsProtected: ссылка на файл перестала быть правом пройти по ней.
|
||||
func TestRecordFileIsProtected(t *testing.T) {
|
||||
app := newTestStorage(t)
|
||||
|
||||
files, err := app.FindCollectionByNameOrId(migrations.FilesCollection)
|
||||
require.NoError(t, err)
|
||||
|
||||
field, ok := files.Fields.GetByName("file").(*core.FileField)
|
||||
require.True(t, ok)
|
||||
assert.True(t, field.Protected,
|
||||
"поле файла не защищено: знание ссылки снова стало бы доступом, а отзыва у неё нет")
|
||||
}
|
||||
|
||||
// TestRecordFileNeedsToken: ссылка на файл записи без токена отказывает, а
|
||||
// конвейер тот же файл по-прежнему читает — он ходит в файловую систему, а не по
|
||||
// ссылке.
|
||||
func TestRecordFileNeedsToken(t *testing.T) {
|
||||
env := setupTestEnv(t, readableMetaViewer())
|
||||
|
||||
created := httptest.NewRecorder()
|
||||
env.serve(created, createMultipartRequest(t, "test.mp3", []byte("audio content")))
|
||||
require.Equal(t, http.StatusCreated, created.Code)
|
||||
|
||||
files, err := env.app.FindAllRecords(migrations.FilesCollection)
|
||||
require.NoError(t, err)
|
||||
require.Len(t, files, 1)
|
||||
|
||||
names := files[0].GetStringSlice("file")
|
||||
require.Len(t, names, 1)
|
||||
|
||||
link := "/api/files/" + migrations.FilesCollection + "/" + files[0].Id + "/" + names[0]
|
||||
|
||||
anonymous := httptest.NewRecorder()
|
||||
env.mux.ServeHTTP(anonymous, httptest.NewRequest(http.MethodGet, link, nil))
|
||||
// Отказ приходит кодом «не найдено»: защищённый файл не раскрывает даже
|
||||
// своего существования. До пометки поля защищённым эта же ссылка отдавала
|
||||
// содержимое кому угодно — знание ссылки и было доступом.
|
||||
assert.Equal(t, http.StatusNotFound, anonymous.Code,
|
||||
"ссылка отдала файл без токена: знание ссылки снова стало доступом")
|
||||
assert.NotContains(t, anonymous.Body.String(), "audio content")
|
||||
|
||||
// А узнанный по заголовку берёт токен и проходит: путь «узнавание → токен
|
||||
// файла → ссылка» обязан работать целиком, иначе файл записи недостижим для
|
||||
// браузера вовсе.
|
||||
withToken := httptest.NewRecorder()
|
||||
env.mux.ServeHTTP(withToken,
|
||||
httptest.NewRequest(http.MethodGet, link+"?token="+fileToken(t, env, env.login), nil))
|
||||
require.Equal(t, http.StatusOK, withToken.Code)
|
||||
assert.Equal(t, "audio content", withToken.Body.String())
|
||||
|
||||
// Конвейер читает тот же файл своим путём — из файловой системы хранилища.
|
||||
fileRepo := pbrepo.NewFileRepository(env.app)
|
||||
reader, err := fileRepo.Open(files[0].Id)
|
||||
require.NoError(t, err)
|
||||
defer func() {
|
||||
assert.NoError(t, reader.Close())
|
||||
}()
|
||||
|
||||
content := make([]byte, len("audio content"))
|
||||
_, err = reader.Read(content)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "audio content", string(content))
|
||||
}
|
||||
|
||||
// countAccounts — сколько учётных записей лежит в хранилище. Проверки судят
|
||||
// заведение по числу строк: «запись одна» и «записи две» — разные исходы, а по
|
||||
// ответу обработчика они неразличимы.
|
||||
// countAccounts — сколько учётных записей лежит в базе. Проверки судят заведение
|
||||
// по числу строк: «запись одна» и «записи две» — разные исходы, а по ответу
|
||||
// обработчика они неразличимы.
|
||||
func countAccounts(t *testing.T, env *testEnv) int {
|
||||
t.Helper()
|
||||
|
||||
records, err := env.app.FindAllRecords(migrations.UsersCollection)
|
||||
require.NoError(t, err)
|
||||
return countRows(t, env, "users")
|
||||
}
|
||||
|
||||
return len(records)
|
||||
// accountLogin читает ключ учётной записи прямо из базы.
|
||||
func accountLogin(t *testing.T, env *testEnv, accountID string) string {
|
||||
t.Helper()
|
||||
|
||||
var login string
|
||||
require.NoError(t, env.db.Reader().
|
||||
QueryRow("SELECT provider_login FROM users WHERE id = ?", accountID).Scan(&login))
|
||||
return login
|
||||
}
|
||||
|
||||
// TestRejectedByRateLimitCreatesNoAccount — отвергнутый ограничителем частоты
|
||||
// запрос не заводит учётной записи.
|
||||
//
|
||||
// Слой узнавания читает базу, а на новом имени ещё и пишет в неё. Стоя раньше
|
||||
// ограничителя, он работал на запросах, которые тот уже отверг: сто двадцать
|
||||
// запросов выбирали бюджет, следующие пятьдесят получали отказ — и заводили
|
||||
// пятьдесят учётных записей. Убрать их потом нечем: учётная запись с записями
|
||||
// не удаляется, а мусорная растёт в той же единственной базе.
|
||||
// ограничителя, он работал бы на запросах, которые тот уже отверг: бюджет
|
||||
// выбирается, следующие запросы получают отказ — и заводят учётные записи.
|
||||
// Убрать их потом нечем: учётная запись с записями не удаляется.
|
||||
func TestRejectedByRateLimitCreatesNoAccount(t *testing.T) {
|
||||
env := setupTestEnv(t, readableMetaViewer())
|
||||
|
||||
require.NoError(t, ApplyAppRateLimit(env.app))
|
||||
|
||||
before := countAccounts(t, env)
|
||||
|
||||
// Бюджет выбирается запросами одного имени, чтобы счётчик успел упереться в
|
||||
// потолок раньше, чем начнутся новые имена.
|
||||
for range appRateMaxRequests + 5 {
|
||||
w := httptest.NewRecorder()
|
||||
env.serve(w, httptest.NewRequest(http.MethodGet, "/app/me", nil))
|
||||
env.getOwn("/app/me")
|
||||
}
|
||||
|
||||
rejected := 0
|
||||
for i := range 20 {
|
||||
w := httptest.NewRecorder()
|
||||
env.mux.ServeHTTP(w, asUser(
|
||||
httptest.NewRequest(http.MethodGet, "/app/me", nil),
|
||||
fmt.Sprintf("newcomer-%d", i)))
|
||||
w := env.getAs(fmt.Sprintf("newcomer-%d", i), "/app/me")
|
||||
if w.Code == http.StatusTooManyRequests {
|
||||
rejected++
|
||||
}
|
||||
@@ -549,18 +378,12 @@ func TestRejectedByRateLimitCreatesNoAccount(t *testing.T) {
|
||||
}
|
||||
|
||||
// TestAccountCreationIsLogged — заведение учётной записи видно владельцу.
|
||||
//
|
||||
// Без строки журнала «никто не заходил» неотличимо от «завелось двадцать», а
|
||||
// прокси, пропустивший чужой заголовок, не оставляет следа вовсе. Значение
|
||||
// заголовка при этом в строку не идёт: им довольно назваться, чтобы стать этим
|
||||
// человеком.
|
||||
func TestAccountCreationIsLogged(t *testing.T) {
|
||||
env := setupTestEnv(t, readableMetaViewer())
|
||||
|
||||
const login = "brand-new-person"
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
env.mux.ServeHTTP(w, asUser(httptest.NewRequest(http.MethodGet, "/app/me", nil), login))
|
||||
w := env.getAs(login, "/app/me")
|
||||
require.Equal(t, http.StatusOK, w.Code)
|
||||
|
||||
journal := env.journal.String()
|
||||
@@ -570,8 +393,7 @@ func TestAccountCreationIsLogged(t *testing.T) {
|
||||
// Второе обращение новой строки не прибавляет: заводится запись однажды.
|
||||
before := strings.Count(journal, "Account created from login header")
|
||||
|
||||
again := httptest.NewRecorder()
|
||||
env.mux.ServeHTTP(again, asUser(httptest.NewRequest(http.MethodGet, "/app/me", nil), login))
|
||||
again := env.getAs(login, "/app/me")
|
||||
require.Equal(t, http.StatusOK, again.Code)
|
||||
|
||||
assert.Equal(t, before, strings.Count(env.journal.String(), "Account created from login header"),
|
||||
@@ -579,10 +401,6 @@ func TestAccountCreationIsLogged(t *testing.T) {
|
||||
}
|
||||
|
||||
// TestDuplicateLoginHeaderIsVisibleToOwner — поломка контура видна в бою.
|
||||
//
|
||||
// Два значения заголовка означают прокси, который его добавляет вместо замены,
|
||||
// — модель угроз называет это главным барьером. Отладочным уровнем такая
|
||||
// поломка в бою не видна вовсе: боевой уровень журнала информационный.
|
||||
func TestDuplicateLoginHeaderIsVisibleToOwner(t *testing.T) {
|
||||
env := setupTestEnv(t, readableMetaViewer())
|
||||
|
||||
@@ -601,27 +419,23 @@ func TestDuplicateLoginHeaderIsVisibleToOwner(t *testing.T) {
|
||||
assert.NotContains(t, journal, "intruder", "значение заголовка уехало в журнал")
|
||||
}
|
||||
|
||||
// TestStorageFailureOnIdentityIsServiceFailure — отказ хранилища на пути
|
||||
// узнавания кончается отказом сервиса, а не молчаливым проходом неузнанным.
|
||||
// TestStorageFailureOnIdentityIsServiceFailure — отказ базы на пути узнавания
|
||||
// кончается отказом сервиса, а не молчаливым проходом неузнанным.
|
||||
//
|
||||
// Иначе человек увидел бы отказ входа там, где легла база, и чинил бы у себя
|
||||
// то, что сломано не у него.
|
||||
// Иначе человек увидел бы отказ входа там, где легла база, и чинил бы у себя то,
|
||||
// что сломано не у него.
|
||||
func TestStorageFailureOnIdentityIsServiceFailure(t *testing.T) {
|
||||
env := setupTestEnv(t, readableMetaViewer())
|
||||
|
||||
// Колонка ключа убирается из схемы: выборка по ней перестаёт работать — так
|
||||
// же, как она перестанет работать при отказе хранилища.
|
||||
users, err := env.app.FindCollectionByNameOrId(migrations.UsersCollection)
|
||||
// Колонка ключа переименовывается: выборка по ней перестаёт работать — так
|
||||
// же, как она перестанет работать при отказе базы.
|
||||
_, err := env.db.Writer().Exec("ALTER TABLE users RENAME COLUMN provider_login TO provider_login_gone")
|
||||
require.NoError(t, err)
|
||||
users.RemoveIndex("idx_users_provider_login")
|
||||
users.Fields.RemoveByName(migrations.ProviderLoginField)
|
||||
require.NoError(t, env.app.Save(users))
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
env.mux.ServeHTTP(w, asUser(httptest.NewRequest(http.MethodGet, "/app/me", nil), "somebody"))
|
||||
w := env.getAs("somebody", "/app/me")
|
||||
|
||||
assert.GreaterOrEqual(t, w.Code, http.StatusInternalServerError,
|
||||
"отказ хранилища выдан за «вас не узнали»")
|
||||
"отказ базы выдан за «вас не узнали»")
|
||||
assert.Contains(t, env.journal.String(), "Failed to resolve account by login header")
|
||||
}
|
||||
|
||||
@@ -632,7 +446,7 @@ func TestStorageFailureOnIdentityIsServiceFailure(t *testing.T) {
|
||||
// начал бы отвечать отказом контракта, и старая закладка молча сменила бы
|
||||
// поведение.
|
||||
func TestFormerAuthRootServesMarkup(t *testing.T) {
|
||||
env := setupWebappEnv(t, builtDist(), true)
|
||||
env := setupTestEnv(t, readableMetaViewer())
|
||||
|
||||
res := env.get("/auth/login")
|
||||
|
||||
|
||||
@@ -1,16 +1,24 @@
|
||||
package http
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"log/slog"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strconv"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
pbrepo "git.vakhrushev.me/av/transcriber/internal/adapter/repo/pocketbase"
|
||||
sqliterepo "git.vakhrushev.me/av/transcriber/internal/adapter/repo/sqlite"
|
||||
"git.vakhrushev.me/av/transcriber/internal/contract"
|
||||
"git.vakhrushev.me/av/transcriber/internal/entity"
|
||||
"git.vakhrushev.me/av/transcriber/internal/metrics"
|
||||
@@ -57,7 +65,7 @@ func TestMe_CarriesAccountWithoutEmail(t *testing.T) {
|
||||
var me MeView
|
||||
require.NoError(t, json.Unmarshal(w.Body.Bytes(), &me))
|
||||
|
||||
assert.Equal(t, env.account.Id, me.ID)
|
||||
assert.Equal(t, env.account.ID, me.ID)
|
||||
assert.NotContains(t, w.Body.String(), "person@example.com",
|
||||
"адрес почты принадлежит человеку, а не сервису")
|
||||
}
|
||||
@@ -73,7 +81,7 @@ func TestUnauthorized_ExistingRecordLooksLikeUnknown(t *testing.T) {
|
||||
env.mux.ServeHTTP(existing, httptest.NewRequest("GET", "/app/audiorecords/"+record.Id, http.NoBody))
|
||||
|
||||
unknown := httptest.NewRecorder()
|
||||
env.mux.ServeHTTP(unknown, httptest.NewRequest("GET", "/app/audiorecords/nosuchrecordid", http.NoBody))
|
||||
env.mux.ServeHTTP(unknown, httptest.NewRequest("GET", "/app/audiorecords/"+unknownRecordID, http.NoBody))
|
||||
|
||||
require.Equal(t, http.StatusUnauthorized, existing.Code)
|
||||
require.Equal(t, http.StatusUnauthorized, unknown.Code)
|
||||
@@ -97,7 +105,7 @@ func TestIntake_SenderFilenameLandsInOwnColumn(t *testing.T) {
|
||||
|
||||
item := intakeItemOf(t, w)
|
||||
|
||||
record, err := env.handler.recordRepo.GetByID(item.ID, env.account.Id)
|
||||
record, err := env.handler.recordRepo.GetByID(item.ID, env.account.ID)
|
||||
require.NoError(t, err)
|
||||
|
||||
require.NotNil(t, record.OriginalFilename)
|
||||
@@ -128,7 +136,7 @@ func TestIntake_LongFilenameIsTrimmed(t *testing.T) {
|
||||
|
||||
item := intakeItemOf(t, w)
|
||||
|
||||
record, err := env.handler.recordRepo.GetByID(item.ID, env.account.Id)
|
||||
record, err := env.handler.recordRepo.GetByID(item.ID, env.account.ID)
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, record.OriginalFilename)
|
||||
|
||||
@@ -170,7 +178,9 @@ func TestErrorBody_OneShapeAcrossBranches(t *testing.T) {
|
||||
}{
|
||||
{
|
||||
name: "записи нет",
|
||||
req: func() *http.Request { return httptest.NewRequest("GET", "/app/audiorecords/nosuch", http.NoBody) },
|
||||
req: func() *http.Request {
|
||||
return httptest.NewRequest("GET", "/app/audiorecords/"+unknownRecordID, http.NoBody)
|
||||
},
|
||||
code: CodeNotFound,
|
||||
},
|
||||
{
|
||||
@@ -209,37 +219,279 @@ func TestErrorBody_OneShapeAcrossBranches(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// Своё правило ограничителя частоты заведено под корнем приложения: правило
|
||||
// хранилища настроено на его собственный корень и наших адресов не покрывает.
|
||||
func TestRateLimitRuleCoversAppRoot(t *testing.T) {
|
||||
// Ограничитель частоты покрывает адреса приложения и **не трогает** адресов
|
||||
// наблюдения: правило своё, и настроено оно на корень приложения.
|
||||
func TestRateLimitCoversAppRootOnly(t *testing.T) {
|
||||
env := setupTestEnv(t, readableMetaViewer())
|
||||
|
||||
require.NoError(t, ApplyAppRateLimit(env.app))
|
||||
|
||||
var found int
|
||||
for _, rule := range env.app.Settings().RateLimits.Rules {
|
||||
if strings.HasPrefix(rule.Label, AppRoot+"/") {
|
||||
found++
|
||||
assert.Positive(t, rule.MaxRequests)
|
||||
assert.Positive(t, rule.Duration)
|
||||
var refused *httptest.ResponseRecorder
|
||||
for range appRateMaxRequests + 1 {
|
||||
refused = env.getOwn("/app/me")
|
||||
if refused.Code == http.StatusTooManyRequests {
|
||||
break
|
||||
}
|
||||
}
|
||||
assert.Equal(t, 1, found, "правило под корнем приложения заведено, и оно одно")
|
||||
assert.True(t, env.app.Settings().RateLimits.Enabled, "и ограничитель включён")
|
||||
require.Equal(t, http.StatusTooManyRequests, refused.Code,
|
||||
"бюджет под корнем приложения не исчерпался — проверке не на чем сработать")
|
||||
|
||||
// Правило приводится к настройке **при каждом подъёме**, то есть на каждом
|
||||
// рестарте сервиса. Без этой проверки ветвь замены не исполнялась бы ни разу,
|
||||
// и правила молча копились бы с каждой выкладкой.
|
||||
require.NoError(t, ApplyAppRateLimit(env.app))
|
||||
require.NoError(t, ApplyAppRateLimit(env.app))
|
||||
// Проба здоровья тем же бюджетом не ограничена: она лежит вне корня
|
||||
// приложения, а слои одеты на корень.
|
||||
assert.Equal(t, http.StatusOK, env.get(HealthPath).Code,
|
||||
"ограничитель приложения закрыл наблюдение за сервисом")
|
||||
}
|
||||
|
||||
again := 0
|
||||
for _, rule := range env.app.Settings().RateLimits.Rules {
|
||||
if strings.HasPrefix(rule.Label, AppRoot+"/") {
|
||||
again++
|
||||
// Два клиентских адреса через один доверенный прокси расходуют **разные**
|
||||
// бюджеты, а заголовок пересылки с недоверенного адреса на ключ бюджета не
|
||||
// влияет.
|
||||
//
|
||||
// Обе половины закрывают свою поломку: бюджет, посчитанный по пиру, становится
|
||||
// общим на весь сервис, а вера заголовку без сверки пира отдаёт обход
|
||||
// ограничителя ровно тому, кого он ограничивает.
|
||||
func TestRateLimitKeyNamesTheClient(t *testing.T) {
|
||||
env := setupTestEnv(t, readableMetaViewer())
|
||||
|
||||
spend := func(peer, forwarded string) int {
|
||||
refused := 0
|
||||
for range appRateMaxRequests + 1 {
|
||||
req := httptest.NewRequest("GET", "/app/me", http.NoBody)
|
||||
req.Header.Set(LoginHeader, env.login)
|
||||
req.RemoteAddr = peer
|
||||
if forwarded != "" {
|
||||
req.Header.Set(ForwardedForHeader, forwarded)
|
||||
}
|
||||
w := httptest.NewRecorder()
|
||||
env.mux.ServeHTTP(w, req)
|
||||
if w.Code == http.StatusTooManyRequests {
|
||||
refused++
|
||||
}
|
||||
}
|
||||
return refused
|
||||
}
|
||||
|
||||
// Первый клиент выбирает свой бюджет целиком.
|
||||
require.Positive(t, spend(trustedPeer, "198.51.100.7"),
|
||||
"бюджет первого клиента не исчерпался — проверке не на чем сработать")
|
||||
|
||||
// Второй клиент за тем же прокси начинает со своего.
|
||||
firstRefusalOfSecond := 0
|
||||
for range appRateMaxRequests {
|
||||
req := httptest.NewRequest("GET", "/app/me", http.NoBody)
|
||||
req.Header.Set(LoginHeader, env.login)
|
||||
req.RemoteAddr = trustedPeer
|
||||
req.Header.Set(ForwardedForHeader, "198.51.100.8")
|
||||
w := httptest.NewRecorder()
|
||||
env.mux.ServeHTTP(w, req)
|
||||
if w.Code == http.StatusTooManyRequests {
|
||||
firstRefusalOfSecond++
|
||||
}
|
||||
}
|
||||
assert.Equal(t, 1, again, "повторный подъём правило заменяет, а не добавляет второе")
|
||||
assert.Zero(t, firstRefusalOfSecond,
|
||||
"исчерпание бюджета одним клиентом отказало другому: бюджет считается по пиру")
|
||||
}
|
||||
|
||||
// Заголовок пересылки, пришедший с недоверенного адреса, на ключ бюджета не
|
||||
// влияет: иначе спрашивающий назначал бы себе ключ счётчика сам и обходил
|
||||
// ограничитель, меняя значение.
|
||||
func TestRateLimitIgnoresForwardedHeaderFromUntrustedPeer(t *testing.T) {
|
||||
env := setupTestEnv(t, readableMetaViewer())
|
||||
|
||||
refused := 0
|
||||
for i := range appRateMaxRequests + 10 {
|
||||
req := httptest.NewRequest("GET", "/app/me", http.NoBody)
|
||||
req.RemoteAddr = untrustedPeer
|
||||
req.Header.Set(ForwardedForHeader, fmt.Sprintf("198.51.100.%d", i%200))
|
||||
w := httptest.NewRecorder()
|
||||
env.mux.ServeHTTP(w, req)
|
||||
if w.Code == http.StatusTooManyRequests {
|
||||
refused++
|
||||
}
|
||||
}
|
||||
|
||||
assert.Positive(t, refused,
|
||||
"меняя заголовок пересылки, спрашивающий обошёл ограничитель")
|
||||
}
|
||||
|
||||
// spendBudget шлёт запросы под корнем приложения и считает отказы ограничителя.
|
||||
// Заголовки пересылки ставит вызывающий: ключ бюджета выводится из них, и
|
||||
// проверке нужен каждый их вид — одна строка, несколько строк, цепочка.
|
||||
func spendBudget(env *testEnv, peer string, count int, forwarded func(i int) []string) int {
|
||||
refused := 0
|
||||
for i := range count {
|
||||
req := httptest.NewRequest("GET", "/app/me", http.NoBody)
|
||||
req.Header.Set(LoginHeader, env.login)
|
||||
req.RemoteAddr = peer
|
||||
for _, value := range forwarded(i) {
|
||||
req.Header.Add(ForwardedForHeader, value)
|
||||
}
|
||||
w := httptest.NewRecorder()
|
||||
env.mux.ServeHTTP(w, req)
|
||||
if w.Code == http.StatusTooManyRequests {
|
||||
refused++
|
||||
}
|
||||
}
|
||||
return refused
|
||||
}
|
||||
|
||||
// Значение, которое приписал сам спрашивающий, ключа бюджета не задаёт.
|
||||
//
|
||||
// Прокси заголовок **дописывает**, а не заменяет: слева в цепочке стоит то, что
|
||||
// прислал аноним, а справа — адрес, который приписал прокси. Ключ, взятый слева,
|
||||
// менялся бы на каждом запросе, и бюджет обходился бы с первого.
|
||||
func TestRateLimitIgnoresValuePresentedByTheClient(t *testing.T) {
|
||||
env := setupTestEnv(t, readableMetaViewer())
|
||||
|
||||
refused := spendBudget(env, trustedPeer, appRateMaxRequests+10, func(i int) []string {
|
||||
return []string{fmt.Sprintf("198.51.100.%d, 203.0.113.7", i%200)}
|
||||
})
|
||||
|
||||
assert.Positive(t, refused,
|
||||
"подставляя своё значение слева, спрашивающий обошёл ограничитель")
|
||||
}
|
||||
|
||||
// Цепочка законно приходит несколькими строками заголовка, и читаются они все:
|
||||
// разбор одной строки увидел бы кусок, которым распоряжается аноним.
|
||||
func TestRateLimitReadsEveryForwardedHeaderLine(t *testing.T) {
|
||||
env := setupTestEnv(t, readableMetaViewer())
|
||||
|
||||
refused := spendBudget(env, trustedPeer, appRateMaxRequests+10, func(i int) []string {
|
||||
return []string{fmt.Sprintf("198.51.100.%d", i%200), "203.0.113.8"}
|
||||
})
|
||||
|
||||
assert.Positive(t, refused,
|
||||
"вторая строка заголовка не прочитана: ключ достался присланному значению")
|
||||
}
|
||||
|
||||
// Доверенные шаги цепочки отбрасываются, и ключом становится первый недоверенный
|
||||
// справа. Два клиента за одним прокси при этом расходуют разные бюджеты.
|
||||
func TestRateLimitSkipsTrustedHopsFromTheRight(t *testing.T) {
|
||||
env := setupTestEnv(t, readableMetaViewer())
|
||||
|
||||
require.Positive(t,
|
||||
spendBudget(env, trustedPeer, appRateMaxRequests+1, func(int) []string {
|
||||
return []string{"203.0.113.11, 10.9.9.9"}
|
||||
}),
|
||||
"бюджет первого клиента не исчерпался — проверке не на чем сработать")
|
||||
|
||||
assert.Zero(t,
|
||||
spendBudget(env, trustedPeer, appRateMaxRequests, func(int) []string {
|
||||
return []string{"203.0.113.12, 10.9.9.9"}
|
||||
}),
|
||||
"исчерпание бюджета одним клиентом отказало другому: доверенный шаг стал ключом")
|
||||
}
|
||||
|
||||
// Тип содержимого ответа выбирает сервис, а не отправитель.
|
||||
//
|
||||
// Расширение приходит из имени, данное отправителем: `запись.html`, отданный
|
||||
// типом `text/html` с показом на месте, стал бы страницей в браузере. Тип
|
||||
// выводится поэтому из **закрытого** перечня известных форматов — той же единой
|
||||
// точки, что и метка метрики, — а всё прочее отдаётся `application/octet-stream`
|
||||
// на сохранение.
|
||||
func TestFileDownload_ContentTypeComesFromKnownFormats(t *testing.T) {
|
||||
env := setupTestEnv(t, readableMetaViewer())
|
||||
|
||||
fetch := func(t *testing.T, name string) *httptest.ResponseRecorder {
|
||||
t.Helper()
|
||||
|
||||
created := httptest.NewRecorder()
|
||||
env.serve(created, createMultipartRequest(t, name, []byte("данные")))
|
||||
require.Equal(t, http.StatusCreated, created.Code, name)
|
||||
|
||||
return env.getOwn("/app/audiorecords/" +
|
||||
intakeItemOf(t, created).ID + "/file?" + CopyParam + "=" + CopyOriginal)
|
||||
}
|
||||
|
||||
t.Run("расширение вне перечня исполняемым типом не отдаётся", func(t *testing.T) {
|
||||
for _, name := range []string{"запись.html", "запись.svg", "запись.xhtml"} {
|
||||
w := fetch(t, name)
|
||||
require.Equal(t, http.StatusOK, w.Code, name)
|
||||
header := w.Result().Header
|
||||
assert.Equal(t, unknownContentType, header.Get("Content-Type"), name)
|
||||
assert.Contains(t, header.Get("Content-Disposition"), dispositionAttachment, name)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("известный формат отдаётся своим типом", func(t *testing.T) {
|
||||
for name, want := range map[string]string{
|
||||
"запись.mkv": "video/x-matroska",
|
||||
"запись.mov": "video/quicktime",
|
||||
"запись.avi": "video/x-msvideo",
|
||||
"запись.mp3": "audio/mpeg",
|
||||
} {
|
||||
w := fetch(t, name)
|
||||
require.Equal(t, http.StatusOK, w.Code, name)
|
||||
header := w.Result().Header
|
||||
assert.Equal(t, want, header.Get("Content-Type"), name)
|
||||
assert.Contains(t, header.Get("Content-Disposition"), dispositionInline, name)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// Перечень типов содержимого сверяется с перечнем известных форматов
|
||||
// механически: формат, объявленный диалогу выбора файла и оставшийся без типа,
|
||||
// уехал бы ответом `application/octet-stream` — то есть сервис предлагал бы
|
||||
// загрузить то, что потом не умеет показать.
|
||||
func TestEveryKnownFormatHasContentType(t *testing.T) {
|
||||
formats := metrics.PublicFormats()
|
||||
require.NotEmpty(t, formats, "перечень форматов пуст: правилу не на чем сработать")
|
||||
|
||||
for _, format := range formats {
|
||||
contentType, disposition := presentationOf(format)
|
||||
assert.NotEqual(t, unknownContentType, contentType,
|
||||
"формат %q сервис объявляет диалогу выбора файла, но типа содержимого у него нет", format)
|
||||
assert.Equal(t, dispositionInline, disposition, format)
|
||||
}
|
||||
|
||||
for format := range contentTypes {
|
||||
assert.Equal(t, format, metrics.FormatLabel(format),
|
||||
"тип содержимого заведён формату %q, которого нет среди известных: ключ никогда не совпадёт", format)
|
||||
}
|
||||
}
|
||||
|
||||
// Паника обработчика отдаёт `500` нашей формой тела, а процесс живёт дальше.
|
||||
//
|
||||
// Слой восстановления — верхняя граница поверхности, и проверяется он через
|
||||
// **всю** цепочку: паника ловится снаружи журнала и маршрутизатора, поэтому
|
||||
// собранная иначе поверхность судила бы не то. Без него один паникующий запрос
|
||||
// уронил бы процесс вместе с конвейером и всеми, кто в это время что-то грузил.
|
||||
func TestPanickingHandlerAnswersOurFailureFormAndProcessLives(t *testing.T) {
|
||||
db, _, _ := newTestStorage(t)
|
||||
users := sqliterepo.NewUserRepository(db)
|
||||
|
||||
journal := &journalBuffer{}
|
||||
logger := slog.New(slog.NewTextHandler(journal, nil))
|
||||
|
||||
panicking := http.HandlerFunc(func(http.ResponseWriter, *http.Request) {
|
||||
panic("шаг обработчика упал")
|
||||
})
|
||||
mounts := ServiceMounts(
|
||||
AppChain(panicking, users, testTrustedNetworks(t), logger),
|
||||
http.NotFoundHandler(),
|
||||
)
|
||||
mux := BuildHandler(mounts, NewWebappHandler(builtDist(), true, logger), logger)
|
||||
|
||||
account, _, err := users.EnsureUser(contract.Identity{Login: "person"})
|
||||
require.NoError(t, err)
|
||||
require.NotEmpty(t, account.ID)
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
mux.ServeHTTP(w, asUser(httptest.NewRequest(http.MethodGet, "/app/me", http.NoBody), "person"))
|
||||
|
||||
require.Equal(t, http.StatusInternalServerError, w.Code)
|
||||
|
||||
var body ErrorBody
|
||||
require.NoError(t, json.Unmarshal(w.Body.Bytes(), &body), "тело отказа — не наша форма")
|
||||
assert.Equal(t, CodeInternal, body.Code)
|
||||
assert.NotEmpty(t, body.Message)
|
||||
assert.NotContains(t, w.Body.String(), "шаг обработчика упал",
|
||||
"значение паники ушло спрашивающему")
|
||||
|
||||
assert.Contains(t, journal.String(), "Handler panicked",
|
||||
"владелец сервиса о панике не узнал")
|
||||
|
||||
// Процесс жив: следующий запрос отвечает как ни в чём не бывало.
|
||||
alive := httptest.NewRecorder()
|
||||
mux.ServeHTTP(alive, httptest.NewRequest(http.MethodGet, HealthPath, http.NoBody))
|
||||
assert.Equal(t, http.StatusOK, alive.Code, "после паники поверхность перестала отвечать")
|
||||
}
|
||||
|
||||
// Перечень доступных видов растёт вместе с готовыми текстами, и вычитанный текст
|
||||
@@ -250,13 +502,13 @@ func TestAvailableViewsCoverEveryKind(t *testing.T) {
|
||||
|
||||
record := jobWithFile(t, env)
|
||||
|
||||
texts := pbrepo.NewTextRepository(env.app)
|
||||
texts := env.handler.textRepo
|
||||
literary, err := texts.Put(record.Id, entity.TextKindLiterary, "вычитанный текст")
|
||||
require.NoError(t, err)
|
||||
transcript, err := texts.Put(record.Id, entity.TextKindTranscript, "сырая расшифровка")
|
||||
require.NoError(t, err)
|
||||
|
||||
structures := pbrepo.NewStructureRepository(env.app)
|
||||
structures := env.handler.structureRepo
|
||||
structure, err := structures.Put(record.Id, 1, []entity.Replica{{StartMs: 0, EndMs: 10, Text: "реплика"}})
|
||||
require.NoError(t, err)
|
||||
|
||||
@@ -318,7 +570,6 @@ func TestTooLargeOnTheRealPath(t *testing.T) {
|
||||
// частом отказе после превышения размера.
|
||||
func TestRateLimitRefusalGoesThroughOneErrorForm(t *testing.T) {
|
||||
env := setupTestEnv(t, readableMetaViewer())
|
||||
require.NoError(t, ApplyAppRateLimit(env.app))
|
||||
|
||||
var last *httptest.ResponseRecorder
|
||||
for range appRateMaxRequests + 1 {
|
||||
@@ -403,7 +654,7 @@ func TestEmptyTextIsNotAnAvailableView(t *testing.T) {
|
||||
|
||||
record := jobWithFile(t, env)
|
||||
|
||||
texts := pbrepo.NewTextRepository(env.app)
|
||||
texts := env.handler.textRepo
|
||||
empty, err := texts.Put(record.Id, entity.TextKindTranscript, "")
|
||||
require.NoError(t, err)
|
||||
|
||||
@@ -425,3 +676,108 @@ func TestEmptyTextIsNotAnAvailableView(t *testing.T) {
|
||||
// И адрес текста отвечает тем же: состоянием, а не обещанием.
|
||||
assert.Equal(t, http.StatusConflict, textOf(t, env, record.Id, entity.TextViewTranscript).Code)
|
||||
}
|
||||
|
||||
// TestFailuresBornOutsideHandlerShareOneForm — **критерий приёмки**: отказы,
|
||||
// рождающиеся не в обработчике, приходят той же формой, что и отказы
|
||||
// обработчика.
|
||||
//
|
||||
// Проверка идёт **настоящими** HTTP-запросами через поднятую цепочку слоёв:
|
||||
// вызовом отображателя ошибки это не проверяется — ни один из трёх отказов до
|
||||
// него не доходит. Предел тела ловит запрос слоем чтения, ограничитель частоты —
|
||||
// слоем перед узнаванием, неизвестный путь — маршрутизатором.
|
||||
func TestFailuresBornOutsideHandlerShareOneForm(t *testing.T) {
|
||||
env := setupTestEnv(t, readableMetaViewer())
|
||||
server := env.liveServer(t)
|
||||
|
||||
// Перечень кодов закрыт: значение вне его приложению разбирать нечем.
|
||||
known := map[string]bool{
|
||||
CodeUnauthorized: true, CodeNotFound: true, CodeBadRequest: true,
|
||||
CodeTooLarge: true, CodeNotReady: true, CodeTooManyRequests: true,
|
||||
CodeInternal: true,
|
||||
}
|
||||
|
||||
assertOurForm := func(t *testing.T, status int, body []byte) {
|
||||
t.Helper()
|
||||
|
||||
var raw map[string]any
|
||||
require.NoError(t, json.Unmarshal(body, &raw), "тело не наше: %s", body)
|
||||
require.Contains(t, raw, "error_code")
|
||||
require.Contains(t, raw, "message")
|
||||
|
||||
code, ok := raw["error_code"].(string)
|
||||
require.True(t, ok)
|
||||
assert.True(t, known[code], "код отказа %q вне закрытого перечня", code)
|
||||
assert.NotEmpty(t, raw["message"])
|
||||
assert.NotEqual(t, http.StatusOK, status)
|
||||
}
|
||||
|
||||
t.Run("предел тела", func(t *testing.T) {
|
||||
// Запрос идёт **сырым соединением**: клиент стандартной библиотеки
|
||||
// отказывается слать объявленную длину, которой не соответствует тело, а
|
||||
// прислать восемь гигабайт на самом деле проверка не может. Сервер при
|
||||
// этом видит обычный запрос: заголовки разобраны, длина объявлена, тела
|
||||
// он не читает вовсе — отказ наступает раньше.
|
||||
status, body := rawRequest(t, server, ""+
|
||||
"POST /app/audiorecords HTTP/1.1\r\n"+
|
||||
"Host: transcriber.test\r\n"+
|
||||
LoginHeader+": "+env.login+"\r\n"+
|
||||
"Content-Type: multipart/form-data; boundary=x\r\n"+
|
||||
"Content-Length: "+strconv.FormatInt(entity.MaxRecordSize+1, 10)+"\r\n"+
|
||||
"Connection: close\r\n\r\n")
|
||||
|
||||
require.Equal(t, http.StatusRequestEntityTooLarge, status)
|
||||
assertOurForm(t, status, body)
|
||||
assert.Contains(t, string(body), `"limit"`, "предел уходит человеку числом")
|
||||
assert.Equal(t, 0, countJobs(t, env), "записи не заводится")
|
||||
})
|
||||
|
||||
t.Run("неизвестный путь под корнем приложения", func(t *testing.T) {
|
||||
res := env.liveRequest(t, server, "/app/nosuchendpoint", nil)
|
||||
|
||||
require.Equal(t, http.StatusNotFound, res.StatusCode)
|
||||
assertOurForm(t, res.StatusCode, res.Body)
|
||||
})
|
||||
|
||||
t.Run("ограничитель частоты", func(t *testing.T) {
|
||||
var last liveResponse
|
||||
for range appRateMaxRequests + 5 {
|
||||
last = env.liveRequest(t, server, "/app/me", nil)
|
||||
if last.StatusCode == http.StatusTooManyRequests {
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
require.Equal(t, http.StatusTooManyRequests, last.StatusCode,
|
||||
"ограничитель не сработал — проверке не на чем сработать")
|
||||
assertOurForm(t, last.StatusCode, last.Body)
|
||||
})
|
||||
}
|
||||
|
||||
// rawRequest шлёт запрос сырым соединением и отдаёт код с телом ответа.
|
||||
//
|
||||
// Нужен там, где клиент стандартной библиотеки запрос не отправит: он судит
|
||||
// соответствие объявленной длины телу, а проверке нужна ровно объявленная.
|
||||
func rawRequest(t *testing.T, server *httptest.Server, request string) (int, []byte) {
|
||||
t.Helper()
|
||||
|
||||
address := strings.TrimPrefix(server.URL, "http://")
|
||||
|
||||
dialer := &net.Dialer{Timeout: 5 * time.Second}
|
||||
conn, err := dialer.DialContext(context.Background(), "tcp", address)
|
||||
require.NoError(t, err)
|
||||
defer func() { require.NoError(t, conn.Close()) }()
|
||||
|
||||
require.NoError(t, conn.SetDeadline(time.Now().Add(5*time.Second)))
|
||||
|
||||
_, err = conn.Write([]byte(request))
|
||||
require.NoError(t, err)
|
||||
|
||||
res, err := http.ReadResponse(bufio.NewReader(conn), nil)
|
||||
require.NoError(t, err)
|
||||
defer func() { require.NoError(t, res.Body.Close()) }()
|
||||
|
||||
body, err := io.ReadAll(res.Body)
|
||||
require.NoError(t, err)
|
||||
|
||||
return res.StatusCode, body
|
||||
}
|
||||
|
||||
@@ -1,14 +1,11 @@
|
||||
package http
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
|
||||
"github.com/pocketbase/pocketbase/apis"
|
||||
"github.com/pocketbase/pocketbase/core"
|
||||
"github.com/pocketbase/pocketbase/tools/hook"
|
||||
"github.com/pocketbase/pocketbase/tools/router"
|
||||
|
||||
"git.vakhrushev.me/av/transcriber/internal/contract"
|
||||
"git.vakhrushev.me/av/transcriber/internal/entity"
|
||||
)
|
||||
@@ -17,9 +14,13 @@ import (
|
||||
// HTTP не различает «файл негоден», «поля записи нет» и «неизвестный вид» — все
|
||||
// три `400`, — а приложению надо решать, предлагать ли повтор и что показать
|
||||
// человеку. Разбор русской фразы был бы единственным оставшимся путём.
|
||||
//
|
||||
// Кода `forbidden` в перечне больше нет: его единственным случаем был владелец
|
||||
// панели, предъявивший собственный токен хранилища. Ни панели, ни токенов у
|
||||
// сервиса не осталось, а узнавание по заголовку учётную запись заводит само —
|
||||
// предъявителя без неё не бывает.
|
||||
const (
|
||||
CodeUnauthorized = "unauthorized"
|
||||
CodeForbidden = "forbidden"
|
||||
CodeNotFound = "not_found"
|
||||
CodeBadRequest = "bad_request"
|
||||
CodeTooLarge = "too_large"
|
||||
@@ -32,7 +33,7 @@ const (
|
||||
//
|
||||
// Два поля, а не одно: код разбирает программа, сообщение читает человек. Сырой
|
||||
// текст ошибки сюда не попадает — ни `err.Error()`, ни детали устройства: имена
|
||||
// внешних сервисов, пути на диске, ключи файлов. Полная ошибка остаётся в
|
||||
// внешних сервисов, пути на диске, имена файлов. Полная ошибка остаётся в
|
||||
// журнале владельца сервиса.
|
||||
//
|
||||
// Limit заполняется только у отказа по размеру: экран обязан показать предел
|
||||
@@ -46,8 +47,7 @@ type ErrorBody struct {
|
||||
// mapDomainError — **единственная** точка, где доменная ошибка становится кодом
|
||||
// ответа и сообщением. Прежде такой точки не было вовсе, и каждый обработчик
|
||||
// решал сам: опрос отвечал «записи нет» на упавшую базу, а приём — «внутренняя
|
||||
// ошибка» на негодный файл. Человек читал первое как «моя запись пропала», а
|
||||
// второе не говорило ему ничего.
|
||||
// ошибка» на негодный файл.
|
||||
//
|
||||
// Ветвь по умолчанию определена намеренно: новая штатная ветвь отказа заводится
|
||||
// добавлением сюда, а не строкой в обработчике. Иначе обычный конфликт уезжает в
|
||||
@@ -87,6 +87,18 @@ func mapDomainError(err error) (int, ErrorBody) {
|
||||
Message: "Текст этого вида для записи ещё не готов",
|
||||
}
|
||||
|
||||
case errors.Is(err, contract.ErrCopyNotReady):
|
||||
return http.StatusConflict, ErrorBody{
|
||||
Code: CodeNotReady,
|
||||
Message: "Этой копии записи ещё нет",
|
||||
}
|
||||
|
||||
case errors.Is(err, contract.ErrTooManyRequests):
|
||||
return http.StatusTooManyRequests, ErrorBody{
|
||||
Code: CodeTooManyRequests,
|
||||
Message: "Слишком много запросов подряд, попробуйте позже",
|
||||
}
|
||||
|
||||
case errors.Is(err, contract.ErrNotFound):
|
||||
message := "Адрес не найден"
|
||||
var owned *messagedError
|
||||
@@ -104,12 +116,6 @@ func mapDomainError(err error) (int, ErrorBody) {
|
||||
Code: CodeUnauthorized,
|
||||
Message: "Сервис вас не узнал",
|
||||
}
|
||||
|
||||
case errors.Is(err, contract.ErrOwnerRequired):
|
||||
return http.StatusForbidden, ErrorBody{
|
||||
Code: CodeForbidden,
|
||||
Message: "У предъявителя нет учётной записи пользователя",
|
||||
}
|
||||
}
|
||||
|
||||
// Чужая запись, ничья и несуществующая отвечают одним и тем же: по разнице
|
||||
@@ -130,114 +136,61 @@ func mapDomainError(err error) (int, ErrorBody) {
|
||||
|
||||
// fail отвечает отказом по доменной ошибке — единственный способ, которым отказ
|
||||
// уходит наружу с адресов приложения.
|
||||
func fail(e *core.RequestEvent, err error) error {
|
||||
//
|
||||
// Отказы, рождающиеся **не в обработчике** — предел тела, ограничитель частоты,
|
||||
// неизвестный путь под корнем приложения, — приходят сюда же: слои сервиса
|
||||
// написаны нами и отвечают своей доменной ошибкой, а не телом библиотеки. Второй
|
||||
// формы тела на адресах приложения не существует.
|
||||
func fail(w http.ResponseWriter, err error) {
|
||||
status, body := mapDomainError(err)
|
||||
return e.JSON(status, body)
|
||||
writeJSON(w, status, body)
|
||||
}
|
||||
|
||||
// OneErrorForm переводит отказ библиотеки в нашу форму тела.
|
||||
//
|
||||
// Своей единой точки мало: часть отказов на адресах приложения рождается **не в
|
||||
// обработчике** и до `mapDomainError` не доходит вовсе. Их три, и все три частые:
|
||||
// предел тела (`413`), ограничитель частоты (`429`) и неизвестный путь под нашим
|
||||
// корнем (`404`). Каждый уходил бы телом `router.ApiError` — без машиночитаемого
|
||||
// кода, — и форм отказа на адресах приложения оказалось бы две вместо одной.
|
||||
//
|
||||
// Дороже всего первый: «запись больше потолка» — самый частый отказ у человека
|
||||
// на мобильной сети, и приложение, разобрав чужое тело, показало бы ветвь
|
||||
// «внутренняя ошибка» вместо предела числом.
|
||||
//
|
||||
// Слой стоит **самым внешним**: он обязан видеть отказ, рождённый слоями ниже
|
||||
// него, включая предел тела и ограничитель частоты.
|
||||
func OneErrorForm() *hook.Handler[*core.RequestEvent] {
|
||||
return &hook.Handler[*core.RequestEvent]{
|
||||
Id: "transcriberOneErrorForm",
|
||||
Priority: apis.DefaultRateLimitMiddlewarePriority - 100,
|
||||
Func: func(e *core.RequestEvent) error {
|
||||
err := e.Next()
|
||||
if err == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Обработчик, ответивший через fail, ошибки не возвращает — его
|
||||
// форма уже ушла в ответ, и сюда доходит только чужая.
|
||||
var apiErr *router.ApiError
|
||||
if !errors.As(err, &apiErr) {
|
||||
return err
|
||||
}
|
||||
|
||||
status, translated := translateAPIError(apiErr)
|
||||
return e.JSON(status, translated)
|
||||
},
|
||||
}
|
||||
// writeJSON отдаёт тело ответа. Отказ записи в журнал не идёт: соединение к
|
||||
// этому моменту оборвано, и сказать о нём некому — строка о каждом закрытом
|
||||
// браузере наполняла бы журнал ничем.
|
||||
func writeJSON(w http.ResponseWriter, status int, body any) {
|
||||
w.Header().Set("Content-Type", "application/json; charset=utf-8")
|
||||
w.WriteHeader(status)
|
||||
_ = json.NewEncoder(w).Encode(body)
|
||||
}
|
||||
|
||||
// translateAPIError переводит отказ библиотеки в перечень наших кодов. Ветви
|
||||
// названы поимённо: значение вне перечня приложению разбирать нечем.
|
||||
func translateAPIError(apiErr *router.ApiError) (int, ErrorBody) {
|
||||
switch apiErr.Status {
|
||||
case http.StatusRequestEntityTooLarge:
|
||||
limit := entity.MaxRecordSize
|
||||
return http.StatusRequestEntityTooLarge, ErrorBody{
|
||||
Code: CodeTooLarge,
|
||||
Message: "Запись больше допустимого размера",
|
||||
Limit: &limit,
|
||||
}
|
||||
case http.StatusTooManyRequests:
|
||||
return http.StatusTooManyRequests, ErrorBody{
|
||||
Code: CodeTooManyRequests,
|
||||
Message: "Слишком много запросов подряд, попробуйте позже",
|
||||
}
|
||||
case http.StatusNotFound:
|
||||
return http.StatusNotFound, ErrorBody{
|
||||
Code: CodeNotFound,
|
||||
Message: "Адрес не найден",
|
||||
}
|
||||
case http.StatusUnauthorized:
|
||||
return mapDomainError(contract.ErrUnauthorized)
|
||||
}
|
||||
|
||||
return apiErr.Status, ErrorBody{
|
||||
Code: CodeInternal,
|
||||
Message: "Внутренняя ошибка сервиса",
|
||||
}
|
||||
// errWithMessage приклеивает к признаку негодного ввода свой текст: причина у
|
||||
// всех одна, а сказать человеку надо разное.
|
||||
func errWithMessage(base error, message string) error {
|
||||
return &messagedError{base: base, message: message}
|
||||
}
|
||||
|
||||
// RequireUser — слой предъявления адресов приложения.
|
||||
//
|
||||
// Своя проверка, а не `apis.RequireAuth`, по одной причине: отказ библиотеки
|
||||
// уходит **её** формой тела, и на адресах приложения оказалось бы две формы
|
||||
// отказа вместо одной. Проверка при этом та же самая, и коллекция названа
|
||||
// поимённо: без имени пускается всякая учётная запись хранилища, включая
|
||||
// владельца панели, — а записи в коллекции пользователей у него нет, и владельцем
|
||||
// записи он стать не может.
|
||||
//
|
||||
// Отказ наступает **до чтения тела**: запись, за которую не заплатит узнанный
|
||||
// отправитель, не должна попасть даже в память, а позже пришлось бы убирать уже
|
||||
// уложенный файл — чего сервис не умеет вовсе.
|
||||
func RequireUser(usersCollection string) *hook.Handler[*core.RequestEvent] {
|
||||
return &hook.Handler[*core.RequestEvent]{
|
||||
Id: "transcriberRequireUser",
|
||||
// Сразу после слоя узнавания: раньше него `e.Auth` ещё пуст, и всякий
|
||||
// запрос получал бы отказ. Слой узнавания, в свою очередь, стоит за
|
||||
// ограничителем частоты — см. `TrustedHeaderIdentity`.
|
||||
//
|
||||
// Предел тела библиотеки идёт следом (−990), и это обязательно: отказ
|
||||
// неузнанному наступает **до** чтения тела.
|
||||
Priority: apis.DefaultRateLimitMiddlewarePriority + 2,
|
||||
Func: func(e *core.RequestEvent) error {
|
||||
if e.Auth == nil {
|
||||
return fail(e, contract.ErrUnauthorized)
|
||||
}
|
||||
type messagedError struct {
|
||||
base error
|
||||
message string
|
||||
}
|
||||
|
||||
// Узнан он всё же узнан, а учётной записи пользователя у него нет:
|
||||
// код здесь другой не по оплошности. `401` значит «предъяви себя», а
|
||||
// предъявитель себя предъявил.
|
||||
if e.Auth.Collection().Name != usersCollection {
|
||||
return fail(e, contract.ErrOwnerRequired)
|
||||
}
|
||||
func (e *messagedError) Error() string { return e.message }
|
||||
func (e *messagedError) Unwrap() error { return e.base }
|
||||
|
||||
return e.Next()
|
||||
},
|
||||
// Recover — верхняя граница обработчика: паникующий запрос отдаёт `500` нашей
|
||||
// формой тела, а процесс живёт.
|
||||
//
|
||||
// Слой свой, потому что своим стал и роутер: прежде его вешала чужая библиотека.
|
||||
// У воркеров такой границы по-прежнему нет — паника в шаге конвейера роняет
|
||||
// процесс целиком, и это осознанно.
|
||||
func Recover(logger *slog.Logger) func(http.Handler) http.Handler {
|
||||
if logger == nil {
|
||||
logger = slog.Default()
|
||||
}
|
||||
|
||||
return func(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
defer func() {
|
||||
if recovered := recover(); recovered != nil {
|
||||
logger.Error("Handler panicked",
|
||||
"error", recovered, "transport", "http")
|
||||
fail(w, errors.New("handler panicked"))
|
||||
}
|
||||
}()
|
||||
|
||||
next.ServeHTTP(w, r)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,274 @@
|
||||
package http
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"git.vakhrushev.me/av/transcriber/internal/contract"
|
||||
"git.vakhrushev.me/av/transcriber/internal/entity"
|
||||
"git.vakhrushev.me/av/transcriber/internal/metrics"
|
||||
)
|
||||
|
||||
// contentTypes — тип содержимого по **известному** формату.
|
||||
//
|
||||
// Ключи здесь — значения той же единой точки, что и метка метрики: формат копии
|
||||
// сперва приводится `metrics.FormatLabel`, и только приведённое ищется в этой
|
||||
// карте. Справочник системы не спрашивается вовсе — он читается из файла хоста,
|
||||
// которого в рабочем слое образа нет, и тип содержимого стал бы функцией того,
|
||||
// где сервис собран.
|
||||
//
|
||||
// Перечень закрыт, и это главное его свойство. Расширение приходит из имени,
|
||||
// данного отправителем, и потому может быть чем угодно: `запись.html` без
|
||||
// приведения ушёл бы ответом `text/html`, который браузер показывает как
|
||||
// страницу. Всё, чего в перечне нет, отдаётся `application/octet-stream` с
|
||||
// расположением `attachment` — тип, который браузер не исполняет.
|
||||
//
|
||||
// Полноту перечня держит проверка: всякий формат, который сервис объявляет
|
||||
// диалогу выбора файла, обязан иметь здесь тип содержимого.
|
||||
var contentTypes = map[string]string{
|
||||
"aac": "audio/aac",
|
||||
"avi": "video/x-msvideo",
|
||||
"flac": "audio/flac",
|
||||
"m4a": "audio/mp4",
|
||||
"mkv": "video/x-matroska",
|
||||
"mov": "video/quicktime",
|
||||
"mp3": "audio/mpeg",
|
||||
"mp4": "video/mp4",
|
||||
"oga": "audio/ogg",
|
||||
"ogg": "audio/ogg",
|
||||
"opus": "audio/ogg",
|
||||
"wav": "audio/wav",
|
||||
"webm": "video/webm",
|
||||
"wma": "audio/x-ms-wma",
|
||||
}
|
||||
|
||||
// Расположение ответа. Известному формату — показ на месте: проигрыватель в
|
||||
// браузере иначе не откроет запись. Всему прочему — сохранение: содержимым и
|
||||
// расширением распоряжается отправитель, а `attachment` браузер не исполняет.
|
||||
const (
|
||||
dispositionInline = "inline"
|
||||
dispositionAttachment = "attachment"
|
||||
)
|
||||
|
||||
// unknownContentType — тип содержимого всего, чего нет в закрытом перечне.
|
||||
const unknownContentType = "application/octet-stream"
|
||||
|
||||
// GetRecordFile отдаёт копию записи её владельцу.
|
||||
//
|
||||
// **Порядок проверок один: владение записью судится до разбора значения копии.**
|
||||
// Неизвестное либо незаданное значение копии у чужой и у несуществующей записи
|
||||
// даёт тот же ответ, что и неизвестный идентификатор. Неотличимость чужой записи
|
||||
// от несуществующей главнее формы ответа на негодный ввод: разбор параметра,
|
||||
// выполненный раньше, отвечал бы одинаково на чужую и на неизвестную только
|
||||
// случайно, а стоило бы ответам разойтись — по этой разнице перебирался бы
|
||||
// список заведённых записей одним негодным параметром.
|
||||
func (h *AppHandler) GetRecordFile(w http.ResponseWriter, r *http.Request) {
|
||||
account, _ := AccountOf(r)
|
||||
|
||||
record, err := h.readOwn(r, account.ID)
|
||||
if err != nil {
|
||||
fail(w, err)
|
||||
return
|
||||
}
|
||||
|
||||
fileID, known := copyOf(record, r.URL.Query().Get(CopyParam))
|
||||
if !known {
|
||||
fail(w, errWithMessage(contract.ErrBadRequest, "Неизвестная копия записи"))
|
||||
return
|
||||
}
|
||||
// Копии, которой у записи ещё нет, отвечает отказ состояния: пустой ответ
|
||||
// читался бы как пустой файл, а «не найдено» слилось бы с ответом на чужую и
|
||||
// неизвестную запись — человек увидел бы его на своей записи, загруженной
|
||||
// минуту назад.
|
||||
if fileID == nil {
|
||||
fail(w, contract.ErrCopyNotReady)
|
||||
return
|
||||
}
|
||||
|
||||
file, err := h.fileRepo.GetByID(*fileID)
|
||||
if err != nil {
|
||||
h.logger.Error("Failed to read the file row of a record", "error", err, "record_id", record.Id)
|
||||
fail(w, err)
|
||||
return
|
||||
}
|
||||
|
||||
content, err := h.fileRepo.Open(*fileID)
|
||||
if err != nil {
|
||||
h.logger.Error("Failed to open the file of a record", "error", err, "record_id", record.Id)
|
||||
fail(w, err)
|
||||
return
|
||||
}
|
||||
defer func() { _ = content.Close() }()
|
||||
|
||||
h.serveCopy(w, r, record, file, content)
|
||||
}
|
||||
|
||||
// copyOf выбирает копию по значению параметра. Второе значение ложно у копии,
|
||||
// которой сервис не знает, и у незаданной: умолчание сделало бы ответ функцией
|
||||
// того, что успел записать конвейер, а не состояния записи.
|
||||
func copyOf(record *entity.AudioRecord, name string) (*string, bool) {
|
||||
switch name {
|
||||
case CopyOriginal:
|
||||
return record.OriginalFileID, true
|
||||
case CopyNormalized:
|
||||
return record.NormalizedFileID, true
|
||||
}
|
||||
return nil, false
|
||||
}
|
||||
|
||||
// serveCopy отдаёт содержимое целиком либо запрошенным куском.
|
||||
//
|
||||
// Выдача по частям обязательна: запись расчётного потолка — шесть часов, и
|
||||
// проигрыватель в браузере перематывает её запросом диапазона, а не повторной
|
||||
// загрузкой целиком.
|
||||
//
|
||||
// **Негодный диапазон приводится к обычному отказу сервиса** — телом той же
|
||||
// формы и кодом из закрытого перечня, — а не отвечает `416` телом библиотеки.
|
||||
// Негодных диапазонов два вида, и оба ведут себя одинаково: неудовлетворимый
|
||||
// (начало за концом файла) и множественный (в запросе назван больше чем один
|
||||
// диапазон). Второй сервис не отдаёт намеренно: ответ из нескольких частей — это
|
||||
// отдельный тип содержимого со своими границами, а просит его один только
|
||||
// самодельный запрос.
|
||||
func (h *AppHandler) serveCopy(
|
||||
w http.ResponseWriter,
|
||||
r *http.Request,
|
||||
record *entity.AudioRecord,
|
||||
file *entity.File,
|
||||
content io.ReadSeeker,
|
||||
) {
|
||||
contentType, disposition := presentationOf(file.Format)
|
||||
|
||||
header := w.Header()
|
||||
header.Set("Content-Type", contentType)
|
||||
header.Set("Accept-Ranges", "bytes")
|
||||
// Имя файла на диске в ответ не идёт: имя, предлагаемое браузеру при
|
||||
// сохранении, строится из имени, данного отправителем, и лежит оно колонкой
|
||||
// записи.
|
||||
header.Set("Content-Disposition", dispositionOf(record, disposition))
|
||||
|
||||
raw := r.Header.Get("Range")
|
||||
if raw == "" {
|
||||
header.Set("Content-Length", strconv.FormatInt(file.Size, 10))
|
||||
w.WriteHeader(http.StatusOK)
|
||||
h.copyBody(w, r, content, file.Size)
|
||||
return
|
||||
}
|
||||
|
||||
start, length, ok := parseSingleRange(raw, file.Size)
|
||||
if !ok {
|
||||
fail(w, errWithMessage(contract.ErrBadRequest, "Запрошенный диапазон записи не читается"))
|
||||
return
|
||||
}
|
||||
|
||||
if _, err := content.Seek(start, io.SeekStart); err != nil {
|
||||
h.logger.Error("Failed to seek the file of a record", "error", err, "record_id", record.Id)
|
||||
fail(w, err)
|
||||
return
|
||||
}
|
||||
|
||||
header.Set("Content-Range", fmt.Sprintf("bytes %d-%d/%d", start, start+length-1, file.Size))
|
||||
header.Set("Content-Length", strconv.FormatInt(length, 10))
|
||||
w.WriteHeader(http.StatusPartialContent)
|
||||
h.copyBody(w, r, content, length)
|
||||
}
|
||||
|
||||
// copyBody переливает содержимое в ответ. Запрос `HEAD` тела не получает: у него
|
||||
// те же заголовки и пустое тело.
|
||||
//
|
||||
// Отказ переливания идёт **отладочной** строкой: он значит оборванное
|
||||
// соединение — человек закрыл вкладку или перемотал запись, — и владельцу
|
||||
// сервиса разбирать здесь нечего. Проглотить его молча всё же нельзя: тогда
|
||||
// оборванная отдача не отличалась бы от полной ничем.
|
||||
func (h *AppHandler) copyBody(w http.ResponseWriter, r *http.Request, content io.Reader, length int64) {
|
||||
if r.Method == http.MethodHead {
|
||||
return
|
||||
}
|
||||
|
||||
if _, err := io.CopyN(w, content, length); err != nil {
|
||||
h.logger.Debug("Failed to send the file of a record", "error", err, "transport", "http")
|
||||
}
|
||||
}
|
||||
|
||||
// presentationOf называет тип содержимого копии и её расположение.
|
||||
//
|
||||
// Оба значения выводятся из одного приведения, и порознь их выводить нельзя:
|
||||
// известный формат, показанный на месте, и незнакомый, отданный на сохранение, —
|
||||
// это одно решение, а два независимых дали бы `text/html` с `inline` у первого
|
||||
// же расширения, которого сервис не знает.
|
||||
func presentationOf(format string) (contentType, disposition string) {
|
||||
if known, ok := contentTypes[metrics.FormatLabel(format)]; ok {
|
||||
return known, dispositionInline
|
||||
}
|
||||
return unknownContentType, dispositionAttachment
|
||||
}
|
||||
|
||||
// dispositionOf строит расположение ответа вместе с именем, предлагаемым
|
||||
// браузеру при сохранении.
|
||||
//
|
||||
// Имя берётся у записи — то, что дал отправитель, — и кодируется по правилам
|
||||
// заголовка: оно приходит извне и содержимым своим сервису не подконтрольно.
|
||||
// Записи без имени получают одно расположение, без имени файла.
|
||||
func dispositionOf(record *entity.AudioRecord, disposition string) string {
|
||||
if record.OriginalFilename == nil || *record.OriginalFilename == "" {
|
||||
return disposition
|
||||
}
|
||||
return disposition + "; filename*=UTF-8''" + url.PathEscape(*record.OriginalFilename)
|
||||
}
|
||||
|
||||
// parseSingleRange разбирает заголовок диапазона.
|
||||
//
|
||||
// Второе значение ложно у всего, что сервис не отдаёт: у нечитаемого заголовка,
|
||||
// у неудовлетворимого диапазона и у запроса, называющего больше одного
|
||||
// диапазона.
|
||||
func parseSingleRange(raw string, size int64) (start, length int64, ok bool) {
|
||||
const prefix = "bytes="
|
||||
|
||||
spec, found := strings.CutPrefix(strings.TrimSpace(raw), prefix)
|
||||
if !found || strings.Contains(spec, ",") {
|
||||
return 0, 0, false
|
||||
}
|
||||
|
||||
first, last, found := strings.Cut(strings.TrimSpace(spec), "-")
|
||||
if !found {
|
||||
return 0, 0, false
|
||||
}
|
||||
first, last = strings.TrimSpace(first), strings.TrimSpace(last)
|
||||
|
||||
switch {
|
||||
case first == "":
|
||||
// Хвост записи: `bytes=-N` просит последние N байтов.
|
||||
suffix, err := strconv.ParseInt(last, 10, 64)
|
||||
if err != nil || suffix <= 0 || size == 0 {
|
||||
return 0, 0, false
|
||||
}
|
||||
if suffix > size {
|
||||
suffix = size
|
||||
}
|
||||
return size - suffix, suffix, true
|
||||
|
||||
case last == "":
|
||||
start, err := strconv.ParseInt(first, 10, 64)
|
||||
if err != nil || start < 0 || start >= size {
|
||||
return 0, 0, false
|
||||
}
|
||||
return start, size - start, true
|
||||
|
||||
default:
|
||||
start, err := strconv.ParseInt(first, 10, 64)
|
||||
if err != nil || start < 0 || start >= size {
|
||||
return 0, 0, false
|
||||
}
|
||||
end, err := strconv.ParseInt(last, 10, 64)
|
||||
if err != nil || end < start {
|
||||
return 0, 0, false
|
||||
}
|
||||
if end >= size {
|
||||
end = size - 1
|
||||
}
|
||||
return start, end - start + 1, true
|
||||
}
|
||||
}
|
||||
@@ -1,121 +1,93 @@
|
||||
package http
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"log/slog"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/netip"
|
||||
|
||||
"github.com/pocketbase/pocketbase/apis"
|
||||
"github.com/pocketbase/pocketbase/core"
|
||||
"github.com/pocketbase/pocketbase/tools/hook"
|
||||
|
||||
pbrepo "git.vakhrushev.me/av/transcriber/internal/adapter/repo/pocketbase"
|
||||
"git.vakhrushev.me/av/transcriber/internal/contract"
|
||||
)
|
||||
|
||||
// Заголовки, которыми обратный прокси называет пришедшего.
|
||||
//
|
||||
// Имена нормативны — ровно как было нормативно имя куки сессии, и по той же
|
||||
// причине: смена имени молча перестаёт узнавать всех, а проверка, которая сама
|
||||
// ставит и сама читает своё имя, этого не замечает. Контур уже пишет эти имена
|
||||
// соседним сервисам, и настройкой они не делаются: второе место, где их можно
|
||||
// написать неверно, выгоды не даёт.
|
||||
// Имена нормативны: смена имени молча перестаёт узнавать всех, а проверка,
|
||||
// которая сама ставит и сама читает своё имя, этого не замечает. Контур уже
|
||||
// пишет эти имена соседним сервисам, и настройкой они не делаются: второе место,
|
||||
// где их можно написать неверно, выгоды не даёт.
|
||||
const (
|
||||
LoginHeader = "Remote-User"
|
||||
NameHeader = "Remote-Name"
|
||||
EmailHeader = "Remote-Email"
|
||||
)
|
||||
|
||||
// FileTokenPath — адрес, которым хранилище выдаёт короткий токен файла.
|
||||
//
|
||||
// Он лежит в пространстве хранилища, а не приложения, и потому назван здесь
|
||||
// поимённо: без узнавания на нём файл записи недостижим для браузера вовсе —
|
||||
// порядок «узнавание → токен файла → ссылка» обрывается на первом шаге.
|
||||
const FileTokenPath = StorageRoot + "/files/token"
|
||||
// ForwardedForHeader — заголовок, которым прокси называет адрес спрашивающего.
|
||||
// Читает его только ограничитель частоты: барьером узнавания он не служит и
|
||||
// служить не может — кто пришёл, решает адрес самого соединения.
|
||||
const ForwardedForHeader = "X-Forwarded-For"
|
||||
|
||||
// accountKey — ключ, под которым узнанная учётная запись живёт в контексте
|
||||
// запроса. Свой тип, а не строка: чужой ключ с тем же текстом иначе перезаписал
|
||||
// бы значение.
|
||||
type accountKey struct{}
|
||||
|
||||
// AccountOf отдаёт учётную запись, от имени которой идёт запрос. Второе значение
|
||||
// ложно у неузнанного.
|
||||
func AccountOf(r *http.Request) (*contract.UserAccount, bool) {
|
||||
account, ok := r.Context().Value(accountKey{}).(*contract.UserAccount)
|
||||
return account, ok
|
||||
}
|
||||
|
||||
// withAccount кладёт узнанную учётную запись в контекст запроса.
|
||||
func withAccount(r *http.Request, account *contract.UserAccount) *http.Request {
|
||||
return r.WithContext(context.WithValue(r.Context(), accountKey{}, account))
|
||||
}
|
||||
|
||||
// TrustedHeaderIdentity узнаёт пришедшего по заголовку доверенного источника.
|
||||
//
|
||||
// # Область
|
||||
//
|
||||
// Слой вешается корневым — иначе к адресу выдачи файлового токена его не
|
||||
// привязать: тот принадлежит роутеру хранилища, и группой его не накрыть. Но
|
||||
// узнаёт он **только на объявленной области**: корень приложения плюс этот
|
||||
// адрес. Область выводится из перечня адресного пространства, а не пишется
|
||||
// вторым списком.
|
||||
// Слой вешается на цепочку корня приложения и только на неё. Область поэтому
|
||||
// выводится из объявленного адресного пространства сервиса, а не перечисляется
|
||||
// вторым списком: корень, переехавший в перечне, уносит слой с собой.
|
||||
//
|
||||
// Сужение здесь не бережливость, а барьер. Ключ учётной записи лежит обычной
|
||||
// колонкой коллекции пользователей, и узнавание на всей поверхности хранилища
|
||||
// дало бы узнанному переписать себе ключ на чужое имя — а первое обращение
|
||||
// настоящего владельца этого имени попало бы в чужую запись вместе со всем
|
||||
// архивом. Схема закрывает этот путь и со своей стороны, правилами коллекции;
|
||||
// два барьера здесь именно потому, что прежний был один и держался на
|
||||
// случайности — на том, что браузер сам не шлёт заголовка авторизации.
|
||||
//
|
||||
// Второе следствие: узнавание не срабатывает на пробе здоровья, на метриках и
|
||||
// на ресурсах приложения. Иначе запрос за каждой картинкой стоил бы обращения к
|
||||
// базе, а первый такой запрос с новым именем — записи в неё.
|
||||
//
|
||||
// # Кто побеждает
|
||||
//
|
||||
// Учётная запись ставится, только когда её ещё нет, — то есть когда слой чтения
|
||||
// токена никого не нашёл. Владелец панели предъявляет свой токен, и подмена его
|
||||
// учётной записью пользователя отобрала бы у него панель посреди работы.
|
||||
// Протухший и негодный токен предъявленными не считаются: библиотека их не
|
||||
// прочитала, `e.Auth` пуст, и запрос узнаётся заголовком.
|
||||
// Сужение закрывает вещь, которая от смены хранилища не зависит: узнавание не
|
||||
// срабатывает на пробе здоровья, на метриках и на ресурсах приложения. Иначе
|
||||
// запрос за каждой картинкой стоил бы обращения к базе, а первый такой запрос с
|
||||
// новым именем — записи в неё.
|
||||
//
|
||||
// # Чего слой не делает
|
||||
//
|
||||
// Отказа он не выдаёт. Проба здоровья, метрики и разметка приложения открыты
|
||||
// неузнанному, и отказ в слое закрыл бы наблюдение за сервисом всякому, кто
|
||||
// пришлёт заголовок. Отказ приходит там, где приходил всегда, — требованием
|
||||
// учётной записи на адресах приложения.
|
||||
//
|
||||
// Исключение одно: отказ **хранилища**. Он кончается отказом сервиса, а не
|
||||
// молчаливым проходом неузнанным, — иначе человек увидел бы отказ входа там, где
|
||||
// легла база.
|
||||
// Отказа он не выдаёт: отказ приходит там, где приходил всегда, — требованием
|
||||
// учётной записи. Исключение одно — отказ базы: он кончается отказом сервиса, а
|
||||
// не молчаливым проходом неузнанным, иначе человек увидел бы отказ входа там,
|
||||
// где легла база.
|
||||
func TrustedHeaderIdentity(
|
||||
app core.App,
|
||||
mounts []Mount,
|
||||
users contract.UserRepository,
|
||||
trusted []netip.Prefix,
|
||||
logger *slog.Logger,
|
||||
) *hook.Handler[*core.RequestEvent] {
|
||||
) func(http.Handler) http.Handler {
|
||||
if logger == nil {
|
||||
logger = slog.Default()
|
||||
}
|
||||
|
||||
return &hook.Handler[*core.RequestEvent]{
|
||||
Id: "transcriberTrustedHeaderIdentity",
|
||||
// **За ограничителем частоты, а не перед ним.** Узнавание читает базу, а
|
||||
// на новом имени ещё и пишет в неё; поставленное раньше ограничителя, оно
|
||||
// работало на запросах, которые тот уже отверг. Замер: сто двадцать
|
||||
// запросов выбирают бюджет, следующие пятьдесят с новыми именами
|
||||
// получают отказ — и заводят пятьдесят учётных записей, которые потом не
|
||||
// убираются ничем.
|
||||
//
|
||||
// Порядок целиком: чтение токена (−1020) → ограничитель (−1000) →
|
||||
// узнавание (−999) → требование учётной записи (−998) → предел тела
|
||||
// (−990). Требование стоит перед пределом тела намеренно: отказ
|
||||
// неузнанному обязан наступать до чтения тела.
|
||||
Priority: apis.DefaultRateLimitMiddlewarePriority + 1,
|
||||
Func: func(e *core.RequestEvent) error {
|
||||
if e.Auth != nil || !underIdentifiedArea(mounts, e.Request.URL.Path) {
|
||||
return e.Next()
|
||||
}
|
||||
|
||||
return func(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
// Более одного значения — не выбор, а отказ. Прокси, настроенный
|
||||
// добавлять заголовок вместо замены, оставляет рядом со своим
|
||||
// значением присланное анонимом, и умолчание «берём первое» отдало
|
||||
// бы вход анониму.
|
||||
values := e.Request.Header.Values(LoginHeader)
|
||||
values := r.Header.Values(LoginHeader)
|
||||
if len(values) != 1 {
|
||||
if len(values) > 1 {
|
||||
// Уровень предупреждающий: два значения означают прокси,
|
||||
// который заголовок **добавляет** вместо замены, — то есть
|
||||
// ровно ту поломку контура, которую модель угроз называет
|
||||
// главной. Отладочным уровнем она в бою не видна вовсе:
|
||||
// боевой уровень журнала информационный.
|
||||
// главной. Отладочным уровнем она в бою не видна вовсе.
|
||||
logger.Warn("Request carries more than one login header",
|
||||
"http.peer_addr", e.Request.RemoteAddr,
|
||||
"http.peer_addr", r.RemoteAddr,
|
||||
"capability", "access", "transport", "http")
|
||||
} else {
|
||||
// Заголовка нет вовсе. В этом контуре это значит, что прокси
|
||||
@@ -123,13 +95,14 @@ func TrustedHeaderIdentity(
|
||||
// штатный — так выглядит и человек, которому провайдер
|
||||
// отказал.
|
||||
logger.Debug("Request carries no login header",
|
||||
"http.peer_addr", e.Request.RemoteAddr,
|
||||
"http.peer_addr", r.RemoteAddr,
|
||||
"capability", "access", "transport", "http")
|
||||
}
|
||||
return e.Next()
|
||||
next.ServeHTTP(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
peer, ok := peerAddress(e.Request.RemoteAddr)
|
||||
peer, ok := peerAddress(r.RemoteAddr)
|
||||
if !ok || !isTrusted(trusted, peer) {
|
||||
// Уровень предупреждающий, а не отладочный, и это решение о
|
||||
// цене. Заголовок с недоверенного адреса в этом контуре — не
|
||||
@@ -143,31 +116,34 @@ func TrustedHeaderIdentity(
|
||||
// видно, чья это поломка: своя (перечень) или контура (прокси
|
||||
// заголовка не ставит).
|
||||
logger.Warn("Login header came from an untrusted peer",
|
||||
"http.peer_addr", e.Request.RemoteAddr,
|
||||
"http.peer_addr", r.RemoteAddr,
|
||||
"capability", "access", "transport", "http")
|
||||
return e.Next()
|
||||
next.ServeHTTP(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
record, created, err := pbrepo.EnsureUser(app, pbrepo.Identity{
|
||||
account, created, err := users.EnsureUser(contract.Identity{
|
||||
Login: values[0],
|
||||
Name: e.Request.Header.Get(NameHeader),
|
||||
Email: e.Request.Header.Get(EmailHeader),
|
||||
Name: r.Header.Get(NameHeader),
|
||||
Email: r.Header.Get(EmailHeader),
|
||||
})
|
||||
if err != nil {
|
||||
if errors.Is(err, pbrepo.ErrLoginNotAcceptable) {
|
||||
if errors.Is(err, contract.ErrLoginNotAcceptable) {
|
||||
// Негодный логин — это негодный ввод, а не поломка сервиса:
|
||||
// пустой заголовок прокси шлёт штатно там, где никого не
|
||||
// назвал. Уровень поэтому отладочный, и запрос идёт дальше
|
||||
// неузнанным.
|
||||
logger.Debug("Login header value is not acceptable",
|
||||
"http.peer_addr", e.Request.RemoteAddr,
|
||||
"http.peer_addr", r.RemoteAddr,
|
||||
"capability", "access", "transport", "http")
|
||||
return e.Next()
|
||||
next.ServeHTTP(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
logger.Error("Failed to resolve account by login header",
|
||||
"error", err, "capability", "access", "transport", "http")
|
||||
return fail(e, err)
|
||||
fail(w, err)
|
||||
return
|
||||
}
|
||||
|
||||
if created {
|
||||
@@ -181,36 +157,35 @@ func TrustedHeaderIdentity(
|
||||
// чтобы стать этим человеком. Идут адрес пира и идентификатор
|
||||
// записи — оба выданы не спрашивающим.
|
||||
logger.Info("Account created from login header",
|
||||
"http.peer_addr", e.Request.RemoteAddr,
|
||||
"account_id", record.Id,
|
||||
"http.peer_addr", r.RemoteAddr,
|
||||
"account_id", account.ID,
|
||||
"capability", "access", "transport", "http")
|
||||
}
|
||||
|
||||
e.Auth = record
|
||||
|
||||
return e.Next()
|
||||
},
|
||||
next.ServeHTTP(w, withAccount(r, account))
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// underIdentifiedArea говорит, узнаётся ли пришедший на этом пути.
|
||||
// RequireUser — слой предъявления адресов приложения.
|
||||
//
|
||||
// Область — корень приложения из перечня адресного пространства плюс адрес
|
||||
// выдачи файлового токена. Корень берётся из перечня, а не литералом: перечень
|
||||
// объявлен единой точкой адресного пространства, и записанный здесь второй раз
|
||||
// он разошёлся бы с ней молча.
|
||||
func underIdentifiedArea(mounts []Mount, requestPath string) bool {
|
||||
if requestPath == FileTokenPath {
|
||||
return true
|
||||
}
|
||||
// Отказ наступает **до чтения тела**: запись, за которую не заплатит узнанный
|
||||
// отправитель, не должна попасть даже в память, а позже пришлось бы убирать уже
|
||||
// уложенный файл — чего сервис не умеет вовсе.
|
||||
//
|
||||
// Ветви «узнан, а учётной записи нет» здесь больше нет: узнавание заводит
|
||||
// учётную запись само, и предъявителя без неё не бывает.
|
||||
func RequireUser() func(http.Handler) http.Handler {
|
||||
return func(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if _, ok := AccountOf(r); !ok {
|
||||
fail(w, contract.ErrUnauthorized)
|
||||
return
|
||||
}
|
||||
|
||||
for _, mount := range mounts {
|
||||
if mount.Path == AppRoot {
|
||||
return mount.Covers(requestPath)
|
||||
}
|
||||
next.ServeHTTP(w, r)
|
||||
})
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
// peerAddress достаёт адрес того, кто открыл соединение.
|
||||
|
||||
@@ -0,0 +1,175 @@
|
||||
package http
|
||||
|
||||
import (
|
||||
"context"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
|
||||
"time"
|
||||
|
||||
"git.vakhrushev.me/av/transcriber/internal/clock"
|
||||
)
|
||||
|
||||
// webappRoute — чем в журнале обозначается всякий путь, отданный приложению.
|
||||
const webappRoute = "<приложение>"
|
||||
|
||||
// journalNote — то, что обработчик оставляет слою журнала о своём запросе.
|
||||
// Указателем в контексте: значение кладёт слой, а заполняет обработчик ниже.
|
||||
type journalNote struct {
|
||||
outcome string
|
||||
}
|
||||
|
||||
type journalKey struct{}
|
||||
|
||||
// noteWebappOutcome оставляет исход раздачи слою журнала.
|
||||
func noteWebappOutcome(r *http.Request, outcome string) {
|
||||
if note, ok := r.Context().Value(journalKey{}).(*journalNote); ok {
|
||||
note.outcome = outcome
|
||||
}
|
||||
}
|
||||
|
||||
// Journal пишет строку о каждом входящем запросе.
|
||||
//
|
||||
// **Путь, которым распоряжается спрашивающий, в журнал не идёт** — вместо него
|
||||
// маршрут из закрытого перечня и длина: по ним видно, что происходит, а
|
||||
// дословная запись сделала бы журнал местом, куда аноним пишет свой текст
|
||||
// произвольной длины. Корень приложения от этого правила не изъят: путь под ним
|
||||
// выбирает тот же спрашивающий.
|
||||
//
|
||||
// Журнал у сервиса **один**: второй, куда чужая библиотека клала путь целиком
|
||||
// вместе с адресом отправителя, ушёл вместе с ней.
|
||||
func Journal(mounts []Mount, logger *slog.Logger) func(http.Handler) http.Handler {
|
||||
if logger == nil {
|
||||
logger = slog.Default()
|
||||
}
|
||||
|
||||
return func(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
start := clock.Start()
|
||||
|
||||
note := &journalNote{}
|
||||
ctx := context.WithValue(r.Context(), journalKey{}, note)
|
||||
|
||||
recorder := &statusRecorder{ResponseWriter: w, status: http.StatusOK}
|
||||
next.ServeHTTP(recorder, r.WithContext(ctx))
|
||||
|
||||
level := slog.LevelInfo
|
||||
if IsObservationAddress(mounts, r.URL.Path) {
|
||||
// Опрос здоровья и метрик идёт постоянно и полезного не несёт.
|
||||
level = slog.LevelDebug
|
||||
}
|
||||
|
||||
attrs := []any{
|
||||
"http.method", r.Method,
|
||||
"http.route", JournalRoute(r, mounts),
|
||||
"http.status_code", recorder.status,
|
||||
"duration_ms", time.Since(start).Milliseconds(),
|
||||
// Длина идёт на всякую строку, а не только на раздачу
|
||||
// приложения: обобщённое значение маршрута теперь достаётся и
|
||||
// путям под корнем приложения, и без длины по строке не видно,
|
||||
// спросили короткий адрес или мегабайт текста.
|
||||
"http.path_length", len(r.URL.Path),
|
||||
"transport", "http",
|
||||
}
|
||||
|
||||
if note.outcome != "" {
|
||||
attrs = append(attrs, "webapp.outcome", note.outcome)
|
||||
}
|
||||
|
||||
logger.Log(ctx, level, "Incoming request", attrs...)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// JournalRoute готовит путь запроса к записи в журнал.
|
||||
//
|
||||
// **Дословно в журнал идёт только то, что принадлежит закрытому перечню**:
|
||||
// точные адреса наблюдения и образцы адресов приложения. Всё прочее — и путь
|
||||
// вне корней сервиса, и путь под корнем приложения, не совпавший ни с одним
|
||||
// образцом, — обозначается одним значением, а длина уходит отдельным полем.
|
||||
//
|
||||
// Прежнее правило судило по принадлежности пути сервису и оставляло зазор
|
||||
// шириной в корень приложения: `/app/<произвольный текст>` принадлежит сервису,
|
||||
// отвечает отказом неузнанному и при этом уезжал в журнал дословно. Множеством
|
||||
// значений под корнем распоряжается спрашивающий ровно так же, как и вне его, —
|
||||
// значит и правило одно на обе половины.
|
||||
//
|
||||
// Идентификатор записи из строки при этом не пропадает: его пишет обработчик
|
||||
// полем `record_id`, и пишет он тот, что прочитал, а не тот, что попросили.
|
||||
//
|
||||
// Имя файла в хранилище из журнала выводимо быть не должно, и сегодня оно туда
|
||||
// не попадает по построению: адрес копии записи назван идентификатором самой
|
||||
// записи, а имя файла на диске в путь не входит вовсе.
|
||||
func JournalRoute(r *http.Request, mounts []Mount) string {
|
||||
if exact, ok := ExactAddressOf(mounts, r.URL.Path); ok {
|
||||
return exact
|
||||
}
|
||||
|
||||
if pattern, ok := appRoutePatternOf(r); ok {
|
||||
return pattern
|
||||
}
|
||||
|
||||
return webappRoute
|
||||
}
|
||||
|
||||
// appRouteIndex — маршрутизатор, заведённый ради одного вопроса: какому образцу
|
||||
// приложения отвечает этот запрос.
|
||||
//
|
||||
// Маршрутизатор, а не свой разбор пути: образец `{id}` разбирает стандартная
|
||||
// библиотека, и второй разбор рядом с ней разошёлся бы с настоящей
|
||||
// маршрутизацией молча. Образцы берутся тем же перечнем, которым вешаются
|
||||
// обработчики.
|
||||
var appRouteIndex = newAppRouteIndex()
|
||||
|
||||
func newAppRouteIndex() *http.ServeMux {
|
||||
mux := http.NewServeMux()
|
||||
for _, pattern := range AppRoutePatterns {
|
||||
mux.Handle(pattern, http.NotFoundHandler())
|
||||
}
|
||||
return mux
|
||||
}
|
||||
|
||||
// knownAppRoutes — тот же перечень множеством: ответ маршрутизатора сверяется с
|
||||
// ним. Перенаправление на очищенный путь маршрутизатор отдаёт образцом,
|
||||
// собранным из самого пути, и без сверки такой ответ уехал бы в журнал
|
||||
// дословно — то есть ровно тем, чего правило не допускает.
|
||||
var knownAppRoutes = knownAppRouteSet()
|
||||
|
||||
func knownAppRouteSet() map[string]struct{} {
|
||||
out := make(map[string]struct{}, len(AppRoutePatterns))
|
||||
for _, pattern := range AppRoutePatterns {
|
||||
out[pattern] = struct{}{}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// appRoutePatternOf называет образец адреса приложения. Второе значение ложно у
|
||||
// всего, что ни одному образцу не отвечает.
|
||||
func appRoutePatternOf(r *http.Request) (string, bool) {
|
||||
_, pattern := appRouteIndex.Handler(r)
|
||||
if _, ok := knownAppRoutes[pattern]; !ok {
|
||||
return "", false
|
||||
}
|
||||
return pattern, true
|
||||
}
|
||||
|
||||
// statusRecorder запоминает код ответа: журнал пишется после обработчика, а
|
||||
// готовый код читать больше неоткуда.
|
||||
type statusRecorder struct {
|
||||
http.ResponseWriter
|
||||
status int
|
||||
written bool
|
||||
}
|
||||
|
||||
func (w *statusRecorder) WriteHeader(status int) {
|
||||
if !w.written {
|
||||
w.status = status
|
||||
w.written = true
|
||||
}
|
||||
w.ResponseWriter.WriteHeader(status)
|
||||
}
|
||||
|
||||
func (w *statusRecorder) Write(p []byte) (int, error) {
|
||||
w.written = true
|
||||
return w.ResponseWriter.Write(p)
|
||||
}
|
||||
@@ -0,0 +1,149 @@
|
||||
package http
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
// journalMounts — перечень адресного пространства для проверок журнала.
|
||||
// Обработчиков он здесь не вешает: журналу нужны только границы, а не то, что
|
||||
// стоит за ними.
|
||||
func journalMounts() []Mount {
|
||||
return []Mount{
|
||||
{Path: AppRoot},
|
||||
{Path: HealthPath, Exact: true},
|
||||
{Path: MetricsPath, Exact: true},
|
||||
}
|
||||
}
|
||||
|
||||
// journalRouteOf — маршрут строки журнала для названного пути.
|
||||
func journalRouteOf(path string) string {
|
||||
return JournalRoute(httptest.NewRequest(http.MethodGet, path, http.NoBody), journalMounts())
|
||||
}
|
||||
|
||||
// Путь, которым распоряжается спрашивающий, в журнал дословно не идёт — и
|
||||
// принадлежность сервису тут ничего не меняет: путь под корнем приложения
|
||||
// выбирает тот же аноним, что и путь вне корней.
|
||||
func TestJournalRouteHidesRequestedPath(t *testing.T) {
|
||||
cases := []string{
|
||||
"/",
|
||||
"/records/abc123def456ghi",
|
||||
"/" + strings.Repeat("a", 1024),
|
||||
"/api/files/files/rec0000000000000/0f7b8dd3-d1cc-424c.mp3",
|
||||
"/_/",
|
||||
// Под корнем приложения — то же самое: образца с таким хвостом нет.
|
||||
"/app",
|
||||
"/app/",
|
||||
"/app/" + strings.Repeat("b", 1024),
|
||||
"/app/audiorecords/abc123def456ghi/file/" + strings.Repeat("c", 512),
|
||||
"/app/audiorecords/abc/def/ghi",
|
||||
}
|
||||
|
||||
for _, path := range cases {
|
||||
assert.Equal(t, webappRoute, journalRouteOf(path), path)
|
||||
}
|
||||
}
|
||||
|
||||
// Дословно пишется закрытый перечень: точные адреса наблюдения и образцы
|
||||
// адресов приложения. Идентификатор записи в образец не входит — он приходит
|
||||
// строкой обработчика полем `record_id`.
|
||||
func TestJournalRouteKeepsClosedList(t *testing.T) {
|
||||
cases := map[string]string{
|
||||
"/app/audiorecords": AppRouteRecords,
|
||||
"/app/audiorecords/abc123def456ghi": AppRouteRecord,
|
||||
"/app/audiorecords/abc123def456ghi/text": AppRouteRecordText,
|
||||
"/app/audiorecords/abc123def456ghi/file": AppRouteRecordFile,
|
||||
"/app/me": AppRouteMe,
|
||||
"/app/config": AppRouteConfig,
|
||||
HealthPath: HealthPath,
|
||||
MetricsPath: MetricsPath,
|
||||
}
|
||||
|
||||
for path, want := range cases {
|
||||
assert.Equal(t, want, journalRouteOf(path), path)
|
||||
}
|
||||
}
|
||||
|
||||
// Значение маршрута берётся из перечня образцов и ничего сверх него не
|
||||
// возвращает: путь, приведённый маршрутизатором к другому виду, дословно уехать
|
||||
// не может.
|
||||
func TestJournalRouteAnswersOnlyFromClosedList(t *testing.T) {
|
||||
allowed := map[string]bool{webappRoute: true, HealthPath: true, MetricsPath: true}
|
||||
for _, pattern := range AppRoutePatterns {
|
||||
allowed[pattern] = true
|
||||
}
|
||||
|
||||
cases := []string{
|
||||
"/app/me/",
|
||||
"/app/audiorecords/../me",
|
||||
"/app//me",
|
||||
"/app/audiorecords/{id}",
|
||||
"/app/me/" + strings.Repeat("d", 256),
|
||||
}
|
||||
|
||||
for _, path := range cases {
|
||||
route := journalRouteOf(path)
|
||||
assert.True(t, allowed[route], "маршрут %q не принадлежит закрытому перечню (путь %q)", route, path)
|
||||
}
|
||||
}
|
||||
|
||||
// Имя, под которым копия легла в каталог данных, в журнал не идёт: строка
|
||||
// журнала иначе стала бы бессрочным ключом к чужой записи. Сегодня оно не
|
||||
// попадает туда по построению — адрес копии назван идентификатором самой записи,
|
||||
// — и проверка сторожит именно это.
|
||||
func TestJournalCarriesNoStoredFileName(t *testing.T) {
|
||||
env := setupTestEnv(t, readableMetaViewer())
|
||||
|
||||
created := httptest.NewRecorder()
|
||||
env.serve(created, createMultipartRequest(t, "разговор.mp3", []byte("данные")))
|
||||
require.Equal(t, http.StatusCreated, created.Code)
|
||||
|
||||
recordID := intakeItemOf(t, created).ID
|
||||
require.Equal(t, http.StatusOK,
|
||||
env.getOwn("/app/audiorecords/"+recordID+"/file?"+CopyParam+"="+CopyOriginal).Code)
|
||||
|
||||
names := storedFileNames(t, env)
|
||||
require.Len(t, names, 1)
|
||||
|
||||
journal := env.journal.String()
|
||||
assert.NotContains(t, journal, names[0], "имя файла в каталоге данных уехало в журнал")
|
||||
assert.Contains(t, journal, recordID, "идентификатор записи остаётся: по нему прослеживается путь")
|
||||
}
|
||||
|
||||
// Путь, отданный приложению, журнал заменяет исходом и длиной: строка о нём не
|
||||
// растёт вместе с длиной пути.
|
||||
func TestJournalWebappOutcomeInsteadOfPath(t *testing.T) {
|
||||
env := setupTestEnv(t, readableMetaViewer())
|
||||
|
||||
long := "/" + strings.Repeat("щ", 500)
|
||||
require.Equal(t, http.StatusOK, env.get(long).Code)
|
||||
|
||||
journal := env.journal.String()
|
||||
assert.NotContains(t, journal, strings.Repeat("щ", 500), "путь уехал в журнал дословно")
|
||||
assert.Contains(t, journal, "webapp.outcome="+OutcomeMarkup)
|
||||
assert.Contains(t, journal, "http.path_length=")
|
||||
}
|
||||
|
||||
// Неузнанный запрос под корнем приложения журнал тоже не пишет дословно, а
|
||||
// строка о нём не растёт вместе с длиной пути. Прежде путь под корнем считался
|
||||
// принадлежащим сервису и уезжал в строку целиком — аноним писал в журнал
|
||||
// владельца свой текст произвольной длины.
|
||||
func TestJournalHidesAnonymousPathUnderAppRoot(t *testing.T) {
|
||||
env := setupTestEnv(t, readableMetaViewer())
|
||||
|
||||
const filler = 4096
|
||||
long := "/app/" + strings.Repeat("щ", filler)
|
||||
require.Equal(t, http.StatusUnauthorized, env.get(long).Code)
|
||||
|
||||
journal := env.journal.String()
|
||||
assert.NotContains(t, journal, strings.Repeat("щ", filler), "путь уехал в журнал дословно")
|
||||
assert.Contains(t, journal, "http.route="+webappRoute)
|
||||
assert.Contains(t, journal, "http.path_length=")
|
||||
assert.Less(t, len(journal), filler,
|
||||
"строка журнала растёт вместе с длиной запрошенного пути")
|
||||
}
|
||||
@@ -9,12 +9,9 @@ import (
|
||||
"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/adapter/repo/pocketbase/migrations"
|
||||
"git.vakhrushev.me/av/transcriber/internal/entity"
|
||||
)
|
||||
|
||||
@@ -168,7 +165,7 @@ func TestList_MalformedCursorIsRejected(t *testing.T) {
|
||||
},
|
||||
{
|
||||
name: "декодируется, но идентификатора нет",
|
||||
cursor: base64.RawURLEncoding.EncodeToString([]byte("2026-08-15 10:00:00.000Z|")),
|
||||
cursor: base64.RawURLEncoding.EncodeToString([]byte("2026-08-15T10:00:00Z|")),
|
||||
},
|
||||
{
|
||||
name: "пара пуста целиком",
|
||||
@@ -202,25 +199,11 @@ func TestTopicsResolveToNames(t *testing.T) {
|
||||
|
||||
// Тема заводится напрямую: словарь тем свой у каждого человека, и пишет его
|
||||
// задача языковой модели, которой ещё нет.
|
||||
topics, err := env.app.FindCollectionByNameOrId(migrations.TopicsCollection)
|
||||
require.NoError(t, err)
|
||||
topic := core.NewRecord(topics)
|
||||
topic.Set("owner", env.account.Id)
|
||||
topic.Set("name", "семейный архив")
|
||||
require.NoError(t, env.app.Save(topic))
|
||||
topicID := newTopic(t, env, env.account.ID, "семейный архив")
|
||||
attachTopic(t, env, all.Items[0].ID, topicID)
|
||||
|
||||
repo := pbrepo.NewAudioRecordRepository(env.app)
|
||||
record, err := repo.GetByID(all.Items[0].ID, env.account.Id)
|
||||
record, err := env.handler.recordRepo.GetByID(all.Items[0].ID, env.account.ID)
|
||||
require.NoError(t, err)
|
||||
record.TopicIDs = []string{topic.Id}
|
||||
require.NoError(t, repo.Save(record, ""))
|
||||
|
||||
// Правку тем конвейер не делает, поэтому кладём их тем же путём, каким это
|
||||
// сделает задача языковой модели, — прямым сохранением записи коллекции.
|
||||
raw, err := env.app.FindRecordById(migrations.RecordsCollection, record.Id)
|
||||
require.NoError(t, err)
|
||||
raw.Set("topics", []string{topic.Id})
|
||||
require.NoError(t, env.app.Save(raw))
|
||||
|
||||
page := pageOf(t, env, "")
|
||||
require.Len(t, page.Items, 1)
|
||||
@@ -247,14 +230,14 @@ func TestList_ThreeStatesEachRecordOnce(t *testing.T) {
|
||||
all := pageOf(t, env, "")
|
||||
require.Len(t, all.Items, 3)
|
||||
|
||||
repo := pbrepo.NewAudioRecordRepository(env.app)
|
||||
repo := env.handler.recordRepo
|
||||
|
||||
halted, err := repo.GetByID(all.Items[0].ID, env.account.Id)
|
||||
halted, err := repo.GetByID(all.Items[0].ID, env.account.ID)
|
||||
require.NoError(t, err)
|
||||
halted.Halt(entity.HaltReasonStuck, "застряла")
|
||||
require.NoError(t, repo.Save(halted, ""))
|
||||
|
||||
done, err := repo.GetByID(all.Items[1].ID, env.account.Id)
|
||||
done, err := repo.GetByID(all.Items[1].ID, env.account.ID)
|
||||
require.NoError(t, err)
|
||||
done.MoveToState(entity.StateDone)
|
||||
require.NoError(t, repo.Save(done, ""))
|
||||
@@ -295,11 +278,11 @@ func TestList_ShowsOnlyOwnRecords(t *testing.T) {
|
||||
|
||||
acceptRecords(t, env, 2)
|
||||
|
||||
_, stranger := newSecondAccount(t, env.app)
|
||||
newSecondAccount(t, env)
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
req := httptest.NewRequest("GET", "/app/audiorecords", http.NoBody)
|
||||
asUser(req, stranger)
|
||||
asUser(req, "stranger")
|
||||
env.mux.ServeHTTP(w, req)
|
||||
require.Equal(t, http.StatusOK, w.Code)
|
||||
|
||||
@@ -319,12 +302,12 @@ func TestList_DoesNotReadTranscript(t *testing.T) {
|
||||
all := pageOf(t, env, "")
|
||||
require.Len(t, all.Items, 1)
|
||||
|
||||
repo := pbrepo.NewAudioRecordRepository(env.app)
|
||||
record, err := repo.GetByID(all.Items[0].ID, env.account.Id)
|
||||
repo := env.handler.recordRepo
|
||||
record, err := repo.GetByID(all.Items[0].ID, env.account.ID)
|
||||
require.NoError(t, err)
|
||||
|
||||
const marker = "СОДЕРЖИМОЕ-РАСШИФРОВКИ-МАРКЕР"
|
||||
texts := pbrepo.NewTextRepository(env.app)
|
||||
texts := env.handler.textRepo
|
||||
transcript, err := texts.Put(record.Id, entity.TextKindTranscript, marker)
|
||||
require.NoError(t, err)
|
||||
record.TranscriptTextID = &transcript.Id
|
||||
@@ -413,19 +396,10 @@ func TestForeignTopicDoesNotResolve(t *testing.T) {
|
||||
all := pageOf(t, env, "")
|
||||
require.Len(t, all.Items, 1)
|
||||
|
||||
stranger, _ := newSecondAccount(t, env.app)
|
||||
stranger := newSecondAccount(t, env)
|
||||
|
||||
topics, err := env.app.FindCollectionByNameOrId(migrations.TopicsCollection)
|
||||
require.NoError(t, err)
|
||||
foreign := core.NewRecord(topics)
|
||||
foreign.Set("owner", stranger.Id)
|
||||
foreign.Set("name", "ЧУЖАЯ-ТЕМА-МАРКЕР")
|
||||
require.NoError(t, env.app.Save(foreign))
|
||||
|
||||
raw, err := env.app.FindRecordById(migrations.RecordsCollection, all.Items[0].ID)
|
||||
require.NoError(t, err)
|
||||
raw.Set("topics", []string{foreign.Id})
|
||||
require.NoError(t, env.app.Save(raw))
|
||||
foreign := newTopic(t, env, stranger.ID, "ЧУЖАЯ-ТЕМА-МАРКЕР")
|
||||
attachTopic(t, env, all.Items[0].ID, foreign)
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
env.serve(w, httptest.NewRequest("GET", "/app/audiorecords", http.NoBody))
|
||||
|
||||
@@ -0,0 +1,124 @@
|
||||
package http
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// Отдельные адреса наблюдения. Корней у сервиса остался один — корень
|
||||
// приложения; `/api` и `/_` ушли вместе со встроенным хранилищем и его панелью,
|
||||
// и адресов под этими именами не существует.
|
||||
//
|
||||
// Резервировать имя за отказом сервис не берётся: имя, за которым ничего не
|
||||
// стоит, ничем не отличается от любого другого свободного, а второй перечень
|
||||
// «когда-то занятых корней» разошёлся бы с первым молча.
|
||||
const (
|
||||
HealthPath = "/health"
|
||||
MetricsPath = "/metrics"
|
||||
)
|
||||
|
||||
// Mount — часть адресного пространства, принадлежащая сервису.
|
||||
//
|
||||
// Перечень этих частей — **единственное** описание того, что сервису
|
||||
// принадлежит, и он не описывает регистрацию, а порождает её: корень,
|
||||
// заведённый мимо перечня, не получит обработчика вовсе. Из него же выводятся
|
||||
// правило неизвестного пути, уровень журнала и область действия узнавания.
|
||||
type Mount struct {
|
||||
// Path — корень либо точный адрес.
|
||||
Path string
|
||||
|
||||
// Exact — путь является точным адресом, а не корнем: `/health` накрывает
|
||||
// только сам себя, а `/app` — всё, что под ним.
|
||||
Exact bool
|
||||
|
||||
// Bind вешает обработчики этой части.
|
||||
Bind func(mux *http.ServeMux)
|
||||
}
|
||||
|
||||
// Covers говорит, принадлежит ли путь этой части адресного пространства.
|
||||
//
|
||||
// Условий два, и оба обязательны: точное совпадение либо префикс **вместе с
|
||||
// косой чертой**. По одному префиксу корню `/app` достался бы посторонний
|
||||
// `/apple`; по одному префиксу с косой чертой голый `/app` не достался бы никому
|
||||
// и уехал бы разметкой приложения.
|
||||
func (m Mount) Covers(requestPath string) bool {
|
||||
if m.Exact {
|
||||
return requestPath == m.Path
|
||||
}
|
||||
|
||||
return requestPath == m.Path || strings.HasPrefix(requestPath, m.Path+"/")
|
||||
}
|
||||
|
||||
// ServiceMounts перечисляет адресное пространство сервиса целиком.
|
||||
//
|
||||
// Обработчик приложения приходит уже одетым в свои слои — ограничитель частоты,
|
||||
// узнавание, требование учётной записи: область их действия и есть корень
|
||||
// приложения, и берётся она отсюда, а не перечисляется вторым списком.
|
||||
func ServiceMounts(app http.Handler, metricsHandler http.Handler) []Mount {
|
||||
return []Mount{
|
||||
{Path: AppRoot, Bind: bindApp(app)},
|
||||
{Path: HealthPath, Exact: true, Bind: bindHealth},
|
||||
{Path: MetricsPath, Exact: true, Bind: bindMetrics(metricsHandler)},
|
||||
}
|
||||
}
|
||||
|
||||
// RegisterServiceRoutes вешает всё, что сервис вешает сам.
|
||||
func RegisterServiceRoutes(mux *http.ServeMux, mounts []Mount) {
|
||||
for _, mount := range mounts {
|
||||
if mount.Bind != nil {
|
||||
mount.Bind(mux)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ExactAddressOf называет точный адрес сервиса, которому отвечает путь. Второе
|
||||
// значение ложно у всего прочего — у пути под корнем и у пути вне корней.
|
||||
//
|
||||
// Точные адреса — закрытый перечень, и только они пишутся в журнал дословно:
|
||||
// значением такого пути распоряжается не спрашивающий, а сам перечень.
|
||||
func ExactAddressOf(mounts []Mount, requestPath string) (string, bool) {
|
||||
for _, mount := range mounts {
|
||||
if mount.Exact && mount.Covers(requestPath) {
|
||||
return mount.Path, true
|
||||
}
|
||||
}
|
||||
|
||||
return "", false
|
||||
}
|
||||
|
||||
// IsObservationAddress говорит, что путь — адрес наблюдения.
|
||||
//
|
||||
// Опрос здоровья и метрик идёт постоянно и полезного не несёт, поэтому уровень
|
||||
// журнала у него свой. Перечень при этом тот же самый: второе перечисление этих
|
||||
// адресов разошлось бы с первым молча.
|
||||
func IsObservationAddress(mounts []Mount, requestPath string) bool {
|
||||
_, ok := ExactAddressOf(mounts, requestPath)
|
||||
return ok
|
||||
}
|
||||
|
||||
// bindApp вешает корень приложения.
|
||||
//
|
||||
// Образцов два, и оба обязательны: без точного `/app` маршрутизатор увёл бы
|
||||
// голый корень перенаправлением на `/app/`, а норма требует от него отказа
|
||||
// приложения, а не переезда.
|
||||
func bindApp(app http.Handler) func(mux *http.ServeMux) {
|
||||
return func(mux *http.ServeMux) {
|
||||
mux.Handle(AppRoot+"/", app)
|
||||
mux.Handle(AppRoot, app)
|
||||
}
|
||||
}
|
||||
|
||||
func bindHealth(mux *http.ServeMux) {
|
||||
mux.HandleFunc(HealthPath, func(w http.ResponseWriter, _ *http.Request) {
|
||||
writeJSON(w, http.StatusOK, map[string]string{
|
||||
"status": "ok",
|
||||
"message": "Transcriber service is running",
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
func bindMetrics(handler http.Handler) func(mux *http.ServeMux) {
|
||||
return func(mux *http.ServeMux) {
|
||||
mux.Handle(MetricsPath, handler)
|
||||
}
|
||||
}
|
||||
@@ -4,71 +4,71 @@ 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"
|
||||
|
||||
"git.vakhrushev.me/av/transcriber/internal/adapter/repo/pocketbase/migrations"
|
||||
"git.vakhrushev.me/av/transcriber/internal/contract"
|
||||
"git.vakhrushev.me/av/transcriber/internal/entity"
|
||||
)
|
||||
|
||||
// Проверки разграничения записей по владельцу. Все идут через собранный роутер:
|
||||
// сужение живёт в хранилище, но судится по тому, что видит отправитель.
|
||||
// Проверки разграничения записей по владельцу. Все идут через собранную
|
||||
// поверхность: сужение живёт в хранилище, но судится по тому, что видит
|
||||
// отправитель.
|
||||
|
||||
// newSecondAccount заводит вторую учётную запись со своим логином. Постоянный
|
||||
// адрес почты первой занят, и повторное сохранение отвергается — адрес здесь
|
||||
// свой.
|
||||
func newSecondAccount(t *testing.T, app core.App) (*core.Record, string) {
|
||||
// unknownRecordID — идентификатор годного вида, которого нет ни у одной записи.
|
||||
const unknownRecordID = "00000000000000000000000000"
|
||||
|
||||
// newSecondAccount заводит вторую учётную запись со своим логином.
|
||||
func newSecondAccount(t *testing.T, env *testEnv) *contract.UserAccount {
|
||||
t.Helper()
|
||||
|
||||
const login = "stranger"
|
||||
|
||||
users, err := app.FindCollectionByNameOrId("users")
|
||||
account, created, err := env.users.EnsureUser(contract.Identity{
|
||||
Login: "stranger",
|
||||
Name: "Посторонний",
|
||||
Email: "stranger@example.com",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
require.True(t, created)
|
||||
|
||||
record := core.NewRecord(users)
|
||||
record.Set(migrations.ProviderLoginField, login)
|
||||
record.Set("email", "stranger@example.com")
|
||||
record.Set("verified", true)
|
||||
record.SetRandomPassword()
|
||||
require.NoError(t, app.Save(record))
|
||||
|
||||
return record, login
|
||||
return account
|
||||
}
|
||||
|
||||
// serveAs шлёт запрос от имени названного логина, а не логина окружения.
|
||||
func serveAs(env *testEnv, login string, w http.ResponseWriter, req *http.Request) {
|
||||
env.mux.ServeHTTP(w, asUser(req, login))
|
||||
}
|
||||
|
||||
// Чужая задача неотличима от несуществующей: тот же код и то же тело. Разница
|
||||
// ответов обратила бы опрос в перебор — по ней считывается, какие задачи
|
||||
// Чужая запись неотличима от несуществующей: тот же код и то же тело. Разница
|
||||
// ответов обратила бы чтение в перебор — по ней считывается, какие записи
|
||||
// заведены.
|
||||
func TestGetTranscribeJobStatus_ForeignJobLooksMissing(t *testing.T) {
|
||||
env := setupTestEnv(t, readableMetaViewer())
|
||||
|
||||
job := jobWithFile(t, env)
|
||||
_, stranger := newSecondAccount(t, env.app)
|
||||
newSecondAccount(t, env)
|
||||
|
||||
foreign := httptest.NewRecorder()
|
||||
serveAs(env, stranger, foreign, httptest.NewRequest("GET", "/app/audiorecords/"+job.Id, http.NoBody))
|
||||
foreign := env.getAs("stranger", "/app/audiorecords/"+job.Id)
|
||||
unknown := env.getAs("stranger", "/app/audiorecords/"+unknownRecordID)
|
||||
|
||||
unknown := httptest.NewRecorder()
|
||||
serveAs(env, stranger, unknown, httptest.NewRequest("GET", "/app/audiorecords/unknown0000000000", http.NoBody))
|
||||
|
||||
require.Equal(t, http.StatusNotFound, foreign.Code, "чужая задача не отдаётся")
|
||||
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.StateUploaded)
|
||||
assert.NotContains(t, foreign.Body.String(), "transcription_text")
|
||||
}
|
||||
|
||||
// Владельцем принятой записи становится предъявитель сессии — и у задачи, и у
|
||||
// её файла.
|
||||
// Нечитаемый по виду идентификатор отвечает тем же, чем неизвестный: разбор идёт
|
||||
// на границе, и до базы такой запрос не доходит вовсе.
|
||||
func TestRecordCard_MalformedIDLooksLikeUnknown(t *testing.T) {
|
||||
env := setupTestEnv(t, readableMetaViewer())
|
||||
|
||||
malformed := env.getOwn("/app/audiorecords/не-идентификатор")
|
||||
unknown := env.getOwn("/app/audiorecords/" + unknownRecordID)
|
||||
|
||||
assert.Equal(t, http.StatusNotFound, malformed.Code)
|
||||
assert.JSONEq(t, unknown.Body.String(), malformed.Body.String())
|
||||
}
|
||||
|
||||
// Владельцем принятой записи становится узнанный предъявитель — и у записи, и у
|
||||
// её копии.
|
||||
func TestCreateTranscribeJob_OwnerIsSession(t *testing.T) {
|
||||
env := setupTestEnv(t, readableMetaViewer())
|
||||
|
||||
@@ -78,24 +78,26 @@ func TestCreateTranscribeJob_OwnerIsSession(t *testing.T) {
|
||||
|
||||
response := intakeItemOf(t, w)
|
||||
|
||||
record, err := env.app.FindRecordById(migrations.RecordsCollection, response.ID)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, env.account.Id, record.GetString("owner"), "владелец задачи — предъявитель")
|
||||
var owner string
|
||||
require.NoError(t, env.db.Reader().
|
||||
QueryRow("SELECT owner_id FROM audio_records WHERE id = ?", response.ID).Scan(&owner))
|
||||
assert.Equal(t, env.account.ID, owner, "владелец записи — предъявитель")
|
||||
|
||||
fileRecord, err := env.app.FindRecordById("files", record.GetString("original_file"))
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, env.account.Id, fileRecord.GetString("owner"), "владелец файла — он же")
|
||||
var fileOwner string
|
||||
require.NoError(t, env.db.Reader().
|
||||
QueryRow("SELECT owner_id FROM files WHERE record_id = ?", response.ID).Scan(&fileOwner))
|
||||
assert.Equal(t, env.account.ID, fileOwner, "владелец копии — он же")
|
||||
}
|
||||
|
||||
// Владельца не задают запросом: своё значение в форме на результат не влияет.
|
||||
func TestCreateTranscribeJob_OwnerFieldFromRequestIgnored(t *testing.T) {
|
||||
env := setupTestEnv(t, readableMetaViewer())
|
||||
|
||||
_, stranger := newSecondAccount(t, env.app)
|
||||
stranger := newSecondAccount(t, env)
|
||||
|
||||
req := createMultipartRequest(t, "sample.mp3", []byte("запись"))
|
||||
query := req.URL.Query()
|
||||
query.Set("owner", stranger)
|
||||
query.Set("owner", stranger.ID)
|
||||
req.URL.RawQuery = query.Encode()
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
@@ -104,97 +106,198 @@ func TestCreateTranscribeJob_OwnerFieldFromRequestIgnored(t *testing.T) {
|
||||
|
||||
response := intakeItemOf(t, w)
|
||||
|
||||
record, err := env.app.FindRecordById(migrations.RecordsCollection, response.ID)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, env.account.Id, record.GetString("owner"))
|
||||
var owner string
|
||||
require.NoError(t, env.db.Reader().
|
||||
QueryRow("SELECT owner_id FROM audio_records WHERE id = ?", response.ID).Scan(&owner))
|
||||
assert.Equal(t, env.account.ID, owner)
|
||||
}
|
||||
|
||||
// Предъявитель, чья сессия не даёт учётной записи пользователя, получает отказ
|
||||
// до чтения тела. Владелец панели — именно такой: узнан он узнан, а записи в
|
||||
// коллекции пользователей у него нет, и владельцем записи он стать не может.
|
||||
// TestFileDownload_NarrowedByOwner — **критерий приёмки**: чужой файл недостижим
|
||||
// по прямой ссылке, и ответ на него тот же, что и на несуществующую запись.
|
||||
//
|
||||
// Отказ **до** укладки обязателен: позже пришлось бы убирать уже сохранённый
|
||||
// файл, а уборки файлов сервис не умеет вовсе.
|
||||
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)
|
||||
|
||||
// Владелец панели предъявляет **свой токен**, а не заголовок: заголовок ему
|
||||
// никто не ставит, и узнавание по нему его учётной записи не касается.
|
||||
req := createMultipartRequest(t, "sample.mp3", []byte("запись"))
|
||||
req.Header.Set("Authorization", token)
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
env.mux.ServeHTTP(w, req)
|
||||
|
||||
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)
|
||||
newSecondAccount(t, env)
|
||||
|
||||
record, err := env.app.FindRecordById("files", *job.OriginalFileID)
|
||||
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,
|
||||
httptest.NewRequest("GET", link+"?token="+fileToken(t, env, env.login), http.NoBody))
|
||||
|
||||
foreign := httptest.NewRecorder()
|
||||
env.mux.ServeHTTP(foreign,
|
||||
httptest.NewRequest("GET", link+"?token="+fileToken(t, env, stranger), http.NoBody))
|
||||
link := "/app/audiorecords/" + job.Id + "/file?" + CopyParam + "=" + CopyOriginal
|
||||
|
||||
mine := env.getOwn(link)
|
||||
require.Equal(t, http.StatusOK, mine.Code, "свой файл отдаётся")
|
||||
assert.Equal(t, "запись", mine.Body.String(), "и отдаётся содержимым")
|
||||
|
||||
assert.NotEqual(t, http.StatusOK, foreign.Code, "чужой файл не отдаётся")
|
||||
foreign := env.getAs("stranger", link)
|
||||
unknown := env.getAs("stranger",
|
||||
"/app/audiorecords/"+unknownRecordID+"/file?"+CopyParam+"="+CopyOriginal)
|
||||
|
||||
assert.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(), "запись", "содержимого в отказе нет")
|
||||
}
|
||||
|
||||
// fileToken берёт у хранилища токен файла для названного логина. Токен выдаётся
|
||||
// на предъявителя: о файле хранилище при выдаче не спрашивает.
|
||||
//
|
||||
// Адрес выдачи лежит в пространстве хранилища, а не приложения, и узнавание на
|
||||
// нём работает поимённо — иначе весь путь «узнавание → токен файла → ссылка»
|
||||
// обрывался бы на первом шаге, и файл записи стал бы недостижим для браузера.
|
||||
func fileToken(t *testing.T, env *testEnv, login string) string {
|
||||
t.Helper()
|
||||
// Негодное значение копии у чужой записи неотличимо от неизвестной записи:
|
||||
// владение судится **до** разбора значения копии. Разойдись эти ответы — по их
|
||||
// разнице перебирался бы список заведённых записей одним негодным параметром.
|
||||
func TestFileDownload_UnknownCopyOnForeignRecordLooksLikeUnknownRecord(t *testing.T) {
|
||||
env := setupTestEnv(t, readableMetaViewer())
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
env.mux.ServeHTTP(w, asUser(
|
||||
httptest.NewRequest("POST", "/api/files/token", http.NoBody), login))
|
||||
require.Equal(t, http.StatusOK, w.Code, "токен файла выдаётся всякому узнанному")
|
||||
job := jobWithFile(t, env)
|
||||
newSecondAccount(t, env)
|
||||
|
||||
var body struct {
|
||||
Token string `json:"token"`
|
||||
}
|
||||
require.NoError(t, json.Unmarshal(w.Body.Bytes(), &body))
|
||||
require.NotEmpty(t, body.Token)
|
||||
foreign := env.getAs("stranger", "/app/audiorecords/"+job.Id+"/file?"+CopyParam+"=whatever")
|
||||
unknown := env.getAs("stranger", "/app/audiorecords/"+unknownRecordID+"/file?"+CopyParam+"=whatever")
|
||||
missing := env.getAs("stranger", "/app/audiorecords/"+unknownRecordID+"/file")
|
||||
|
||||
return body.Token
|
||||
assert.Equal(t, http.StatusNotFound, foreign.Code)
|
||||
assert.Equal(t, unknown.Code, foreign.Code)
|
||||
assert.JSONEq(t, unknown.Body.String(), foreign.Body.String())
|
||||
assert.JSONEq(t, missing.Body.String(), foreign.Body.String(),
|
||||
"незаданная копия у чужой записи отвечает не как неизвестная запись")
|
||||
}
|
||||
|
||||
// Копия, которой у записи ещё нет, отвечает отказом состояния, а не «не
|
||||
// найдено»: иначе человек увидел бы «не найдено» на своей записи, загруженной
|
||||
// минуту назад.
|
||||
func TestFileDownload_MissingCopyIsConflict(t *testing.T) {
|
||||
env := setupTestEnv(t, readableMetaViewer())
|
||||
|
||||
job := jobWithFile(t, env)
|
||||
|
||||
w := env.getOwn("/app/audiorecords/" + job.Id + "/file?" + CopyParam + "=" + CopyNormalized)
|
||||
|
||||
require.Equal(t, http.StatusConflict, w.Code)
|
||||
|
||||
var body ErrorBody
|
||||
require.NoError(t, json.Unmarshal(w.Body.Bytes(), &body))
|
||||
assert.Equal(t, CodeNotReady, body.Code)
|
||||
|
||||
unknown := env.getOwn("/app/audiorecords/" + unknownRecordID + "/file?" + CopyParam + "=" + CopyOriginal)
|
||||
assert.NotEqual(t, unknown.Code, w.Code, "отказ состояния слился с ответом на неизвестную запись")
|
||||
}
|
||||
|
||||
// Копия, которой сервис не знает, и незаданная копия у **своей** записи дают
|
||||
// отказ по негодному вводу.
|
||||
func TestFileDownload_UnknownCopyIsBadRequest(t *testing.T) {
|
||||
env := setupTestEnv(t, readableMetaViewer())
|
||||
|
||||
job := jobWithFile(t, env)
|
||||
|
||||
for name, query := range map[string]string{
|
||||
"копия неизвестна": "?" + CopyParam + "=whatever",
|
||||
"копия не названа": "",
|
||||
} {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
w := env.getOwn("/app/audiorecords/" + job.Id + "/file" + query)
|
||||
|
||||
require.Equal(t, http.StatusBadRequest, w.Code)
|
||||
|
||||
var body ErrorBody
|
||||
require.NoError(t, json.Unmarshal(w.Body.Bytes(), &body))
|
||||
assert.Equal(t, CodeBadRequest, body.Code)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Проигрыватель просит кусок записи: ответ несёт запрошенный кусок, а не файл
|
||||
// целиком.
|
||||
func TestFileDownload_RangeGivesRequestedSlice(t *testing.T) {
|
||||
env := setupTestEnv(t, readableMetaViewer())
|
||||
|
||||
const content = "0123456789abcdef"
|
||||
|
||||
created := httptest.NewRecorder()
|
||||
env.serve(created, createMultipartRequest(t, "sample.mp3", []byte(content)))
|
||||
require.Equal(t, http.StatusCreated, created.Code)
|
||||
|
||||
server := env.liveServer(t)
|
||||
link := "/app/audiorecords/" + intakeItemOf(t, created).ID + "/file?" + CopyParam + "=" + CopyOriginal
|
||||
|
||||
res := env.liveRequest(t, server, link, map[string]string{"Range": "bytes=4-9"})
|
||||
|
||||
require.Equal(t, http.StatusPartialContent, res.StatusCode)
|
||||
assert.Equal(t, "456789", string(res.Body), "ответ несёт не запрошенный кусок")
|
||||
assert.Equal(t, int64(len("456789")), res.ContentLength, "длина ответа — длина файла целиком")
|
||||
assert.Equal(t, "bytes 4-9/16", res.Header.Get("Content-Range"))
|
||||
}
|
||||
|
||||
// TestFileDownload_BadRangeIsOurFailureForm — **критерий приёмки**: негодный
|
||||
// диапазон приходит обычным отказом сервиса, а не `416` телом библиотеки.
|
||||
//
|
||||
// Проверка идёт настоящим HTTP-запросом через поднятую цепочку слоёв: вызовом
|
||||
// отображателя ошибки это не проверяется — код `416` рождается не в нём.
|
||||
func TestFileDownload_BadRangeIsOurFailureForm(t *testing.T) {
|
||||
env := setupTestEnv(t, readableMetaViewer())
|
||||
|
||||
created := httptest.NewRecorder()
|
||||
env.serve(created, createMultipartRequest(t, "sample.mp3", []byte("0123456789")))
|
||||
require.Equal(t, http.StatusCreated, created.Code)
|
||||
|
||||
server := env.liveServer(t)
|
||||
link := "/app/audiorecords/" + intakeItemOf(t, created).ID + "/file?" + CopyParam + "=" + CopyOriginal
|
||||
|
||||
cases := map[string]string{
|
||||
"неудовлетворимый диапазон": "bytes=99999999-",
|
||||
"два диапазона в запросе": "bytes=0-1,4-5",
|
||||
}
|
||||
|
||||
for name, rangeHeader := range cases {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
res := env.liveRequest(t, server, link, map[string]string{"Range": rangeHeader})
|
||||
|
||||
require.Equal(t, http.StatusBadRequest, res.StatusCode, "ответ не `400`")
|
||||
assert.NotContains(t, res.Header.Get("Content-Type"), "multipart",
|
||||
"сервис отдал ответ из нескольких частей")
|
||||
|
||||
var body map[string]any
|
||||
require.NoError(t, json.Unmarshal(res.Body, &body))
|
||||
assert.Contains(t, body, "error_code")
|
||||
assert.Contains(t, body, "message")
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Имя файла на диске в ответ не идёт: имя, предлагаемое браузеру при сохранении,
|
||||
// строится из имени, данного отправителем.
|
||||
func TestFileDownload_CarriesNoStoredName(t *testing.T) {
|
||||
env := setupTestEnv(t, readableMetaViewer())
|
||||
|
||||
created := httptest.NewRecorder()
|
||||
env.serve(created, createMultipartRequest(t, "разговор.mp3", []byte("запись")))
|
||||
require.Equal(t, http.StatusCreated, created.Code)
|
||||
|
||||
recordID := intakeItemOf(t, created).ID
|
||||
|
||||
w := env.getOwn("/app/audiorecords/" + recordID + "/file?" + CopyParam + "=" + CopyOriginal)
|
||||
require.Equal(t, http.StatusOK, w.Code)
|
||||
|
||||
names := storedFileNames(t, env)
|
||||
require.Len(t, names, 1)
|
||||
|
||||
headers := w.Result().Header
|
||||
for _, value := range headers.Values("Content-Disposition") {
|
||||
assert.NotContains(t, value, names[0], "имя файла на диске уехало в ответ")
|
||||
}
|
||||
assert.Contains(t, strings.Join(headers.Values("Content-Disposition"), " "), "%D1%80",
|
||||
"имя, данное отправителем, в ответ не попало")
|
||||
}
|
||||
|
||||
// Перечня доступных копий карточка не объявляет: копий две, и каждая выводится
|
||||
// из рубежа записи, который карточка несёт и так.
|
||||
func TestRecordCard_HasNoCopyList(t *testing.T) {
|
||||
env := setupTestEnv(t, readableMetaViewer())
|
||||
|
||||
job := jobWithFile(t, env)
|
||||
|
||||
w := env.getOwn("/app/audiorecords/" + job.Id)
|
||||
require.Equal(t, http.StatusOK, w.Code)
|
||||
|
||||
var raw map[string]json.RawMessage
|
||||
require.NoError(t, json.Unmarshal(w.Body.Bytes(), &raw))
|
||||
|
||||
assert.NotContains(t, raw, "available_copies")
|
||||
assert.NotContains(t, raw, "copies")
|
||||
}
|
||||
|
||||
@@ -1,17 +1,17 @@
|
||||
package http
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/netip"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/pocketbase/pocketbase/core"
|
||||
"git.vakhrushev.me/av/transcriber/internal/clock"
|
||||
"git.vakhrushev.me/av/transcriber/internal/contract"
|
||||
)
|
||||
|
||||
// Своё правило ограничителя частоты под корнем приложения.
|
||||
//
|
||||
// Заводится потому, что правило хранилища настроено на **его** корень и наших
|
||||
// адресов больше не покрывает: приложение уехало в своё пространство, и вместе с
|
||||
// переездом ограничитель перестал бы существовать для него вовсе. Потеря тихая —
|
||||
// заметить её нечем, пока кто-нибудь не начнёт опрашивать карточку в цикле.
|
||||
// Бюджет ограничителя частоты под корнем приложения.
|
||||
//
|
||||
// Числа скромные намеренно: сервисом пользуются единицы человек, а экран
|
||||
// опрашивает карточку, пока запись идёт по конвейеру. Из них же выводится
|
||||
@@ -22,71 +22,202 @@ const (
|
||||
appRateWindowSec = 60
|
||||
)
|
||||
|
||||
// ApplyAppRateLimit ставит правило ограничителя на корень приложения.
|
||||
//
|
||||
// Правило приводится к настройке при каждом подъёме, как и настройки провайдера:
|
||||
// применённый шаг схемы не переписывается, а настройки хранилища живут в базе, и
|
||||
// правило, положенное однажды, не пережило бы ни правки числа, ни чистого
|
||||
// каталога данных.
|
||||
func ApplyAppRateLimit(app core.App) error {
|
||||
settings := app.Settings()
|
||||
// staleBudgetAge — через сколько неиспользуемый счётчик выбрасывается. Карта
|
||||
// счётчиков растёт с числом адресов, и без уборки она стала бы местом, куда
|
||||
// спрашивающий кладёт по строке на каждый свой адрес.
|
||||
const staleBudgetAge = 10 * time.Minute
|
||||
|
||||
rule := core.RateLimitRule{
|
||||
Label: AppRoot + "/",
|
||||
MaxRequests: appRateMaxRequests,
|
||||
Duration: appRateWindowSec,
|
||||
// RateLimit — свой ограничитель частоты под корнем приложения.
|
||||
//
|
||||
// Бюджет считается по адресу спрашивающего скользящим окном: столько-то запросов
|
||||
// за столько-то секунд. Отказ уходит **нашей** формой тела — слой отвечает
|
||||
// доменной ошибкой, а не телом библиотеки, и второй формы отказа на адресах
|
||||
// приложения не появляется.
|
||||
//
|
||||
// **Адрес спрашивающего берётся из заголовка пересылки — и только тогда, когда
|
||||
// соединение пришло с адреса из объявленного перечня доверенных.** Во всяком
|
||||
// другом случае адресом считается адрес пира, а пришедший заголовок на ключ
|
||||
// бюджета не влияет ничем.
|
||||
//
|
||||
// **Цепочка пересылки читается справа налево до первого недоверенного адреса.**
|
||||
// Значением слева распоряжается тот, кто шлёт запрос: прокси заголовок
|
||||
// дописывает, а не заменяет, и левое значение цепочки он берёт из присланного.
|
||||
//
|
||||
// Обе половины правила закрывают свою поломку. Сервис стоит за обратным прокси,
|
||||
// и пир у него один на всех: бюджет, посчитанный по пиру, становится общим на
|
||||
// весь сервис, и один спрашивающий исчерпывает его остальным. Обратная ошибка —
|
||||
// верить заголовку без сверки пира — отдаёт обход ограничителя ровно тому, кого
|
||||
// он ограничивает: значением пересылаемого заголовка распоряжается тот, кто шлёт
|
||||
// запрос, и меняет он его на каждом запросе.
|
||||
//
|
||||
// **Узнавание и ограничитель берут адрес разными способами, и это намеренно.**
|
||||
// Узнаванию нужен адрес самого соединения: им оно решает, верить ли заголовку
|
||||
// вообще. Ограничителю нужен адрес того, кого он ограничивает, а тот за прокси в
|
||||
// адресе соединения не виден вовсе.
|
||||
//
|
||||
// Слой стоит **перед** узнаванием: узнавание читает базу, а на новом имени ещё и
|
||||
// пишет в неё; выполненное раньше ограничителя, оно работало бы на запросах,
|
||||
// которые тот уже отверг, и поток отвергнутых обращений заводил бы учётные
|
||||
// записи, которые потом не убираются ничем.
|
||||
func RateLimit(trusted []netip.Prefix) func(http.Handler) http.Handler {
|
||||
limiter := newRateLimiter(appRateMaxRequests, appRateWindowSec*time.Second)
|
||||
|
||||
return func(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if !limiter.allow(clientAddress(r, trusted)) {
|
||||
fail(w, contract.ErrTooManyRequests)
|
||||
return
|
||||
}
|
||||
|
||||
next.ServeHTTP(w, r)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// clientAddress — тот, кого ограничивают.
|
||||
func clientAddress(r *http.Request, trusted []netip.Prefix) string {
|
||||
peer, ok := peerAddress(r.RemoteAddr)
|
||||
if !ok {
|
||||
// Адрес пира не разобран — ключ берётся строкой как есть: общий бюджет
|
||||
// лучше отсутствующего.
|
||||
return r.RemoteAddr
|
||||
}
|
||||
|
||||
replaced := false
|
||||
for i, existing := range settings.RateLimits.Rules {
|
||||
if existing.Label == rule.Label {
|
||||
settings.RateLimits.Rules[i] = rule
|
||||
replaced = true
|
||||
break
|
||||
if !isTrusted(trusted, peer) {
|
||||
return peer.String()
|
||||
}
|
||||
|
||||
if client, ok := forwardedClient(r, trusted); ok {
|
||||
return client
|
||||
}
|
||||
|
||||
// Прокси адреса не назвал либо назвал одними доверенными: счётчик падает
|
||||
// обратно на адрес пира — то есть на общий. Лучше общий, чем ключ, который
|
||||
// выбирает сам спрашивающий.
|
||||
return peer.String()
|
||||
}
|
||||
|
||||
// forwardedClient достаёт из цепочки пересылки того, кого ограничивают.
|
||||
//
|
||||
// **Цепочка читается справа налево, а доверенные адреса отбрасываются.**
|
||||
// Значение слева пишет тот, кто шлёт запрос: прокси заголовок **дописывает**, а
|
||||
// не заменяет, поэтому левым значением цепочки распоряжается аноним, и ключ
|
||||
// бюджета, взятый оттуда, меняется у него на каждом запросе — бюджет обходится
|
||||
// с первого. Справа же стоит адрес, который приписал ближайший к нам прокси, и
|
||||
// каждый следующий шаг влево тем доверен, кто его приписал. Первый недоверенный
|
||||
// справа и есть последний адрес, за который отвечает контур, а не спрашивающий.
|
||||
//
|
||||
// Заголовок читается **всеми** строками, а не одной: цепочка законно приходит
|
||||
// несколькими заголовками, и разбор одной строки увидел бы только её часть — то
|
||||
// есть снова кусок, которым распоряжается аноним.
|
||||
func forwardedClient(r *http.Request, trusted []netip.Prefix) (string, bool) {
|
||||
hops := forwardedChain(r)
|
||||
|
||||
for i := len(hops) - 1; i >= 0; i-- {
|
||||
addr, err := netip.ParseAddr(hops[i])
|
||||
if err != nil {
|
||||
// Значение, которое не разбирается адресом, доверенным считать
|
||||
// нечем: цепочка на нём кончается.
|
||||
return "", false
|
||||
}
|
||||
|
||||
addr = addr.Unmap()
|
||||
if isTrusted(trusted, addr) {
|
||||
continue
|
||||
}
|
||||
|
||||
return addr.String(), true
|
||||
}
|
||||
|
||||
return "", false
|
||||
}
|
||||
|
||||
// forwardedChain — цепочка пересылки одним списком: все строки заголовка по
|
||||
// порядку, каждая разобрана по запятым.
|
||||
func forwardedChain(r *http.Request) []string {
|
||||
var hops []string
|
||||
for _, value := range r.Header.Values(ForwardedForHeader) {
|
||||
for _, hop := range strings.Split(value, ",") {
|
||||
hop = strings.TrimSpace(hop)
|
||||
if hop != "" {
|
||||
hops = append(hops, hop)
|
||||
}
|
||||
}
|
||||
}
|
||||
if !replaced {
|
||||
settings.RateLimits.Rules = append(settings.RateLimits.Rules, rule)
|
||||
return hops
|
||||
}
|
||||
|
||||
// rateLimiter — счётчики бюджета по ключу.
|
||||
//
|
||||
// Окно скользящее и хранится метками времени: счётчик с обнулением по границе
|
||||
// окна пропустил бы двойной бюджет на стыке двух окон.
|
||||
type rateLimiter struct {
|
||||
mu sync.Mutex
|
||||
max int
|
||||
window time.Duration
|
||||
budgets map[string]*budget
|
||||
swept time.Time
|
||||
}
|
||||
|
||||
type budget struct {
|
||||
hits []time.Time
|
||||
}
|
||||
|
||||
func newRateLimiter(maxRequests int, window time.Duration) *rateLimiter {
|
||||
return &rateLimiter{
|
||||
max: maxRequests,
|
||||
window: window,
|
||||
budgets: map[string]*budget{},
|
||||
swept: clock.Now(),
|
||||
}
|
||||
}
|
||||
|
||||
func (l *rateLimiter) allow(key string) bool {
|
||||
now := clock.Now()
|
||||
|
||||
l.mu.Lock()
|
||||
defer l.mu.Unlock()
|
||||
|
||||
l.sweep(now)
|
||||
|
||||
b, ok := l.budgets[key]
|
||||
if !ok {
|
||||
b = &budget{}
|
||||
l.budgets[key] = b
|
||||
}
|
||||
|
||||
settings.RateLimits.Enabled = true
|
||||
b.hits = fresh(b.hits, now.Add(-l.window))
|
||||
if len(b.hits) >= l.max {
|
||||
return false
|
||||
}
|
||||
|
||||
if err := app.Save(settings); err != nil {
|
||||
return fmt.Errorf("failed to apply app rate limit: %w", err)
|
||||
}
|
||||
return nil
|
||||
b.hits = append(b.hits, now)
|
||||
return true
|
||||
}
|
||||
|
||||
// ApplyTrustedProxyHeaders называет хранилищу заголовок, из которого брать
|
||||
// адрес спрашивающего.
|
||||
//
|
||||
// Без этого ограничитель частоты ключует счётчик адресом **пира**, а пир с
|
||||
// переездом входа на заголовок всегда один и тот же — обратный прокси. Бюджет
|
||||
// в этом случае общий на весь сервис: восемь одновременно открытых карточек
|
||||
// выбирают его целиком, и девятый человек получает отказ, не сделав ни одного
|
||||
// запроса. Норма при этом требует обратного — бюджет считается по адресу
|
||||
// спрашивающего.
|
||||
//
|
||||
// Доверие здесь той же природы, что и к `Remote-User`, и той же ширины: адрес
|
||||
// берётся из пересылаемого заголовка, а верить пересылаемому можно ровно
|
||||
// потому, что до нас дотянулся доверенный пир. Отсюда требование к контуру,
|
||||
// записанное в модели угроз: прокси обязан `X-Forwarded-For` **перезаписывать**,
|
||||
// а не дописывать к присланному, — иначе спрашивающий назначает себе ключ
|
||||
// счётчика сам и обходит ограничитель, меняя значение.
|
||||
//
|
||||
// Барьером узнавания этот заголовок не служит и служить не может: кто пришёл,
|
||||
// по-прежнему решает адрес самого соединения.
|
||||
func ApplyTrustedProxyHeaders(app core.App) error {
|
||||
settings := app.Settings()
|
||||
// sweep выбрасывает счётчики, которыми давно не пользовались. Идёт не чаще раза
|
||||
// в окно: обход карты на каждом запросе стоил бы больше самого ограничителя.
|
||||
func (l *rateLimiter) sweep(now time.Time) {
|
||||
if now.Sub(l.swept) < l.window {
|
||||
return
|
||||
}
|
||||
l.swept = now
|
||||
|
||||
settings.TrustedProxy.Headers = []string{"X-Forwarded-For"}
|
||||
// Пустой заголовок означает, что прокси адреса не назвал; счётчик тогда
|
||||
// падает обратно на адрес пира — то есть на общий, как было. Лучше общий,
|
||||
// чем один пустой ключ на всех.
|
||||
settings.TrustedProxy.UseLeftmostIP = true
|
||||
for key, b := range l.budgets {
|
||||
b.hits = fresh(b.hits, now.Add(-staleBudgetAge))
|
||||
if len(b.hits) == 0 {
|
||||
delete(l.budgets, key)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if err := app.Save(settings); err != nil {
|
||||
return fmt.Errorf("failed to apply trusted proxy headers: %w", err)
|
||||
// fresh отбрасывает метки старше границы. Метки лежат по возрастанию, поэтому
|
||||
// достаточно найти первую свежую.
|
||||
func fresh(hits []time.Time, edge time.Time) []time.Time {
|
||||
for i, hit := range hits {
|
||||
if hit.After(edge) {
|
||||
return hits[i:]
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
package http
|
||||
|
||||
import (
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"net/netip"
|
||||
|
||||
"git.vakhrushev.me/av/transcriber/internal/contract"
|
||||
)
|
||||
|
||||
// AppChain одевает адреса приложения в их слои.
|
||||
//
|
||||
// Порядок один и он несущий: ограничитель частоты → узнавание → требование
|
||||
// учётной записи → обработчик.
|
||||
//
|
||||
// - ограничитель стоит первым, потому что узнавание читает базу, а на новом
|
||||
// имени ещё и пишет в неё: поставленное раньше, оно работало бы на запросах,
|
||||
// которые ограничитель уже отверг, и поток отвергнутых обращений заводил бы
|
||||
// учётные записи, которые потом не убираются ничем;
|
||||
// - требование учётной записи стоит перед чтением тела: запись, за которую не
|
||||
// заплатит узнанный отправитель, не должна попасть даже в память, а позже
|
||||
// пришлось бы убирать уже уложенный файл — чего сервис не умеет вовсе.
|
||||
//
|
||||
// Область действия всех трёх — корень приложения, и берётся она из перечня
|
||||
// адресного пространства: цепочка вешается на корень целиком, вторым списком
|
||||
// адресов её не описывают.
|
||||
func AppChain(
|
||||
routes http.Handler,
|
||||
users contract.UserRepository,
|
||||
trusted []netip.Prefix,
|
||||
logger *slog.Logger,
|
||||
) http.Handler {
|
||||
handler := RequireUser()(routes)
|
||||
handler = TrustedHeaderIdentity(users, trusted, logger)(handler)
|
||||
handler = RateLimit(trusted)(handler)
|
||||
return handler
|
||||
}
|
||||
|
||||
// BuildHandler собирает поверхность сервиса целиком.
|
||||
//
|
||||
// Раздача приложения вешается последней: она занимает корень, и всё, что не
|
||||
// совпало ни с одним адресом сервиса, доходит до неё.
|
||||
//
|
||||
// Снаружи всех — журнал и восстановление после паники: первый обязан видеть код
|
||||
// ответа, каким бы слоем тот ни родился, второй — не дать одному паникующему
|
||||
// запросу уронить процесс.
|
||||
func BuildHandler(mounts []Mount, webapp *WebappHandler, logger *slog.Logger) http.Handler {
|
||||
mux := http.NewServeMux()
|
||||
|
||||
RegisterServiceRoutes(mux, mounts)
|
||||
webapp.Register(mux)
|
||||
|
||||
return Journal(mounts, logger)(Recover(logger)(mux))
|
||||
}
|
||||
@@ -8,12 +8,9 @@ import (
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"github.com/pocketbase/pocketbase/apis"
|
||||
|
||||
"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"
|
||||
)
|
||||
|
||||
@@ -47,7 +44,7 @@ func TestRecordCard_CarriesNoTextButListsViews(t *testing.T) {
|
||||
|
||||
record := jobWithFile(t, env)
|
||||
|
||||
texts := pbrepo.NewTextRepository(env.app)
|
||||
texts := env.handler.textRepo
|
||||
transcript, err := texts.Put(record.Id, entity.TextKindTranscript, "сырая расшифровка")
|
||||
require.NoError(t, err)
|
||||
|
||||
@@ -120,7 +117,7 @@ func TestRecordText_TranscriptView(t *testing.T) {
|
||||
|
||||
record := jobWithFile(t, env)
|
||||
|
||||
texts := pbrepo.NewTextRepository(env.app)
|
||||
texts := env.handler.textRepo
|
||||
transcript, err := texts.Put(record.Id, entity.TextKindTranscript, "сырая расшифровка")
|
||||
require.NoError(t, err)
|
||||
literary, err := texts.Put(record.Id, entity.TextKindLiterary, "вычитанный текст")
|
||||
@@ -149,7 +146,7 @@ func TestRecordText_ReplicasView(t *testing.T) {
|
||||
|
||||
record := jobWithFile(t, env)
|
||||
|
||||
structures := pbrepo.NewStructureRepository(env.app)
|
||||
structures := env.handler.structureRepo
|
||||
structure, err := structures.Put(record.Id, 1, []entity.Replica{
|
||||
{StartMs: 0, EndMs: 1500, Text: "первая реплика"},
|
||||
{StartMs: 1500, EndMs: 3000, Text: "вторая реплика"},
|
||||
@@ -179,7 +176,7 @@ func TestRecordText_NotReadyDiffersFromNotFound(t *testing.T) {
|
||||
|
||||
record := jobWithFile(t, env)
|
||||
|
||||
texts := pbrepo.NewTextRepository(env.app)
|
||||
texts := env.handler.textRepo
|
||||
transcript, err := texts.Put(record.Id, entity.TextKindTranscript, "сырая расшифровка")
|
||||
require.NoError(t, err)
|
||||
record.TranscriptTextID = &transcript.Id
|
||||
@@ -204,6 +201,64 @@ func TestRecordText_NotReadyDiffersFromNotFound(t *testing.T) {
|
||||
"«ещё не готово» и «записи нет» ведут к разным действиям человека")
|
||||
}
|
||||
|
||||
// Каждая ветвь «ещё не готово» отвечает одним кодом и одним телом.
|
||||
//
|
||||
// Ветвей четыре, и достижимы все: текста нет вовсе, текст заведён пустым,
|
||||
// структуры реплик нет, структура заведена без единой реплики. Шаг завершения
|
||||
// пишет результат несколькими операциями, поэтому запись законно застаётся между
|
||||
// ними. Ветвь, отвечающая иначе, отправила бы приложение за текстом, которого
|
||||
// нет, либо показала бы «записи нет» на своей записи.
|
||||
func TestRecordText_EveryNotReadyBranchIsOneAnswer(t *testing.T) {
|
||||
env := setupTestEnv(t, readableMetaViewer())
|
||||
|
||||
notReady := func(t *testing.T, record *entity.AudioRecord, view string) {
|
||||
t.Helper()
|
||||
|
||||
w := textOf(t, env, record.Id, view)
|
||||
require.Equal(t, http.StatusConflict, w.Code, view)
|
||||
|
||||
var body ErrorBody
|
||||
require.NoError(t, json.Unmarshal(w.Body.Bytes(), &body))
|
||||
assert.Equal(t, CodeNotReady, body.Code, view)
|
||||
assert.NotEmpty(t, body.Message, view)
|
||||
}
|
||||
|
||||
t.Run("текста нет вовсе", func(t *testing.T) {
|
||||
record := jobWithFile(t, env)
|
||||
notReady(t, record, entity.TextViewTranscript)
|
||||
notReady(t, record, entity.TextViewLiterary)
|
||||
notReady(t, record, entity.TextViewReplicas)
|
||||
})
|
||||
|
||||
t.Run("текст заведён пустым", func(t *testing.T) {
|
||||
record := jobWithFile(t, env)
|
||||
|
||||
transcript, err := env.handler.textRepo.Put(record.Id, entity.TextKindTranscript, "")
|
||||
require.NoError(t, err)
|
||||
record.TranscriptTextID = &transcript.Id
|
||||
|
||||
literary, err := env.handler.textRepo.Put(record.Id, entity.TextKindLiterary, "")
|
||||
require.NoError(t, err)
|
||||
record.LiteraryTextID = &literary.Id
|
||||
|
||||
require.NoError(t, env.handler.recordRepo.Save(record, ""))
|
||||
|
||||
notReady(t, record, entity.TextViewTranscript)
|
||||
notReady(t, record, entity.TextViewLiterary)
|
||||
})
|
||||
|
||||
t.Run("структура заведена без единой реплики", func(t *testing.T) {
|
||||
record := jobWithFile(t, env)
|
||||
|
||||
structure, err := env.handler.structureRepo.Put(record.Id, 1, []entity.Replica{})
|
||||
require.NoError(t, err)
|
||||
record.StructureID = &structure.Id
|
||||
require.NoError(t, env.handler.recordRepo.Save(record, ""))
|
||||
|
||||
notReady(t, record, entity.TextViewReplicas)
|
||||
})
|
||||
}
|
||||
|
||||
// Вид, которого сервис не знает, и незаданный вид дают отказ по негодному вводу:
|
||||
// умолчание сделало бы ответ функцией того, что успел записать конвейер.
|
||||
func TestRecordText_UnknownViewIsBadRequest(t *testing.T) {
|
||||
@@ -241,7 +296,7 @@ func TestRecordText_ReadFailureIsNotANotFound(t *testing.T) {
|
||||
|
||||
record := jobWithFile(t, env)
|
||||
|
||||
texts := pbrepo.NewTextRepository(env.app)
|
||||
texts := env.handler.textRepo
|
||||
transcript, err := texts.Put(record.Id, entity.TextKindTranscript, "сырая расшифровка")
|
||||
require.NoError(t, err)
|
||||
|
||||
@@ -251,20 +306,21 @@ func TestRecordText_ReadFailureIsNotANotFound(t *testing.T) {
|
||||
// Обработчик пересобирается с отказывающим хранилищем текстов: остальная
|
||||
// цепочка та же, что и в проде.
|
||||
journal := &journalBuffer{}
|
||||
logger := slog.New(slog.NewTextHandler(journal, nil))
|
||||
handler := NewAppHandler(
|
||||
env.handler.recordRepo,
|
||||
&failingTextRepo{},
|
||||
pbrepo.NewStructureRepository(env.app),
|
||||
env.handler.structureRepo,
|
||||
env.handler.fileRepo,
|
||||
env.handler.trsService,
|
||||
slog.New(slog.NewTextHandler(journal, nil)),
|
||||
logger,
|
||||
)
|
||||
|
||||
r, err := apis.NewRouter(env.app)
|
||||
require.NoError(t, err)
|
||||
r.Bind(TrustedHeaderIdentity(env.app, ServiceMounts(handler, http.NotFoundHandler()), testTrustedNetworks(t), nil))
|
||||
handler.Register(r)
|
||||
mux, err := r.BuildMux()
|
||||
require.NoError(t, err)
|
||||
mounts := ServiceMounts(
|
||||
AppChain(handler.Routes(), env.users, testTrustedNetworks(t), logger),
|
||||
http.NotFoundHandler(),
|
||||
)
|
||||
mux := BuildHandler(mounts, NewWebappHandler(builtDist(), true, logger), logger)
|
||||
|
||||
req := httptest.NewRequest("GET", "/app/audiorecords/"+record.Id+"/text?view="+entity.TextViewTranscript, http.NoBody)
|
||||
asUser(req, env.login)
|
||||
@@ -280,16 +336,25 @@ func TestRecordText_ReadFailureIsNotANotFound(t *testing.T) {
|
||||
|
||||
// Прежние адреса приложения убраны целиком: контракт объявлен сломанным, и
|
||||
// адрес, отвечающий по-старому, означал бы два дома у одного вопроса.
|
||||
//
|
||||
// Отвечают они теперь тем же, чем отвечает всякий путь вне корней сервиса, —
|
||||
// разметкой приложения: корень `/api` перестал существовать вместе со
|
||||
// встроенным хранилищем, и резервировать имя за отказом сервис не берётся.
|
||||
func TestFormerAddressesAreGone(t *testing.T) {
|
||||
env := setupTestEnv(t, readableMetaViewer())
|
||||
|
||||
record := jobWithFile(t, env)
|
||||
before := countJobs(t, env)
|
||||
|
||||
status := httptest.NewRecorder()
|
||||
env.serve(status, httptest.NewRequest("GET", "/api/status/"+record.Id, http.NoBody))
|
||||
assert.Equal(t, http.StatusNotFound, status.Code)
|
||||
assert.Equal(t, http.StatusOK, status.Code)
|
||||
assert.Contains(t, status.Body.String(), "приложение", "прежний адрес отдал не разметку")
|
||||
assert.NotContains(t, status.Body.String(), entity.StateUploaded,
|
||||
"прежний адрес отдал данные записи")
|
||||
|
||||
intake := httptest.NewRecorder()
|
||||
env.serve(intake, createMultipartRequestAt(t, "/api/audio", "запись.mp3", []byte("данные")))
|
||||
assert.Equal(t, http.StatusNotFound, intake.Code)
|
||||
assert.Equal(t, http.StatusMethodNotAllowed, intake.Code)
|
||||
assert.Equal(t, before, countJobs(t, env), "прежний адрес приёма завёл запись")
|
||||
}
|
||||
|
||||
@@ -6,6 +6,8 @@ import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"io/fs"
|
||||
"log/slog"
|
||||
"mime/multipart"
|
||||
"net/http"
|
||||
@@ -15,20 +17,19 @@ import (
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
"testing/fstest"
|
||||
"time"
|
||||
|
||||
"github.com/pocketbase/pocketbase/apis"
|
||||
"github.com/pocketbase/pocketbase/core"
|
||||
"github.com/prometheus/client_golang/prometheus"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"git.vakhrushev.me/av/transcriber/internal/adapter/recognizer"
|
||||
pbrepo "git.vakhrushev.me/av/transcriber/internal/adapter/repo/pocketbase"
|
||||
"git.vakhrushev.me/av/transcriber/internal/adapter/repo/pocketbase/migrations"
|
||||
sqliterepo "git.vakhrushev.me/av/transcriber/internal/adapter/repo/sqlite"
|
||||
"git.vakhrushev.me/av/transcriber/internal/clock"
|
||||
"git.vakhrushev.me/av/transcriber/internal/contract"
|
||||
"git.vakhrushev.me/av/transcriber/internal/entity"
|
||||
"git.vakhrushev.me/av/transcriber/internal/ident"
|
||||
"git.vakhrushev.me/av/transcriber/internal/service"
|
||||
)
|
||||
|
||||
@@ -66,19 +67,33 @@ func readableMetaViewer() *stubMetaViewer {
|
||||
return &stubMetaViewer{seconds: 42}
|
||||
}
|
||||
|
||||
const testMarkup = `<!doctype html><html lang="ru"><body>приложение</body></html>`
|
||||
|
||||
// builtDist — собранное приложение, каким его видит раздача.
|
||||
func builtDist() fs.FS {
|
||||
return fstest.MapFS{
|
||||
"index.html": {Data: []byte(testMarkup)},
|
||||
"assets/index-abc123.js": {Data: []byte("console.log(1)")},
|
||||
"favicon.ico": {Data: []byte("значок")},
|
||||
}
|
||||
}
|
||||
|
||||
// testEnv — собранное окружение одной проверки. Каталог данных свой у каждой:
|
||||
// рабочий каталог процесса проверки не трогают.
|
||||
type testEnv struct {
|
||||
mux http.Handler
|
||||
handler *AppHandler
|
||||
app core.App
|
||||
db *sqliterepo.DB
|
||||
store *sqliterepo.Store
|
||||
users *sqliterepo.UserRepository
|
||||
files *sqliterepo.FileRepository
|
||||
journal *journalBuffer
|
||||
// login — логин у провайдера, которым доверенный источник называет
|
||||
// пришедшего. Адреса приложения закрыты узнаванием, и проверка, судящая их
|
||||
// по существу, обязана назваться ровно так же, как это делает прокси.
|
||||
login string
|
||||
// account — учётная запись, которой принадлежит этот логин.
|
||||
account *core.Record
|
||||
account *contract.UserAccount
|
||||
}
|
||||
|
||||
// Доверенный источник проверок.
|
||||
@@ -93,10 +108,68 @@ const (
|
||||
func testTrustedNetworks(t *testing.T) []netip.Prefix {
|
||||
t.Helper()
|
||||
|
||||
prefix, err := netip.ParsePrefix("10.0.0.0/8")
|
||||
// Подсетей две: своя для запросов через `httptest.NewRecorder` и петлевая
|
||||
// для тех проверок, которым нужен **настоящий** HTTP-запрос через поднятую
|
||||
// цепочку слоёв — у них адрес пира ставит ядро, и он петлевой.
|
||||
networks := make([]netip.Prefix, 0, 2)
|
||||
for _, raw := range []string{"10.0.0.0/8", "127.0.0.0/8"} {
|
||||
prefix, err := netip.ParsePrefix(raw)
|
||||
require.NoError(t, err)
|
||||
networks = append(networks, prefix)
|
||||
}
|
||||
|
||||
return networks
|
||||
}
|
||||
|
||||
// liveServer поднимает настоящий HTTP-сервер на этой поверхности.
|
||||
//
|
||||
// Нужен там, где проверка судит поведение, которого у вызова обработчика
|
||||
// напрямую не бывает: разбор заголовка диапазона идёт по настоящему запросу, а
|
||||
// адрес пира ставит ядро.
|
||||
func (e *testEnv) liveServer(t *testing.T) *httptest.Server {
|
||||
t.Helper()
|
||||
|
||||
server := httptest.NewServer(e.mux)
|
||||
t.Cleanup(server.Close)
|
||||
|
||||
return server
|
||||
}
|
||||
|
||||
// liveResponse — готовый ответ настоящего запроса: тело прочитано и закрыто, и
|
||||
// проверке остаётся судить значения.
|
||||
type liveResponse struct {
|
||||
StatusCode int
|
||||
Header http.Header
|
||||
ContentLength int64
|
||||
Body []byte
|
||||
}
|
||||
|
||||
// liveRequest шлёт настоящий запрос от имени учётной записи проверки.
|
||||
func (e *testEnv) liveRequest(
|
||||
t *testing.T, server *httptest.Server, path string, headers map[string]string,
|
||||
) liveResponse {
|
||||
t.Helper()
|
||||
|
||||
req, err := http.NewRequestWithContext(context.Background(), http.MethodGet, server.URL+path, nil)
|
||||
require.NoError(t, err)
|
||||
req.Header.Set(LoginHeader, e.login)
|
||||
for name, value := range headers {
|
||||
req.Header.Set(name, value)
|
||||
}
|
||||
|
||||
res, err := server.Client().Do(req)
|
||||
require.NoError(t, err)
|
||||
defer func() { require.NoError(t, res.Body.Close()) }()
|
||||
|
||||
body, err := io.ReadAll(res.Body)
|
||||
require.NoError(t, err)
|
||||
|
||||
return []netip.Prefix{prefix}
|
||||
return liveResponse{
|
||||
StatusCode: res.StatusCode,
|
||||
Header: res.Header,
|
||||
ContentLength: res.ContentLength,
|
||||
Body: body,
|
||||
}
|
||||
}
|
||||
|
||||
// asUser делает запрос запросом названного человека: ставит заголовок и адрес
|
||||
@@ -113,30 +186,23 @@ func (e *testEnv) serve(w http.ResponseWriter, req *http.Request) {
|
||||
e.mux.ServeHTTP(w, asUser(req, e.login))
|
||||
}
|
||||
|
||||
// newTestAccount заводит учётную запись с известным логином.
|
||||
//
|
||||
// Запись создаётся прямым сохранением, а не запросом к API: заводить её
|
||||
// запросом нельзя — создание закрыто шагом схемы. Прямое сохранение идёт мимо
|
||||
// правил доступа так же, как идёт заведение записи первым обращением.
|
||||
func newTestAccount(t *testing.T, app core.App) (*core.Record, string) {
|
||||
t.Helper()
|
||||
// get шлёт запрос **неузнанным**: заголовка на нём нет вовсе.
|
||||
func (e *testEnv) get(path string) *httptest.ResponseRecorder {
|
||||
w := httptest.NewRecorder()
|
||||
e.mux.ServeHTTP(w, httptest.NewRequest(http.MethodGet, path, nil))
|
||||
return w
|
||||
}
|
||||
|
||||
const login = "person"
|
||||
// getAs шлёт запрос от имени названного логина.
|
||||
func (e *testEnv) getAs(login, path string) *httptest.ResponseRecorder {
|
||||
w := httptest.NewRecorder()
|
||||
e.mux.ServeHTTP(w, asUser(httptest.NewRequest(http.MethodGet, path, nil), login))
|
||||
return w
|
||||
}
|
||||
|
||||
users, err := app.FindCollectionByNameOrId("users")
|
||||
require.NoError(t, err)
|
||||
|
||||
record := core.NewRecord(users)
|
||||
record.Set(migrations.ProviderLoginField, login)
|
||||
record.Set("email", "person@example.com")
|
||||
record.Set("verified", true)
|
||||
// Случайный пароль ставит и сам сервис, когда заводит запись первым
|
||||
// обращением: запись auth-коллекции без пароля не сохраняется, а войти по
|
||||
// нему всё равно нельзя — парольный вход выключен шагом схемы.
|
||||
record.SetRandomPassword()
|
||||
require.NoError(t, app.Save(record))
|
||||
|
||||
return record, login
|
||||
// getOwn шлёт запрос от имени учётной записи проверки.
|
||||
func (e *testEnv) getOwn(path string) *httptest.ResponseRecorder {
|
||||
return e.getAs(e.login, path)
|
||||
}
|
||||
|
||||
// journalBuffer — перехваченный журнал одной проверки. Свой на случай: общий на
|
||||
@@ -162,45 +228,72 @@ func (b *journalBuffer) String() string {
|
||||
return b.text.String()
|
||||
}
|
||||
|
||||
// newTestStorage поднимает хранилище на пустом каталоге и накатывает схему —
|
||||
// ровно тем же путём, каким это делает сервис при старте.
|
||||
func newTestStorage(t *testing.T) core.App {
|
||||
// newTestStorage поднимает базу на пустом каталоге и накатывает схему — ровно
|
||||
// тем же путём, каким это делает сервис при старте.
|
||||
func newTestStorage(t *testing.T) (*sqliterepo.DB, *sqliterepo.Store, string) {
|
||||
t.Helper()
|
||||
|
||||
app, err := pbrepo.New(t.TempDir())
|
||||
dir := t.TempDir()
|
||||
db, err := sqliterepo.Open(dir, sqliterepo.Settings{BusyTimeoutMs: 5000, ReadConnections: 4})
|
||||
require.NoError(t, err)
|
||||
t.Cleanup(func() {
|
||||
if err := app.ResetBootstrapState(); err != nil {
|
||||
t.Logf("не удалось закрыть хранилище: %v", err)
|
||||
if err := db.Close(); err != nil {
|
||||
t.Logf("не удалось закрыть базу: %v", err)
|
||||
}
|
||||
})
|
||||
|
||||
return app
|
||||
require.NoError(t, sqliterepo.Migrate(
|
||||
context.Background(), db, dir, slog.New(slog.DiscardHandler),
|
||||
))
|
||||
|
||||
return db, sqliterepo.NewStore(dir), dir
|
||||
}
|
||||
|
||||
// envOptions — чем одна проверка отличается от другой.
|
||||
type envOptions struct {
|
||||
metaviewer contract.AudioMetaViewer
|
||||
dist fs.FS
|
||||
built bool
|
||||
}
|
||||
|
||||
func setupTestEnv(t *testing.T, metaviewer contract.AudioMetaViewer) *testEnv {
|
||||
app := newTestStorage(t)
|
||||
return setupEnv(t, envOptions{metaviewer: metaviewer, dist: builtDist(), built: true})
|
||||
}
|
||||
|
||||
pbrepo.BindPanelRules(app)
|
||||
// setupEnv собирает поверхность сервиса тем же способом, что и точка входа:
|
||||
// адреса порождает перечень адресного пространства, слои одевает та же цепочка.
|
||||
// Собери проверка поверхность иначе — и судила бы она не то.
|
||||
func setupEnv(t *testing.T, opts envOptions) *testEnv {
|
||||
t.Helper()
|
||||
|
||||
db, store, _ := newTestStorage(t)
|
||||
|
||||
recordRepo := sqliterepo.NewAudioRecordRepository(db)
|
||||
textRepo := sqliterepo.NewTextRepository(db)
|
||||
structureRepo := sqliterepo.NewStructureRepository(db)
|
||||
fileRepo := sqliterepo.NewFileRepository(db, store)
|
||||
users := sqliterepo.NewUserRepository(db)
|
||||
|
||||
recordRepo := pbrepo.NewAudioRecordRepository(app)
|
||||
textRepo := pbrepo.NewTextRepository(app)
|
||||
repos := service.Repositories{
|
||||
Records: recordRepo,
|
||||
Files: pbrepo.NewFileRepository(app),
|
||||
Files: fileRepo,
|
||||
Texts: textRepo,
|
||||
Structures: pbrepo.NewStructureRepository(app),
|
||||
Recognitions: pbrepo.NewRecognitionRepository(app),
|
||||
Events: pbrepo.NewRecordEventRepository(app),
|
||||
Structures: structureRepo,
|
||||
Recognitions: sqliterepo.NewRecognitionRepository(db, store),
|
||||
Events: sqliterepo.NewRecordEventRepository(db),
|
||||
}
|
||||
|
||||
// Журнал уходит в буфер, а не в никуда: по нему судит проверка запрета на
|
||||
// имя отправителя. Вывод прогона от этого не меняется — ERROR-строки ветки
|
||||
// отказа по-прежнему не попадают на экран и не размывают признак, по
|
||||
// которому отличают новый красный шаг гейта от объявленного долга.
|
||||
// отказа по-прежнему не попадают на экран.
|
||||
journal := &journalBuffer{}
|
||||
logger := slog.New(slog.NewTextHandler(journal, nil))
|
||||
|
||||
metaviewer := opts.metaviewer
|
||||
if metaviewer == nil {
|
||||
metaviewer = readableMetaViewer()
|
||||
}
|
||||
|
||||
trsService := service.NewTranscribeService(
|
||||
repos,
|
||||
metaviewer,
|
||||
@@ -210,29 +303,31 @@ func setupTestEnv(t *testing.T, metaviewer contract.AudioMetaViewer) *testEnv {
|
||||
logger,
|
||||
)
|
||||
|
||||
handler := NewAppHandler(recordRepo, textRepo, pbrepo.NewStructureRepository(app), trsService, logger)
|
||||
handler := NewAppHandler(recordRepo, textRepo, structureRepo, fileRepo, trsService, logger)
|
||||
|
||||
// Роутер собирается тем же способом, что и боевой: слой узнавания вешается
|
||||
// корневым, маршруты вешает сам обработчик, и проверка судит ту же цепочку,
|
||||
// что и прод. Собери роутер иначе — и проверка судила бы не то.
|
||||
r, err := apis.NewRouter(app)
|
||||
mounts := ServiceMounts(
|
||||
AppChain(handler.Routes(), users, testTrustedNetworks(t), logger),
|
||||
http.NotFoundHandler(),
|
||||
)
|
||||
webapp := NewWebappHandler(opts.dist, opts.built, logger)
|
||||
mux := BuildHandler(mounts, webapp, logger)
|
||||
|
||||
account, _, err := users.EnsureUser(contract.Identity{
|
||||
Login: "person",
|
||||
Name: "Человек",
|
||||
Email: "person@example.com",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
mounts := ServiceMounts(handler, http.NotFoundHandler())
|
||||
r.Bind(TrustedHeaderIdentity(app, mounts, testTrustedNetworks(t), logger))
|
||||
handler.Register(r)
|
||||
|
||||
mux, err := r.BuildMux()
|
||||
require.NoError(t, err)
|
||||
|
||||
account, login := newTestAccount(t, app)
|
||||
|
||||
return &testEnv{
|
||||
mux: mux,
|
||||
handler: handler,
|
||||
app: app,
|
||||
db: db,
|
||||
store: store,
|
||||
users: users,
|
||||
files: fileRepo,
|
||||
journal: journal,
|
||||
login: login,
|
||||
login: "person",
|
||||
account: account,
|
||||
}
|
||||
}
|
||||
@@ -285,65 +380,115 @@ func intakeItemOf(t *testing.T, w *httptest.ResponseRecorder) IntakeItem {
|
||||
return items[0]
|
||||
}
|
||||
|
||||
// storedFileNames отдаёт имена, под которыми файлы легли в хранилище.
|
||||
// storedFileNames отдаёт имена, под которыми копии легли в каталог данных.
|
||||
func storedFileNames(t *testing.T, env *testEnv) []string {
|
||||
records, err := env.app.FindAllRecords(migrations.FilesCollection)
|
||||
t.Helper()
|
||||
|
||||
rows, err := env.db.Reader().QueryContext(context.Background(), "SELECT file_name FROM files")
|
||||
require.NoError(t, err)
|
||||
defer func() { require.NoError(t, rows.Close()) }()
|
||||
|
||||
var names []string
|
||||
for _, record := range records {
|
||||
names = append(names, record.GetStringSlice("file")...)
|
||||
for rows.Next() {
|
||||
var name string
|
||||
require.NoError(t, rows.Scan(&name))
|
||||
names = append(names, name)
|
||||
}
|
||||
require.NoError(t, rows.Err())
|
||||
return names
|
||||
}
|
||||
|
||||
// countFiles считает записи о файлах.
|
||||
// countRows считает строки названной таблицы.
|
||||
func countRows(t *testing.T, env *testEnv, table string) int {
|
||||
t.Helper()
|
||||
|
||||
var total int
|
||||
require.NoError(t, env.db.Reader().QueryRowContext(context.Background(), "SELECT COUNT(*) FROM "+table).Scan(&total))
|
||||
return total
|
||||
}
|
||||
|
||||
// countFiles считает строки о копиях записей.
|
||||
func countFiles(t *testing.T, env *testEnv) int {
|
||||
records, err := env.app.FindAllRecords(migrations.FilesCollection)
|
||||
require.NoError(t, err)
|
||||
return len(records)
|
||||
return countRows(t, env, "files")
|
||||
}
|
||||
|
||||
// countJobs считает заведённые аудиозаписи.
|
||||
func countJobs(t *testing.T, env *testEnv) int {
|
||||
records, err := env.app.FindAllRecords(migrations.RecordsCollection)
|
||||
require.NoError(t, err)
|
||||
return len(records)
|
||||
return countRows(t, env, "audio_records")
|
||||
}
|
||||
|
||||
// jobWithFile заводит задачу вместе с её записью: ссылка на файл обязательна
|
||||
// схемой, потому что без неё задача не пройдёт ни одного шага.
|
||||
// jobWithFile заводит запись вместе с её принятой копией: без ссылки на файл
|
||||
// запись не прошла бы ни одного шага.
|
||||
func jobWithFile(t *testing.T, env *testEnv) *entity.AudioRecord {
|
||||
t.Helper()
|
||||
|
||||
repo := pbrepo.NewFileRepository(env.app)
|
||||
work, err := repo.Stage(".mp3", strings.NewReader("запись"))
|
||||
return jobWithFileOf(t, env, env.account.ID)
|
||||
}
|
||||
|
||||
// jobWithFileOf — то же, но от имени названного владельца.
|
||||
func jobWithFileOf(t *testing.T, env *testEnv, ownerID string) *entity.AudioRecord {
|
||||
t.Helper()
|
||||
|
||||
recordID := ident.New()
|
||||
|
||||
work, err := env.files.Stage(".mp3", strings.NewReader("запись"))
|
||||
require.NoError(t, err)
|
||||
defer func() { require.NoError(t, work.Close()) }()
|
||||
|
||||
// Владелец — учётная запись проверки: задача, пришедшая из веба, без
|
||||
// владельца больше не заводится, и фикстура без него описывала бы состояние,
|
||||
// которого в проде не бывает.
|
||||
file, err := repo.Create("sample.mp3", work, contract.FileMeta{Format: "mp3"}, env.account.Id)
|
||||
// Владелец — учётная запись проверки: ничьей записи в хранилище не бывает, и
|
||||
// фикстура без владельца описывала бы состояние, которого в проде нет.
|
||||
file, err := env.files.Create(
|
||||
recordID, "sample.mp3", work, contract.FileMeta{Format: "mp3"}, ownerID,
|
||||
)
|
||||
require.NoError(t, err)
|
||||
|
||||
record := &entity.AudioRecord{
|
||||
Id: recordID,
|
||||
State: entity.StateUploaded,
|
||||
StateEnteredAt: clock.Now(),
|
||||
Source: entity.SourceApi,
|
||||
OwnerID: env.account.Id,
|
||||
OwnerID: ownerID,
|
||||
OriginalFileID: &file.Id,
|
||||
}
|
||||
require.NoError(t, env.handler.recordRepo.Create(record))
|
||||
return record
|
||||
}
|
||||
|
||||
// storedContent читает содержимое файла из хранилища.
|
||||
func storedContent(t *testing.T, env *testEnv, fileID string) []byte {
|
||||
repo := pbrepo.NewFileRepository(env.app)
|
||||
reader, err := repo.Open(fileID)
|
||||
// newTopic заводит тему в словаре названного владельца.
|
||||
//
|
||||
// Запросом к базе, а не адресом сервиса: адреса, которым заводят тему, у сервиса
|
||||
// нет — словарь пишет задача языковой модели, которой ещё нет. Проверка обязана
|
||||
// пройти тот же путь разрешения тем, каким пойдёт она.
|
||||
func newTopic(t *testing.T, env *testEnv, ownerID, name string) string {
|
||||
t.Helper()
|
||||
|
||||
id := ident.New()
|
||||
now := clock.Now().UTC().Format("2006-01-02T15:04:05Z")
|
||||
_, err := env.db.Writer().ExecContext(context.Background(),
|
||||
"INSERT INTO topics (id, owner_id, name, created_at, updated_at) VALUES (?, ?, ?, ?, ?)",
|
||||
id, ownerID, name, now, now,
|
||||
)
|
||||
require.NoError(t, err)
|
||||
defer reader.Close()
|
||||
|
||||
return id
|
||||
}
|
||||
|
||||
// attachTopic связывает тему с записью.
|
||||
func attachTopic(t *testing.T, env *testEnv, recordID, topicID string) {
|
||||
t.Helper()
|
||||
|
||||
_, err := env.db.Writer().ExecContext(context.Background(),
|
||||
"INSERT INTO record_topics (record_id, topic_id) VALUES (?, ?)", recordID, topicID,
|
||||
)
|
||||
require.NoError(t, err)
|
||||
}
|
||||
|
||||
// storedContent читает содержимое копии из каталога данных.
|
||||
func storedContent(t *testing.T, env *testEnv, fileID string) []byte {
|
||||
t.Helper()
|
||||
|
||||
reader, err := env.files.Open(fileID)
|
||||
require.NoError(t, err)
|
||||
defer func() { require.NoError(t, reader.Close()) }()
|
||||
|
||||
var buf bytes.Buffer
|
||||
_, err = buf.ReadFrom(reader)
|
||||
@@ -387,7 +532,7 @@ func TestCreateTranscribeJob_Success(t *testing.T) {
|
||||
// отправитель получит идентификатор записи, которой не будет никогда.
|
||||
require.Equal(t, 1, countJobs(t, env))
|
||||
|
||||
job, err := env.handler.recordRepo.GetByID(response.ID, env.account.Id)
|
||||
job, err := env.handler.recordRepo.GetByID(response.ID, env.account.ID)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, entity.StateUploaded, job.State)
|
||||
require.NotNil(t, job.OriginalFileID)
|
||||
@@ -529,9 +674,20 @@ func TestCreateTranscribeJob_SenderFileNameNotStored(t *testing.T) {
|
||||
require.Len(t, names, 1)
|
||||
|
||||
assert.NotContains(t, names[0], "секретное-слово",
|
||||
"имя, данное отправителем, в хранилище не попадает")
|
||||
"имя, данное отправителем, в имя файла не попадает")
|
||||
assert.True(t, strings.HasSuffix(names[0], ".mp3"),
|
||||
"расширение при этом сохраняется: %q", names[0])
|
||||
|
||||
// И в **путь** к файлу оно не попадает тоже: подкаталог назван
|
||||
// идентификатором записи, а не именем отправителя.
|
||||
var recordID, storedRecord string
|
||||
require.NoError(t, env.db.Reader().
|
||||
QueryRow("SELECT id FROM audio_records").Scan(&recordID))
|
||||
require.NoError(t, env.db.Reader().
|
||||
QueryRow("SELECT record_id FROM files").Scan(&storedRecord))
|
||||
assert.Equal(t, recordID, storedRecord, "подкаталог копии назван не записью")
|
||||
assert.NotContains(t, storedRecord, "секретное-слово",
|
||||
"имя, данное отправителем, попало в путь к файлу")
|
||||
}
|
||||
|
||||
func TestCreateTranscribeJob_MetaViewerFailure(t *testing.T) {
|
||||
@@ -659,7 +815,7 @@ func TestCreateTranscribeJob_JournalTracesRecord(t *testing.T) {
|
||||
|
||||
response := intakeItemOf(t, w)
|
||||
|
||||
job, err := env.handler.recordRepo.GetByID(response.ID, env.account.Id)
|
||||
job, err := env.handler.recordRepo.GetByID(response.ID, env.account.ID)
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, job.OriginalFileID)
|
||||
|
||||
@@ -821,7 +977,5 @@ func TestAcceptedRecordSurvivesSenderDisconnect(t *testing.T) {
|
||||
|
||||
require.Equal(t, http.StatusCreated, w.Result().StatusCode, "тело ответа: %s", w.Body.String())
|
||||
|
||||
jobs, err := env.app.FindAllRecords(migrations.RecordsCollection)
|
||||
require.NoError(t, err)
|
||||
assert.Len(t, jobs, 1, "задача заведена, несмотря на ушедшего отправителя")
|
||||
assert.Equal(t, 1, countJobs(t, env), "задача заведена, несмотря на ушедшего отправителя")
|
||||
}
|
||||
|
||||
@@ -9,29 +9,6 @@ import (
|
||||
"path"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/pocketbase/pocketbase/apis"
|
||||
"github.com/pocketbase/pocketbase/core"
|
||||
"github.com/pocketbase/pocketbase/tools/router"
|
||||
)
|
||||
|
||||
// Корни адресного пространства и отдельные адреса наблюдения.
|
||||
//
|
||||
// `/api` и `/_` принадлежат хранилищу: первый — его наборам адресов, второй —
|
||||
// панели владельца. Поменять их нельзя, это литералы библиотеки.
|
||||
//
|
||||
// Корня `/auth` здесь больше нет: собственного входа у сервиса не осталось, и
|
||||
// адресов под этим корнем не существует. Прежние адреса входа поэтому отвечают
|
||||
// тем же, чем отвечает всякий путь вне корней, — разметкой приложения.
|
||||
// Резервировать имя за отказом сервис не берётся: имя, за которым ничего не
|
||||
// стоит, ничем не отличается от любого другого свободного, а второй перечень
|
||||
// «когда-то занятых корней» разошёлся бы с первым молча.
|
||||
const (
|
||||
StorageRoot = "/api"
|
||||
PanelRoot = "/_"
|
||||
|
||||
HealthPath = "/health"
|
||||
MetricsPath = "/metrics"
|
||||
)
|
||||
|
||||
// assetsDir — каталог, который наполняет сборщик приложения.
|
||||
@@ -45,104 +22,6 @@ const assetsDir = "assets"
|
||||
// Срок хранения ресурса сборщика — год.
|
||||
const assetMaxAgeSeconds = 31536000
|
||||
|
||||
// Mount — часть адресного пространства, принадлежащая сервису.
|
||||
//
|
||||
// Перечень этих частей — **единственное** описание того, что сервису
|
||||
// принадлежит, и он не описывает регистрацию, а порождает её: корень,
|
||||
// заведённый мимо перечня, не получит обработчика вовсе. Прежде такой перечень
|
||||
// был бы вторым описанием таблицы маршрутов, которую ведут три места, и корень,
|
||||
// забытый в нём, молча отдавал бы разметку там, где программа ждёт отказ
|
||||
// контракта.
|
||||
type Mount struct {
|
||||
// Path — корень либо точный адрес.
|
||||
Path string
|
||||
|
||||
// Exact — путь является точным адресом, а не корнем: `/health` накрывает
|
||||
// только сам себя, а `/app` — всё, что под ним.
|
||||
Exact bool
|
||||
|
||||
// Bind вешает обработчики этой части. Пусто у того, что вешает библиотека.
|
||||
Bind func(r *router.Router[*core.RequestEvent])
|
||||
}
|
||||
|
||||
// Covers говорит, принадлежит ли путь этой части адресного пространства.
|
||||
//
|
||||
// Условий два, и оба обязательны: точное совпадение либо префикс **вместе с
|
||||
// косой чертой**. По одному префиксу корню `/app` достался бы посторонний
|
||||
// `/apple`; по одному префиксу с косой чертой голый `/api` не достался бы
|
||||
// никому и уехал бы разметкой приложения.
|
||||
func (m Mount) Covers(requestPath string) bool {
|
||||
if m.Exact {
|
||||
return requestPath == m.Path
|
||||
}
|
||||
|
||||
return requestPath == m.Path || strings.HasPrefix(requestPath, m.Path+"/")
|
||||
}
|
||||
|
||||
// ServiceMounts перечисляет адресное пространство сервиса целиком.
|
||||
func ServiceMounts(appHandler *AppHandler, metricsHandler http.Handler) []Mount {
|
||||
return []Mount{
|
||||
{Path: StorageRoot},
|
||||
{Path: PanelRoot},
|
||||
{Path: AppRoot, Bind: appHandler.Register},
|
||||
{Path: HealthPath, Exact: true, Bind: bindHealth},
|
||||
{Path: MetricsPath, Exact: true, Bind: bindMetrics(metricsHandler)},
|
||||
}
|
||||
}
|
||||
|
||||
// RegisterServiceRoutes вешает всё, что сервис вешает сам.
|
||||
func RegisterServiceRoutes(r *router.Router[*core.RequestEvent], mounts []Mount) {
|
||||
for _, mount := range mounts {
|
||||
if mount.Bind != nil {
|
||||
mount.Bind(r)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// IsServiceAddress говорит, принадлежит ли путь сервису хоть какой-то частью.
|
||||
func IsServiceAddress(mounts []Mount, requestPath string) bool {
|
||||
for _, mount := range mounts {
|
||||
if mount.Covers(requestPath) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
// IsObservationAddress говорит, что путь — адрес наблюдения.
|
||||
//
|
||||
// Опрос здоровья и метрик идёт постоянно и полезного не несёт, поэтому уровень
|
||||
// журнала у него свой. Перечень при этом тот же самый: второе перечисление этих
|
||||
// адресов разошлось бы с первым молча.
|
||||
func IsObservationAddress(mounts []Mount, requestPath string) bool {
|
||||
for _, mount := range mounts {
|
||||
if mount.Exact && mount.Covers(requestPath) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
func bindHealth(r *router.Router[*core.RequestEvent]) {
|
||||
r.GET(HealthPath, func(e *core.RequestEvent) error {
|
||||
return e.JSON(http.StatusOK, map[string]string{
|
||||
"status": "ok",
|
||||
"message": "Transcriber service is running",
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
func bindMetrics(handler http.Handler) func(r *router.Router[*core.RequestEvent]) {
|
||||
return func(r *router.Router[*core.RequestEvent]) {
|
||||
r.GET(MetricsPath, func(e *core.RequestEvent) error {
|
||||
handler.ServeHTTP(e.Response, e.Request)
|
||||
return nil
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// notBuiltPage — что видит человек у бинарника без собранного приложения.
|
||||
//
|
||||
// Состояние возможно только у собранного мимо набора проверок: и набор
|
||||
@@ -164,25 +43,10 @@ const (
|
||||
OutcomeFailure = "failure"
|
||||
)
|
||||
|
||||
// journalOutcomeKey — под каким ключом раздача оставляет исход слою журнала.
|
||||
const journalOutcomeKey = "transcriberWebappOutcome"
|
||||
|
||||
// WebappOutcome отдаёт исход, оставленный раздачей, либо пустую строку, если
|
||||
// запрос до неё не дошёл.
|
||||
func WebappOutcome(e *core.RequestEvent) string {
|
||||
outcome, ok := e.Get(journalOutcomeKey).(string)
|
||||
if !ok {
|
||||
return ""
|
||||
}
|
||||
|
||||
return outcome
|
||||
}
|
||||
|
||||
// WebappHandler раздаёт собранное приложение и держит правило неизвестного пути.
|
||||
type WebappHandler struct {
|
||||
dist fs.FS
|
||||
built bool
|
||||
mounts []Mount
|
||||
fingerprint string
|
||||
logger *slog.Logger
|
||||
}
|
||||
@@ -191,14 +55,14 @@ type WebappHandler struct {
|
||||
//
|
||||
// Файловая система приходит параметром, а не тянется пакетом: так тест
|
||||
// подставляет свою сборку, не собирая приложение.
|
||||
func NewWebappHandler(dist fs.FS, built bool, mounts []Mount, logger *slog.Logger) *WebappHandler {
|
||||
func NewWebappHandler(dist fs.FS, built bool, logger *slog.Logger) *WebappHandler {
|
||||
if logger == nil {
|
||||
logger = slog.Default()
|
||||
}
|
||||
|
||||
if !built {
|
||||
logger.Warn("Webapp is not built, service will answer with a placeholder page")
|
||||
return &WebappHandler{dist: dist, built: built, mounts: mounts, logger: logger}
|
||||
return &WebappHandler{dist: dist, built: built, logger: logger}
|
||||
}
|
||||
|
||||
// Отпечаток вшитой сборки — единственное, чем «не та сборка» отличается от
|
||||
@@ -211,17 +75,12 @@ func NewWebappHandler(dist fs.FS, built bool, mounts []Mount, logger *slog.Logge
|
||||
return &WebappHandler{
|
||||
dist: dist,
|
||||
built: built,
|
||||
mounts: mounts,
|
||||
fingerprint: fingerprint,
|
||||
logger: logger,
|
||||
}
|
||||
}
|
||||
|
||||
// buildFingerprint — короткий отпечаток разметки вшитой сборки.
|
||||
//
|
||||
// Считается по самой разметке, а не по файлу, который положил бы сборщик: файл
|
||||
// пришлось бы заводить настройкой сборки, а разметка меняется вместе с именами
|
||||
// ресурсов, то есть при всякой пересборке с изменениями.
|
||||
func buildFingerprint(dist fs.FS) string {
|
||||
markup, err := fs.ReadFile(dist, "index.html")
|
||||
if err != nil {
|
||||
@@ -235,54 +94,41 @@ func buildFingerprint(dist fs.FS) string {
|
||||
|
||||
// Register вешает раздачу на корень.
|
||||
//
|
||||
// Маршрут стоит ровно на `/`, а не на `/{path...}`: сборка маршрутов сама
|
||||
// вешает на `/` отказ «ничего не совпало», если такого маршрута ещё нет, и
|
||||
// второй всепокрывающий образец рядом с ним спорил бы с ним за путь.
|
||||
func (h *WebappHandler) Register(r *router.Router[*core.RequestEvent]) {
|
||||
// Успешная раздача в журнал хранилища не пишется.
|
||||
//
|
||||
// Журнал хранилища — второй, помимо журнала контейнера, и в него библиотека
|
||||
// кладёт путь запроса целиком вместе с адресом отправителя, храня строки
|
||||
// пять суток. Путь здесь выбирает спрашивающий, и без этого отказа всякое
|
||||
// открытие приложения оставляло бы там его текст и его адрес. Готовая
|
||||
// раздача статики ставит тот же признак первой строкой; своя написана мимо
|
||||
// неё, и признак перенесён руками.
|
||||
//
|
||||
// Отказы записываются по-прежнему: признак снимает только успех.
|
||||
r.Any("/", h.Serve).Bind(apis.SkipSuccessActivityLog())
|
||||
// Путь, принадлежащий корню сервиса, до неё не доходит вовсе: его забирает
|
||||
// маршрутизатор, у которого корень объявлен своим образцом. Отказ контракта
|
||||
// поэтому остаётся отказом контракта, а не проваливается в разметку.
|
||||
func (h *WebappHandler) Register(mux *http.ServeMux) {
|
||||
mux.HandleFunc("/", h.Serve)
|
||||
}
|
||||
|
||||
// Serve отдаёт приложение либо отказ — по порядку, объявленному дизайном.
|
||||
func (h *WebappHandler) Serve(e *core.RequestEvent) error {
|
||||
requestPath := e.Request.URL.Path
|
||||
|
||||
// Путь принадлежит сервису — значит под этим корнем такого адреса просто
|
||||
// нет. Отказ уходит формой библиотеки, то есть тем же, чем этот корень
|
||||
// отвечает и сегодня: своя форма здесь была бы второй.
|
||||
if IsServiceAddress(h.mounts, requestPath) {
|
||||
e.Set(journalOutcomeKey, OutcomeFailure)
|
||||
return router.NewNotFoundError("", nil)
|
||||
}
|
||||
|
||||
if e.Request.Method != http.MethodGet && e.Request.Method != http.MethodHead {
|
||||
e.Set(journalOutcomeKey, OutcomeFailure)
|
||||
return router.NewApiError(http.StatusMethodNotAllowed, "", nil)
|
||||
// Serve отдаёт приложение либо отказ.
|
||||
func (h *WebappHandler) Serve(w http.ResponseWriter, r *http.Request) {
|
||||
// Открывающими страницу считаются `GET` и `HEAD`, и только они.
|
||||
if r.Method != http.MethodGet && r.Method != http.MethodHead {
|
||||
noteWebappOutcome(r, OutcomeFailure)
|
||||
http.Error(w, "", http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
|
||||
if !h.built {
|
||||
e.Set(journalOutcomeKey, OutcomeFailure)
|
||||
return e.HTML(http.StatusServiceUnavailable, notBuiltPage)
|
||||
noteWebappOutcome(r, OutcomeFailure)
|
||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||
w.WriteHeader(http.StatusServiceUnavailable)
|
||||
_, _ = w.Write([]byte(notBuiltPage))
|
||||
return
|
||||
}
|
||||
|
||||
name := strings.TrimPrefix(path.Clean(requestPath), "/")
|
||||
name := strings.TrimPrefix(path.Clean(r.URL.Path), "/")
|
||||
if name == "" || name == "." {
|
||||
return h.serveIndex(e)
|
||||
h.serveIndex(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
if info, err := fs.Stat(h.dist, name); err == nil && !info.IsDir() {
|
||||
e.Set(journalOutcomeKey, OutcomeAsset)
|
||||
h.setCacheHeader(e, name)
|
||||
return e.FileFS(h.dist, name)
|
||||
noteWebappOutcome(r, OutcomeAsset)
|
||||
h.setCacheHeader(w, name)
|
||||
http.ServeFileFS(w, r, h.dist, name)
|
||||
return
|
||||
}
|
||||
|
||||
// Разметка прежней сборки называет ресурсы прежней сборки. Подменить их
|
||||
@@ -290,33 +136,29 @@ func (h *WebappHandler) Serve(e *core.RequestEvent) error {
|
||||
// типу содержимого, человек увидит пустой экран, а в кодах ответов сервиса
|
||||
// не останется ничего.
|
||||
if underAssets(name) {
|
||||
e.Set(journalOutcomeKey, OutcomeFailure)
|
||||
return router.NewNotFoundError("", nil)
|
||||
noteWebappOutcome(r, OutcomeFailure)
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
return h.serveIndex(e)
|
||||
h.serveIndex(w, r)
|
||||
}
|
||||
|
||||
func (h *WebappHandler) serveIndex(e *core.RequestEvent) error {
|
||||
e.Set(journalOutcomeKey, OutcomeMarkup)
|
||||
h.setCacheHeader(e, "index.html")
|
||||
func (h *WebappHandler) serveIndex(w http.ResponseWriter, r *http.Request) {
|
||||
noteWebappOutcome(r, OutcomeMarkup)
|
||||
h.setCacheHeader(w, "index.html")
|
||||
|
||||
// Отпечаток идёт метке ответа: `no-cache` обещает дешёвую проверку —
|
||||
// «спроси заново и получи подтверждение», — а вшитый файл не несёт времени
|
||||
// правки вовсе, и без метки браузеру каждый раз отдаётся полное тело вместо
|
||||
// подтверждения. Отпечаток уже посчитан при подъёме, второго счёта не надо.
|
||||
// подтверждения.
|
||||
if h.fingerprint != "" {
|
||||
e.Response.Header().Set("ETag", `"`+h.fingerprint+`"`)
|
||||
w.Header().Set("ETag", `"`+h.fingerprint+`"`)
|
||||
}
|
||||
|
||||
return e.FileFS(h.dist, "index.html")
|
||||
http.ServeFileFS(w, r, h.dist, "index.html")
|
||||
}
|
||||
|
||||
// setCacheHeader назначает срок хранения по каталогу, а не по виду файла.
|
||||
//
|
||||
// Вид файла признака не даёт: в сборке лежат и файлы с постоянными именами —
|
||||
// иконка, манифест, — и такой файл, однажды отданный как неизменяемый, не
|
||||
// отзывается со стороны сервиса ничем. Запросов он больше не увидит.
|
||||
// underAssets говорит, ведёт ли путь в каталог сборщика.
|
||||
//
|
||||
// Условий два, и оба обязательны — та же пара, что у принадлежности корню: сам
|
||||
@@ -327,14 +169,19 @@ func underAssets(name string) bool {
|
||||
return name == assetsDir || strings.HasPrefix(name, assetsDir+"/")
|
||||
}
|
||||
|
||||
func (h *WebappHandler) setCacheHeader(e *core.RequestEvent, name string) {
|
||||
// setCacheHeader назначает срок хранения по каталогу, а не по виду файла.
|
||||
//
|
||||
// Вид файла признака не даёт: в сборке лежат и файлы с постоянными именами —
|
||||
// иконка, манифест, — и такой файл, однажды отданный как неизменяемый, не
|
||||
// отзывается со стороны сервиса ничем.
|
||||
func (h *WebappHandler) setCacheHeader(w http.ResponseWriter, name string) {
|
||||
if underAssets(name) {
|
||||
e.Response.Header().Set(
|
||||
w.Header().Set(
|
||||
"Cache-Control",
|
||||
"public, max-age="+strconv.Itoa(assetMaxAgeSeconds)+", immutable",
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
e.Response.Header().Set("Cache-Control", "no-cache")
|
||||
w.Header().Set("Cache-Control", "no-cache")
|
||||
}
|
||||
|
||||
@@ -2,88 +2,25 @@ package http
|
||||
|
||||
import (
|
||||
"io/fs"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
"testing/fstest"
|
||||
"time"
|
||||
|
||||
"github.com/pocketbase/pocketbase/apis"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"git.vakhrushev.me/av/transcriber/internal/adapter/recognizer"
|
||||
pbrepo "git.vakhrushev.me/av/transcriber/internal/adapter/repo/pocketbase"
|
||||
"git.vakhrushev.me/av/transcriber/internal/entity"
|
||||
"git.vakhrushev.me/av/transcriber/internal/service"
|
||||
)
|
||||
|
||||
const testMarkup = `<!doctype html><html lang="ru"><body>приложение</body></html>`
|
||||
|
||||
// builtDist — собранное приложение, каким его видит раздача.
|
||||
func builtDist() fs.FS {
|
||||
return fstest.MapFS{
|
||||
"index.html": {Data: []byte(testMarkup)},
|
||||
"assets/index-abc123.js": {Data: []byte("console.log(1)")},
|
||||
"favicon.ico": {Data: []byte("значок")},
|
||||
}
|
||||
}
|
||||
|
||||
// webappEnv — окружение проверок раздачи. Роутер собирается тем же способом,
|
||||
// что и боевой: маршруты порождает перечень, а не перечисление в проверке.
|
||||
// webappEnv — окружение проверок раздачи. Поверхность собирается тем же
|
||||
// способом, что и боевая: маршруты порождает перечень адресного пространства, а
|
||||
// не перечисление в проверке.
|
||||
type webappEnv struct {
|
||||
mux http.Handler
|
||||
journal *journalBuffer
|
||||
login string
|
||||
env *testEnv
|
||||
}
|
||||
|
||||
func setupWebappEnv(t *testing.T, dist fs.FS, built bool) *webappEnv {
|
||||
t.Helper()
|
||||
|
||||
app := newTestStorage(t)
|
||||
pbrepo.BindPanelRules(app)
|
||||
|
||||
recordRepo := pbrepo.NewAudioRecordRepository(app)
|
||||
textRepo := pbrepo.NewTextRepository(app)
|
||||
structureRepo := pbrepo.NewStructureRepository(app)
|
||||
|
||||
journal := &journalBuffer{}
|
||||
logger := slog.New(slog.NewTextHandler(journal, nil))
|
||||
|
||||
trsService := service.NewTranscribeService(
|
||||
service.Repositories{
|
||||
Records: recordRepo,
|
||||
Files: pbrepo.NewFileRepository(app),
|
||||
Texts: textRepo,
|
||||
Structures: structureRepo,
|
||||
Recognitions: pbrepo.NewRecognitionRepository(app),
|
||||
Events: pbrepo.NewRecordEventRepository(app),
|
||||
},
|
||||
readableMetaViewer(),
|
||||
&stubConverter{},
|
||||
&recognizer.MemoryAudioRecognizer{},
|
||||
entity.StuckLimits{Own: time.Hour, Foreign: 24 * time.Hour},
|
||||
logger,
|
||||
)
|
||||
|
||||
appHandler := NewAppHandler(recordRepo, textRepo, structureRepo, trsService, logger)
|
||||
|
||||
mounts := ServiceMounts(appHandler, http.NotFoundHandler())
|
||||
|
||||
r, err := apis.NewRouter(app)
|
||||
require.NoError(t, err)
|
||||
|
||||
r.Bind(TrustedHeaderIdentity(app, mounts, testTrustedNetworks(t), logger))
|
||||
RegisterServiceRoutes(r, mounts)
|
||||
NewWebappHandler(dist, built, mounts, logger).Register(r)
|
||||
|
||||
mux, err := r.BuildMux()
|
||||
require.NoError(t, err)
|
||||
|
||||
_, login := newTestAccount(t, app)
|
||||
|
||||
return &webappEnv{mux: mux, journal: journal, login: login}
|
||||
return &webappEnv{env: setupEnv(t, envOptions{dist: dist, built: built})}
|
||||
}
|
||||
|
||||
func (e *webappEnv) get(path string) *httptest.ResponseRecorder {
|
||||
@@ -93,15 +30,20 @@ func (e *webappEnv) get(path string) *httptest.ResponseRecorder {
|
||||
func (e *webappEnv) do(method, path string, identified bool) *httptest.ResponseRecorder {
|
||||
req := httptest.NewRequest(method, path, nil)
|
||||
if identified {
|
||||
asUser(req, e.login)
|
||||
asUser(req, e.env.login)
|
||||
}
|
||||
|
||||
rec := httptest.NewRecorder()
|
||||
e.mux.ServeHTTP(rec, req)
|
||||
e.env.mux.ServeHTTP(rec, req)
|
||||
|
||||
return rec
|
||||
}
|
||||
|
||||
// journal отдаёт перехваченный журнал окружения.
|
||||
func (e *webappEnv) journal() string {
|
||||
return e.env.journal.String()
|
||||
}
|
||||
|
||||
// Обновление страницы посреди приложения открывает тот же экран: адреса у
|
||||
// приложения обычные, а не после решётки, и сервер обязан отдать разметку.
|
||||
func TestWebappServesMarkupOutsideServiceRoots(t *testing.T) {
|
||||
@@ -117,16 +59,33 @@ func TestWebappServesMarkupOutsideServiceRoots(t *testing.T) {
|
||||
|
||||
// Путь внутри корня сервиса в приложение не проваливается никогда: иначе
|
||||
// программа, ошибшаяся адресом, приняла бы разметку с кодом 200 за ответ.
|
||||
//
|
||||
// Корень у сервиса остался один — корень приложения; `/api` и `/_` ушли вместе
|
||||
// со встроенным хранилищем, и пути под ними стали обычными путями вне корней.
|
||||
func TestWebappNeverAnswersInsideServiceRoots(t *testing.T) {
|
||||
env := setupWebappEnv(t, builtDist(), true)
|
||||
|
||||
for _, path := range []string{"/api/nope", "/_/nope"} {
|
||||
for _, path := range []string{"/app/nope", "/app"} {
|
||||
res := env.get(path)
|
||||
|
||||
assert.NotContains(t, res.Body.String(), "приложение", path)
|
||||
}
|
||||
}
|
||||
|
||||
// Прежние корни хранилища и панели отвечают разметкой — тем же, чем отвечает
|
||||
// всякий путь вне корней. Знак, записанный своим кодом, попадает в то же
|
||||
// правило: правило одно, и особого случая у него нет.
|
||||
func TestWebappServesFormerStorageAndPanelPaths(t *testing.T) {
|
||||
env := setupWebappEnv(t, builtDist(), true)
|
||||
|
||||
for _, path := range []string{"/api/nope", "/_/", "/%5f/"} {
|
||||
res := env.get(path)
|
||||
|
||||
assert.Equal(t, http.StatusOK, res.Code, path)
|
||||
assert.Contains(t, res.Body.String(), "приложение", path)
|
||||
}
|
||||
}
|
||||
|
||||
// Под корнем приложения отказ уходит его собственной формой, и код отвечает
|
||||
// причине: без сессии — «предъяви себя», с сессией — «такого адреса нет».
|
||||
func TestWebappLeavesAppRootToItsOwnFailureForm(t *testing.T) {
|
||||
@@ -147,8 +106,14 @@ func TestWebappLeavesAppRootToItsOwnFailureForm(t *testing.T) {
|
||||
func TestWebappRootMatchNeedsExactOrSlash(t *testing.T) {
|
||||
env := setupWebappEnv(t, builtDist(), true)
|
||||
|
||||
bare := env.get(StorageRoot)
|
||||
assert.NotContains(t, bare.Body.String(), "приложение", "голый корень разметкой не подменяется")
|
||||
bare := env.do(http.MethodGet, AppRoot, true)
|
||||
assert.Equal(t, http.StatusNotFound, bare.Code, "голый корень разметкой не подменяется")
|
||||
assert.NotContains(t, bare.Body.String(), "приложение")
|
||||
|
||||
bareAnonymous := env.get(AppRoot)
|
||||
assert.Equal(t, http.StatusUnauthorized, bareAnonymous.Code,
|
||||
"голый корень неузнанному отвечает как прочие адреса под ним")
|
||||
assert.NotContains(t, bareAnonymous.Body.String(), "приложение")
|
||||
|
||||
neighbour := env.get(AppRoot + "le")
|
||||
assert.Equal(t, http.StatusOK, neighbour.Code)
|
||||
@@ -245,5 +210,5 @@ func TestWebappNotBuilt(t *testing.T) {
|
||||
|
||||
// Владелец сервиса узнаёт об этом журналом подъёма, а не от человека,
|
||||
// открывшего страницу.
|
||||
assert.Contains(t, env.journal.String(), "Webapp is not built")
|
||||
assert.Contains(t, env.journal(), "Webapp is not built")
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user