- пришедшего называет заголовок Remote-User от прокси, и верят ему только с адреса из перечня trusted_proxies; своего входа у сервиса не осталось — ни корня /auth, ни кук, ни срока сессии, ни секрета клиента в конфиге и в базе - учётная запись заводится первым обращением: EnsureUser в пакете хранилища, шаг схемы 202608220001 с колонкой provider_login и снятыми правилами users - cmd/oidcstub заменён на cmd/devtools с подкомандой proxy; заодно закрыт унаследованный DL3066 — пользователь образа назван числом
828 lines
35 KiB
Go
828 lines
35 KiB
Go
package http
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"log/slog"
|
|
"mime/multipart"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"net/netip"
|
|
"regexp"
|
|
"strings"
|
|
"sync"
|
|
"testing"
|
|
"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"
|
|
"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/service"
|
|
)
|
|
|
|
// Подставные адаптеры вместо ffprobe и ffmpeg. Проверки судят приём — что
|
|
// запись сохранена, задача заведена и ответ такой, какой обещан, — а не
|
|
// способность внешней программы разобрать звук. Внешних программ здесь нет
|
|
// ни одной, и видно это по списку импортов.
|
|
|
|
// stubMetaViewer отдаёт заданную длительность либо заданную ошибку.
|
|
type stubMetaViewer struct {
|
|
seconds int
|
|
err error
|
|
}
|
|
|
|
func (m *stubMetaViewer) GetInfo(ctx context.Context, _ string) (*contract.AudioInfo, error) {
|
|
// Настоящий `ffprobe` заведён с контекстом и по отмене умирает; стаб,
|
|
// который контекст игнорирует, сделал бы проверку приёма неспособной упасть.
|
|
if err := ctx.Err(); err != nil {
|
|
return nil, err
|
|
}
|
|
if m.err != nil {
|
|
return nil, m.err
|
|
}
|
|
return &contract.AudioInfo{Seconds: m.seconds}, nil
|
|
}
|
|
|
|
// stubConverter молчалив: приём конвертацию не делает, и ни одна проверка
|
|
// этого файла её не зовёт.
|
|
type stubConverter struct{}
|
|
|
|
func (c *stubConverter) Convert(context.Context, string, string) error { return nil }
|
|
|
|
// readableMetaViewer — источник метаданных, который читает любую запись.
|
|
func readableMetaViewer() *stubMetaViewer {
|
|
return &stubMetaViewer{seconds: 42}
|
|
}
|
|
|
|
// testEnv — собранное окружение одной проверки. Каталог данных свой у каждой:
|
|
// рабочий каталог процесса проверки не трогают.
|
|
type testEnv struct {
|
|
mux http.Handler
|
|
handler *AppHandler
|
|
app core.App
|
|
journal *journalBuffer
|
|
// login — логин у провайдера, которым доверенный источник называет
|
|
// пришедшего. Адреса приложения закрыты узнаванием, и проверка, судящая их
|
|
// по существу, обязана назваться ровно так же, как это делает прокси.
|
|
login string
|
|
// account — учётная запись, которой принадлежит этот логин.
|
|
account *core.Record
|
|
}
|
|
|
|
// Доверенный источник проверок.
|
|
//
|
|
// Подсеть и адрес пира разведены намеренно: проверка недоверенного источника
|
|
// берёт адрес **вне** подсети, и обе стороны условия видны рядом.
|
|
const (
|
|
trustedPeer = "10.1.2.3:34567"
|
|
untrustedPeer = "203.0.113.9:34567"
|
|
)
|
|
|
|
func testTrustedNetworks(t *testing.T) []netip.Prefix {
|
|
t.Helper()
|
|
|
|
prefix, err := netip.ParsePrefix("10.0.0.0/8")
|
|
require.NoError(t, err)
|
|
|
|
return []netip.Prefix{prefix}
|
|
}
|
|
|
|
// asUser делает запрос запросом названного человека: ставит заголовок и адрес
|
|
// пира из доверенной подсети — ровно то, что делает обратный прокси.
|
|
func asUser(req *http.Request, login string) *http.Request {
|
|
req.Header.Set(LoginHeader, login)
|
|
req.RemoteAddr = trustedPeer
|
|
return req
|
|
}
|
|
|
|
// serve шлёт запрос от имени узнанного. Проверки, судящие отказ неузнанному,
|
|
// зовут `mux` напрямую: запрос без заголовка и есть запрос неузнанного.
|
|
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()
|
|
|
|
const login = "person"
|
|
|
|
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
|
|
}
|
|
|
|
// journalBuffer — перехваченный журнал одной проверки. Свой на случай: общий на
|
|
// пакет сделал бы исход функцией от соседних случаев — «поля на месте» прошло бы
|
|
// на чужой строке, а «маркера нет» покраснело бы от чужой. Замок нужен потому,
|
|
// что пишущих в него потоков два: логгер сервиса и логгер обработчика.
|
|
type journalBuffer struct {
|
|
mu sync.Mutex
|
|
text strings.Builder
|
|
}
|
|
|
|
func (b *journalBuffer) Write(p []byte) (int, error) {
|
|
b.mu.Lock()
|
|
defer b.mu.Unlock()
|
|
return b.text.Write(p)
|
|
}
|
|
|
|
// String отдаёт весь перехваченный текст. Проверки ищут в нём значение, а не имя
|
|
// поля: имя, вернувшееся под другим ключом, поиск по ключу не разбудил бы.
|
|
func (b *journalBuffer) String() string {
|
|
b.mu.Lock()
|
|
defer b.mu.Unlock()
|
|
return b.text.String()
|
|
}
|
|
|
|
// newTestStorage поднимает хранилище на пустом каталоге и накатывает схему —
|
|
// ровно тем же путём, каким это делает сервис при старте.
|
|
func newTestStorage(t *testing.T) core.App {
|
|
t.Helper()
|
|
|
|
app, err := pbrepo.New(t.TempDir())
|
|
require.NoError(t, err)
|
|
t.Cleanup(func() {
|
|
if err := app.ResetBootstrapState(); err != nil {
|
|
t.Logf("не удалось закрыть хранилище: %v", err)
|
|
}
|
|
})
|
|
|
|
return app
|
|
}
|
|
|
|
func setupTestEnv(t *testing.T, metaviewer contract.AudioMetaViewer) *testEnv {
|
|
app := newTestStorage(t)
|
|
|
|
pbrepo.BindPanelRules(app)
|
|
|
|
recordRepo := pbrepo.NewAudioRecordRepository(app)
|
|
textRepo := pbrepo.NewTextRepository(app)
|
|
repos := service.Repositories{
|
|
Records: recordRepo,
|
|
Files: pbrepo.NewFileRepository(app),
|
|
Texts: textRepo,
|
|
Structures: pbrepo.NewStructureRepository(app),
|
|
Recognitions: pbrepo.NewRecognitionRepository(app),
|
|
Events: pbrepo.NewRecordEventRepository(app),
|
|
}
|
|
|
|
// Журнал уходит в буфер, а не в никуда: по нему судит проверка запрета на
|
|
// имя отправителя. Вывод прогона от этого не меняется — ERROR-строки ветки
|
|
// отказа по-прежнему не попадают на экран и не размывают признак, по
|
|
// которому отличают новый красный шаг гейта от объявленного долга.
|
|
journal := &journalBuffer{}
|
|
logger := slog.New(slog.NewTextHandler(journal, nil))
|
|
|
|
trsService := service.NewTranscribeService(
|
|
repos,
|
|
metaviewer,
|
|
&stubConverter{},
|
|
&recognizer.MemoryAudioRecognizer{},
|
|
entity.StuckLimits{Own: time.Hour, Foreign: 24 * time.Hour},
|
|
logger,
|
|
)
|
|
|
|
handler := NewAppHandler(recordRepo, textRepo, pbrepo.NewStructureRepository(app), trsService, logger)
|
|
|
|
// Роутер собирается тем же способом, что и боевой: слой узнавания вешается
|
|
// корневым, маршруты вешает сам обработчик, и проверка судит ту же цепочку,
|
|
// что и прод. Собери роутер иначе — и проверка судила бы не то.
|
|
r, err := apis.NewRouter(app)
|
|
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,
|
|
journal: journal,
|
|
login: login,
|
|
account: account,
|
|
}
|
|
}
|
|
|
|
// createMultipartRequest собирает запрос из имени и содержимого. Файла на диске
|
|
// для этого не нужно: имя проверяет выбор расширения, содержимое — сохранение.
|
|
func createMultipartRequest(t *testing.T, fileName string, content []byte) *http.Request {
|
|
return createMultipartRequestWithField(t, "audio", fileName, content)
|
|
}
|
|
|
|
// createMultipartRequestWithField кладёт запись в поле с заданным именем —
|
|
// нужно, чтобы построить форму без поля `audio`.
|
|
func createMultipartRequestWithField(t *testing.T, field, fileName string, content []byte) *http.Request {
|
|
return createMultipartRequestAtWithField(t, "/app/audiorecords", field, fileName, content)
|
|
}
|
|
|
|
// createMultipartRequestAt собирает тот же запрос по названному адресу — нужно
|
|
// проверке, судящей убранные адреса.
|
|
func createMultipartRequestAt(t *testing.T, path, fileName string, content []byte) *http.Request {
|
|
return createMultipartRequestAtWithField(t, path, "audio", fileName, content)
|
|
}
|
|
|
|
func createMultipartRequestAtWithField(t *testing.T, path, field, fileName string, content []byte) *http.Request {
|
|
var buf bytes.Buffer
|
|
writer := multipart.NewWriter(&buf)
|
|
|
|
part, err := writer.CreateFormFile(field, fileName)
|
|
require.NoError(t, err)
|
|
|
|
_, err = part.Write(content)
|
|
require.NoError(t, err)
|
|
|
|
err = writer.Close()
|
|
require.NoError(t, err)
|
|
|
|
req := httptest.NewRequest("POST", path, &buf)
|
|
req.Header.Set("Content-Type", writer.FormDataContentType())
|
|
|
|
return req
|
|
}
|
|
|
|
// intakeItemOf разбирает ответ приёма и отдаёт единственный его элемент. Ответ
|
|
// списком всегда, даже на один файл: форма согласована один раз и вперёд.
|
|
func intakeItemOf(t *testing.T, w *httptest.ResponseRecorder) IntakeItem {
|
|
t.Helper()
|
|
|
|
var items []IntakeItem
|
|
require.NoError(t, json.Unmarshal(w.Body.Bytes(), &items))
|
|
require.Len(t, items, 1)
|
|
return items[0]
|
|
}
|
|
|
|
// storedFileNames отдаёт имена, под которыми файлы легли в хранилище.
|
|
func storedFileNames(t *testing.T, env *testEnv) []string {
|
|
records, err := env.app.FindAllRecords(migrations.FilesCollection)
|
|
require.NoError(t, err)
|
|
|
|
var names []string
|
|
for _, record := range records {
|
|
names = append(names, record.GetStringSlice("file")...)
|
|
}
|
|
return names
|
|
}
|
|
|
|
// countFiles считает записи о файлах.
|
|
func countFiles(t *testing.T, env *testEnv) int {
|
|
records, err := env.app.FindAllRecords(migrations.FilesCollection)
|
|
require.NoError(t, err)
|
|
return len(records)
|
|
}
|
|
|
|
// countJobs считает заведённые аудиозаписи.
|
|
func countJobs(t *testing.T, env *testEnv) int {
|
|
records, err := env.app.FindAllRecords(migrations.RecordsCollection)
|
|
require.NoError(t, err)
|
|
return len(records)
|
|
}
|
|
|
|
// jobWithFile заводит задачу вместе с её записью: ссылка на файл обязательна
|
|
// схемой, потому что без неё задача не пройдёт ни одного шага.
|
|
func jobWithFile(t *testing.T, env *testEnv) *entity.AudioRecord {
|
|
t.Helper()
|
|
|
|
repo := pbrepo.NewFileRepository(env.app)
|
|
work, err := repo.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)
|
|
require.NoError(t, err)
|
|
|
|
record := &entity.AudioRecord{
|
|
State: entity.StateUploaded,
|
|
StateEnteredAt: clock.Now(),
|
|
Source: entity.SourceApi,
|
|
OwnerID: env.account.Id,
|
|
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)
|
|
require.NoError(t, err)
|
|
defer reader.Close()
|
|
|
|
var buf bytes.Buffer
|
|
_, err = buf.ReadFrom(reader)
|
|
require.NoError(t, err)
|
|
return buf.Bytes()
|
|
}
|
|
|
|
func TestCreateTranscribeJob_Success(t *testing.T) {
|
|
env := setupTestEnv(t, readableMetaViewer())
|
|
|
|
content := []byte("содержимое записи, которое обязано доехать до диска целиком")
|
|
req := createMultipartRequest(t, "sample.m4a", content)
|
|
|
|
w := httptest.NewRecorder()
|
|
env.serve(w, req)
|
|
|
|
require.Equal(t, http.StatusCreated, w.Code)
|
|
|
|
// Имена полей ответа нормативны: контракт объявлен необратимым, а экраны
|
|
// строятся на этих именах. Судим по сырому JSON — разбор в структуру
|
|
// переименовал бы тег вместе с ожиданием, и проверка не смогла бы упасть.
|
|
//
|
|
// Ответ — **список**, даже когда файл в запросе один: форма согласована
|
|
// вперёд, чтобы приём нескольких файлов и распознавание повтора её не
|
|
// переписывали.
|
|
var rawItems []map[string]json.RawMessage
|
|
err := json.Unmarshal(w.Body.Bytes(), &rawItems)
|
|
require.NoError(t, err)
|
|
require.Len(t, rawItems, 1)
|
|
assert.Contains(t, rawItems[0], "id")
|
|
assert.Contains(t, rawItems[0], "state")
|
|
assert.Contains(t, rawItems[0], "duplicate", "место под признак повтора заведено вперёд")
|
|
assert.NotContains(t, rawItems[0], "job_id", "прежние имена полей убраны вместе с опросом")
|
|
|
|
response := intakeItemOf(t, w)
|
|
|
|
assert.NotEmpty(t, response.ID)
|
|
assert.Equal(t, entity.StateUploaded, response.State)
|
|
|
|
// Задача действительно заведена, а не только названа в ответе: иначе
|
|
// отправитель получит идентификатор записи, которой не будет никогда.
|
|
require.Equal(t, 1, countJobs(t, env))
|
|
|
|
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)
|
|
assert.NotEmpty(t, *job.OriginalFileID)
|
|
|
|
// Содержимое лежит в хранилище одним файлом и целиком.
|
|
require.Equal(t, 1, countFiles(t, env))
|
|
assert.Equal(t, content, storedContent(t, env, *job.OriginalFileID))
|
|
}
|
|
|
|
func TestCreateTranscribeJob_NoFile(t *testing.T) {
|
|
// Две ветки одного сценария: тела нет вовсе и форма есть, а поля в ней нет.
|
|
// Вторая — та, что описана требованием; первая ходит тем же путём.
|
|
testCases := []struct {
|
|
name string
|
|
req func(t *testing.T) *http.Request
|
|
}{
|
|
{
|
|
name: "no body at all",
|
|
req: func(t *testing.T) *http.Request {
|
|
// Запрос строится так, как его видит сервер: у пришедшего по
|
|
// проводу тело не бывает пустым указателем.
|
|
return httptest.NewRequest("POST", "/app/audiorecords", http.NoBody)
|
|
},
|
|
},
|
|
{
|
|
name: "form without audio field",
|
|
req: func(t *testing.T) *http.Request {
|
|
return createMultipartRequestWithField(t, "attachment", "sample.m4a", []byte("запись"))
|
|
},
|
|
},
|
|
}
|
|
|
|
for _, tc := range testCases {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
env := setupTestEnv(t, readableMetaViewer())
|
|
|
|
w := httptest.NewRecorder()
|
|
env.serve(w, tc.req(t))
|
|
|
|
require.Equal(t, http.StatusBadRequest, w.Code)
|
|
|
|
var response ErrorBody
|
|
err := json.Unmarshal(w.Body.Bytes(), &response)
|
|
require.NoError(t, err)
|
|
|
|
assert.Equal(t, CodeBadRequest, response.Code)
|
|
assert.NotEmpty(t, response.Message, "рядом с кодом стоит фраза для человека")
|
|
assert.Equal(t, 0, countFiles(t, env))
|
|
assert.Equal(t, 0, countJobs(t, env))
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestCreateTranscribeJob_EmptyFile(t *testing.T) {
|
|
env := setupTestEnv(t, readableMetaViewer())
|
|
|
|
// Собственного порога по размеру у приёма нет: годность записи судит
|
|
// источник метаданных, а не приём.
|
|
req := createMultipartRequest(t, "empty.m4a", nil)
|
|
|
|
w := httptest.NewRecorder()
|
|
env.serve(w, req)
|
|
|
|
require.Equal(t, http.StatusCreated, w.Code)
|
|
|
|
response := intakeItemOf(t, w)
|
|
|
|
assert.NotEmpty(t, response.ID)
|
|
assert.Equal(t, entity.StateUploaded, response.State)
|
|
}
|
|
|
|
func TestCreateTranscribeJob_DifferentFileExtensions(t *testing.T) {
|
|
testCases := []struct {
|
|
name string
|
|
fileName string
|
|
expectExt string
|
|
}{
|
|
{
|
|
name: "m4a file",
|
|
fileName: "test.m4a",
|
|
expectExt: ".m4a",
|
|
},
|
|
{
|
|
name: "mp3 file",
|
|
fileName: "test.mp3",
|
|
expectExt: ".mp3",
|
|
},
|
|
{
|
|
name: "wav file",
|
|
fileName: "test.wav",
|
|
expectExt: ".wav",
|
|
},
|
|
{
|
|
name: "file without extension",
|
|
fileName: "test",
|
|
expectExt: ".audio",
|
|
},
|
|
}
|
|
|
|
for _, tc := range testCases {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
env := setupTestEnv(t, readableMetaViewer())
|
|
|
|
req := createMultipartRequest(t, tc.fileName, []byte("запись"))
|
|
|
|
w := httptest.NewRecorder()
|
|
env.serve(w, req)
|
|
|
|
require.Equal(t, http.StatusCreated, w.Code)
|
|
|
|
names := storedFileNames(t, env)
|
|
require.Len(t, names, 1)
|
|
|
|
// Имя отправителя в хранилище не попадает: имя файла — свой
|
|
// идентификатор, от отправителя взято только расширение. Суффикс
|
|
// дописывает само хранилище, поэтому сверяем хвост, а не Ext.
|
|
assert.True(t, strings.HasSuffix(names[0], tc.expectExt),
|
|
"имя в хранилище %q оканчивается на %q", names[0], tc.expectExt)
|
|
assert.NotContains(t, names[0], strings.TrimSuffix(tc.fileName, tc.expectExt))
|
|
})
|
|
}
|
|
}
|
|
|
|
// Имя, данное отправителем, в хранилище не попадает целиком — умолчание
|
|
// библиотеки, строящее имя файла из него, не применяется. Проверка отдельная от
|
|
// перебора расширений: там сверяется хвост, здесь — что основы имени нет.
|
|
func TestCreateTranscribeJob_SenderFileNameNotStored(t *testing.T) {
|
|
env := setupTestEnv(t, readableMetaViewer())
|
|
|
|
req := createMultipartRequest(t, "секретное-слово.mp3", []byte("запись"))
|
|
|
|
w := httptest.NewRecorder()
|
|
env.serve(w, req)
|
|
|
|
require.Equal(t, http.StatusCreated, w.Code)
|
|
|
|
names := storedFileNames(t, env)
|
|
require.Len(t, names, 1)
|
|
|
|
assert.NotContains(t, names[0], "секретное-слово",
|
|
"имя, данное отправителем, в хранилище не попадает")
|
|
assert.True(t, strings.HasSuffix(names[0], ".mp3"),
|
|
"расширение при этом сохраняется: %q", names[0])
|
|
}
|
|
|
|
func TestCreateTranscribeJob_MetaViewerFailure(t *testing.T) {
|
|
env := setupTestEnv(t, &stubMetaViewer{err: errors.New("не удалось прочитать запись")})
|
|
|
|
req := createMultipartRequest(t, "broken.m4a", []byte("не запись вовсе"))
|
|
|
|
w := httptest.NewRecorder()
|
|
env.serve(w, req)
|
|
|
|
// Негодная запись — отказ по причине, а не по месту: прежде здесь стоял
|
|
// `500`, и «файл не читается» приходило человеку как «сломался сервер».
|
|
require.Equal(t, http.StatusBadRequest, w.Code)
|
|
|
|
var response ErrorBody
|
|
err := json.Unmarshal(w.Body.Bytes(), &response)
|
|
require.NoError(t, err)
|
|
|
|
// Причина отказа принадлежит журналу, а не отправителю.
|
|
assert.Equal(t, CodeBadRequest, response.Code)
|
|
assert.NotEmpty(t, response.Message)
|
|
assert.NotContains(t, w.Body.String(), "не удалось прочитать запись")
|
|
assert.NotContains(t, w.Body.String(), "transcibe", "опечатка ушла вместе с прежним текстом")
|
|
|
|
assert.Equal(t, 0, countJobs(t, env))
|
|
}
|
|
|
|
// senderNameMarker — метка внутри имени, которое даёт отправитель. ASCII и
|
|
// заведомо уникальна: в остальном выводе прогона такой строки нет, поэтому
|
|
// находка означает утечку, а не совпадение. Ищется она **значением**, а не
|
|
// именем журнального поля: имя, вернувшееся под другим ключом, поиск по ключу
|
|
// пропустил бы.
|
|
const senderNameMarker = "SENDERNAMELEAKMARKER7Q2"
|
|
|
|
// Тексты, по которым проверки находят журнальные строки. Первый — записанный
|
|
// долг `docs/conventions/logging.md`: `msg` обязан стать короткой категорией.
|
|
// Когда долг закроют, правка будет здесь и одна.
|
|
const (
|
|
msgIntake = "Creating audio record"
|
|
// Отказ пишет доменная граница — приём, — а не транспорт: конвенция просит
|
|
// логировать ошибку один раз, и повторная запись транспорта снята.
|
|
msgIntakeErr = "Failed to get file info"
|
|
)
|
|
|
|
func TestCreateTranscribeJob_SenderFileNameNotLogged(t *testing.T) {
|
|
env := setupTestEnv(t, readableMetaViewer())
|
|
|
|
// Метка стоит в основе имени, а расширение обычное: расширение запретом не
|
|
// накрыто и в журнале остаётся законно.
|
|
req := createMultipartRequest(t, senderNameMarker+".mp3", []byte("запись"))
|
|
|
|
w := httptest.NewRecorder()
|
|
env.serve(w, req)
|
|
|
|
require.Equal(t, http.StatusCreated, w.Code)
|
|
|
|
journal := env.journal.String()
|
|
|
|
// Сперва — что журнал приёма вообще перехвачен. Без этого утверждения
|
|
// пустой буфер сделал бы проверку запрета зелёной, ничего не прочитав.
|
|
require.Contains(t, journal, msgIntake,
|
|
"строка приёма попадает в перехваченный журнал")
|
|
|
|
assert.NotContains(t, journal, senderNameMarker,
|
|
"имя, данное отправителем, не пишется в журнал: инвариант приватности")
|
|
}
|
|
|
|
func TestCreateTranscribeJob_SenderFileNameNotLoggedOnFailure(t *testing.T) {
|
|
// Отказ — тот путь, где имя приехало бы в журнал текстом ошибки: приём
|
|
// назван конвенцией логирующей границей, и цепочка `%w` осядет полем error.
|
|
env := setupTestEnv(t, &stubMetaViewer{err: errors.New("не удалось прочитать запись")})
|
|
|
|
req := createMultipartRequest(t, senderNameMarker+".mp3", []byte("не запись вовсе"))
|
|
|
|
w := httptest.NewRecorder()
|
|
env.serve(w, req)
|
|
|
|
require.Equal(t, http.StatusBadRequest, w.Code)
|
|
|
|
journal := env.journal.String()
|
|
|
|
// Сперва — что журнал ветки отказа вообще перехвачен: обработчик пишет свою
|
|
// строку, и без неё оракул молча сузился бы вдвое.
|
|
require.Contains(t, journal, msgIntakeErr,
|
|
"строка приёма об отказе попадает в перехваченный журнал")
|
|
|
|
assert.NotContains(t, journal, senderNameMarker,
|
|
"имя отправителя не пишется в журнал и на пути отказа")
|
|
}
|
|
|
|
// Имя, под которым файл лёг в хранилище, — это последняя часть ссылки
|
|
// `/api/files/...`, по которой запись скачивают. Попав в журнал, строка стала бы
|
|
// бессрочным ключом к чужому аудио, поэтому в журнал идёт имя, заданное
|
|
// сервисом, а суффикс хранилища — нет.
|
|
func TestCreateTranscribeJob_StorageFileNameNotLogged(t *testing.T) {
|
|
env := setupTestEnv(t, readableMetaViewer())
|
|
|
|
req := createMultipartRequest(t, "sample.mp3", []byte("запись"))
|
|
|
|
w := httptest.NewRecorder()
|
|
env.serve(w, req)
|
|
|
|
require.Equal(t, http.StatusCreated, w.Code)
|
|
|
|
names := storedFileNames(t, env)
|
|
require.Len(t, names, 1)
|
|
|
|
journal := env.journal.String()
|
|
require.Contains(t, journal, msgIntake, "журнал приёма перехвачен")
|
|
|
|
assert.NotContains(t, journal, names[0],
|
|
"имени файла в хранилище в журнале нет: по нему собирается ссылка на скачивание")
|
|
}
|
|
|
|
func TestCreateTranscribeJob_JournalTracesRecord(t *testing.T) {
|
|
env := setupTestEnv(t, readableMetaViewer())
|
|
|
|
content := []byte("содержимое записи")
|
|
req := createMultipartRequest(t, "sample.mp3", content)
|
|
|
|
w := httptest.NewRecorder()
|
|
env.serve(w, req)
|
|
|
|
require.Equal(t, http.StatusCreated, w.Code)
|
|
|
|
response := intakeItemOf(t, w)
|
|
|
|
job, err := env.handler.recordRepo.GetByID(response.ID, env.account.Id)
|
|
require.NoError(t, err)
|
|
require.NotNil(t, job.OriginalFileID)
|
|
|
|
// Отбор по идентификатору **этого** прогона: иначе утверждение прошло бы по
|
|
// строке, оставленной соседней проверкой, и прослеживаемость числилась бы
|
|
// сохранённой при пустом журнале.
|
|
journal := env.journal.String()
|
|
assert.Contains(t, journal, *job.OriginalFileID, "по журналу видно, какой файл заведён")
|
|
assert.Contains(t, journal, ".mp3", "расширение принятой записи в журнале остаётся")
|
|
|
|
// Разделитель ключа и значения задаёт обработчик: сегодня текстовый, по
|
|
// конвенции — JSON. Утверждение держится на значении и переживёт замену.
|
|
assert.Regexp(t, fmt.Sprintf(`size["=:\s]+%d`, len(content)), journal,
|
|
"размер принятой записи в байтах в журнале остаётся")
|
|
|
|
// Запись о приёме не сменила адресата: на DEBUG её в боевой настройке не
|
|
// будет вовсе, и разбор постфактум опереться будет не на что. Уровень
|
|
// ищется в строке самого приёма — соседние строки тоже идут на INFO, и
|
|
// поиск по всему журналу не упал бы от понижения этой.
|
|
assert.Regexp(t, `(?m)^.*level["=:\s]+INFO.*`+regexp.QuoteMeta(msgIntake)+`.*$`, journal,
|
|
"строка приёма остаётся на уровне INFO")
|
|
|
|
// И она ровно одна: вторая строка приёма означала бы второй путь заведения
|
|
// задачи мимо общей точки, то есть место, куда запрет не доехал.
|
|
assert.Equal(t, 1, strings.Count(journal, msgIntake),
|
|
"на принятую запись приходится одна журнальная строка приёма")
|
|
}
|
|
|
|
// metricLabelValues собирает значения меток названного семейства метрик из
|
|
// общего реестра процесса. Проверка судит реестр, а не функцию приведения:
|
|
// приведение, снятое в точке употребления, функцию не ломает, а хвост имени
|
|
// уходит на страницу метрик, которая отдаётся без проверки отправителя.
|
|
func metricLabelValues(t *testing.T, family string) []string {
|
|
families, err := prometheus.DefaultGatherer.Gather()
|
|
require.NoError(t, err)
|
|
|
|
var values []string
|
|
for _, mf := range families {
|
|
if mf.GetName() != family {
|
|
continue
|
|
}
|
|
for _, m := range mf.GetMetric() {
|
|
for _, label := range m.GetLabel() {
|
|
values = append(values, label.GetValue())
|
|
}
|
|
}
|
|
}
|
|
return values
|
|
}
|
|
|
|
func TestCreateTranscribeJob_MetricLabelCarriesNoSenderName(t *testing.T) {
|
|
env := setupTestEnv(t, readableMetaViewer())
|
|
|
|
// Хвост после последней точки — это тоже кусок имени, данного отправителем.
|
|
req := createMultipartRequest(t, "sample."+senderNameMarker, []byte("запись"))
|
|
|
|
w := httptest.NewRecorder()
|
|
env.serve(w, req)
|
|
|
|
require.Equal(t, http.StatusCreated, w.Code)
|
|
|
|
values := metricLabelValues(t, "transcriber_input_file_size_bytes")
|
|
require.NotEmpty(t, values, "метрика размера принятой записи заполняется приёмом")
|
|
assert.NotContains(t, values, "."+senderNameMarker,
|
|
"метка метрики не несёт хвоста имени, данного отправителем")
|
|
assert.NotContains(t, values, senderNameMarker,
|
|
"метка метрики не несёт хвоста имени и без ведущей точки")
|
|
assert.Contains(t, values, "other",
|
|
"незнакомое расширение приведено к общему значению")
|
|
|
|
// А в хранилище расширение остаётся пришедшим: приведение сюда не
|
|
// распространяется.
|
|
names := storedFileNames(t, env)
|
|
require.Len(t, names, 1)
|
|
assert.True(t, strings.HasSuffix(names[0], "."+senderNameMarker),
|
|
"имя в хранилище сохраняет пришедшее расширение: %q", names[0])
|
|
}
|
|
|
|
func TestGetTranscribeJobStatus_Success(t *testing.T) {
|
|
env := setupTestEnv(t, readableMetaViewer())
|
|
|
|
job := jobWithFile(t, env)
|
|
|
|
req := httptest.NewRequest("GET", "/app/audiorecords/"+job.Id, http.NoBody)
|
|
|
|
w := httptest.NewRecorder()
|
|
env.serve(w, req)
|
|
|
|
require.Equal(t, http.StatusOK, w.Code)
|
|
|
|
var response RecordView
|
|
require.NoError(t, json.Unmarshal(w.Body.Bytes(), &response))
|
|
|
|
assert.Equal(t, job.Id, response.ID)
|
|
assert.Equal(t, entity.StateUploaded, response.State)
|
|
assert.NotEmpty(t, response.CreatedAt)
|
|
}
|
|
|
|
func TestGetTranscribeJobStatus_NoTranscriptionText(t *testing.T) {
|
|
env := setupTestEnv(t, readableMetaViewer())
|
|
|
|
job := jobWithFile(t, env)
|
|
|
|
req := httptest.NewRequest("GET", "/app/audiorecords/"+job.Id, http.NoBody)
|
|
|
|
w := httptest.NewRecorder()
|
|
env.serve(w, req)
|
|
|
|
require.Equal(t, http.StatusOK, w.Code)
|
|
|
|
// Судим по сырому JSON: имена полей карточки нормативны, а разбор в структуру
|
|
// переименовал бы тег вместе с ожиданием — и проверка не смогла бы упасть.
|
|
var raw map[string]json.RawMessage
|
|
require.NoError(t, json.Unmarshal(w.Body.Bytes(), &raw))
|
|
|
|
assert.Contains(t, raw, "id")
|
|
assert.Contains(t, raw, "state")
|
|
assert.Contains(t, raw, "created_at")
|
|
assert.Contains(t, raw, "original_filename")
|
|
assert.Contains(t, raw, "duration_ms")
|
|
assert.Contains(t, raw, "size_bytes")
|
|
assert.NotContains(t, raw, "transcription_text", "текст читается своим адресом")
|
|
assert.NotContains(t, raw, "job_id", "прежние имена ушли вместе с опросом готовности")
|
|
}
|
|
|
|
func TestGetTranscribeJobStatus_NotFound(t *testing.T) {
|
|
env := setupTestEnv(t, readableMetaViewer())
|
|
|
|
req := httptest.NewRequest("GET", "/app/audiorecords/non-existent-id", http.NoBody)
|
|
|
|
w := httptest.NewRecorder()
|
|
env.serve(w, req)
|
|
|
|
require.Equal(t, http.StatusNotFound, w.Code)
|
|
|
|
var response ErrorBody
|
|
require.NoError(t, json.Unmarshal(w.Body.Bytes(), &response))
|
|
|
|
assert.Equal(t, CodeNotFound, response.Code, "код отказа машиночитаем")
|
|
assert.NotEmpty(t, response.Message, "и рядом с ним фраза для человека")
|
|
}
|
|
|
|
// Отправитель, у которого соединение оборвалось после полной загрузки, задачу
|
|
// всё равно получает: запись доехала целиком, а результат он заберёт позже по
|
|
// карточкой записи. Приём на контексте запроса терял бы такую запись молча —
|
|
// решение владельца от 2026-08-13.
|
|
func TestAcceptedRecordSurvivesSenderDisconnect(t *testing.T) {
|
|
env := setupTestEnv(t, readableMetaViewer())
|
|
|
|
req := createMultipartRequest(t, "sample.m4a", []byte("аудио"))
|
|
// Так выглядит ушедший отправитель: контекст запроса отменяется сервером,
|
|
// когда соединение закрылось.
|
|
ctx, cancel := context.WithCancel(req.Context())
|
|
cancel()
|
|
req = req.WithContext(ctx)
|
|
|
|
w := httptest.NewRecorder()
|
|
env.serve(w, req)
|
|
|
|
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, "задача заведена, несмотря на ушедшего отправителя")
|
|
}
|