имя файла отправителя убрано из журнала приёма
- расширение приводится к перечню известных форматов прежде метки метрики: страница метрик открыта, и хвост имени уезжал на неё дословно - проверки приёма перехватывают все три потока журнала и читают реестр метрик, каждая падает при снятии того, что сторожит
This commit is contained in:
@@ -5,7 +5,7 @@ import (
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"io"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"mime/multipart"
|
||||
"net/http"
|
||||
@@ -13,7 +13,10 @@ import (
|
||||
"os"
|
||||
"path"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"runtime"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
@@ -27,6 +30,8 @@ import (
|
||||
"github.com/gin-gonic/gin"
|
||||
_ "github.com/mattn/go-sqlite3"
|
||||
"github.com/pressly/goose/v3"
|
||||
"github.com/prometheus/client_golang/prometheus"
|
||||
sloggin "github.com/samber/slog-gin"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
@@ -74,6 +79,31 @@ type testEnv struct {
|
||||
handler *TranscribeHandler
|
||||
db *sql.DB
|
||||
storageDir string
|
||||
journal *journalBuffer
|
||||
}
|
||||
|
||||
// journalBuffer — перехваченный журнал одной проверки. Свой на случай: общий на
|
||||
// пакет сделал бы исход функцией от соседних случаев — «поля на месте» прошло бы
|
||||
// на чужой строке, а «маркера нет» покраснело бы от чужой. Замок нужен потому,
|
||||
// что пишущих в него потоков три: логгер сервиса, стандартный `log` транспорта
|
||||
// и middleware запроса.
|
||||
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()
|
||||
}
|
||||
|
||||
func setupTestDB(t *testing.T) (*sql.DB, *goqu.Database) {
|
||||
@@ -115,11 +145,26 @@ func setupTestEnv(t *testing.T, metaviewer contract.AudioMetaViewer) *testEnv {
|
||||
fileRepo := sqlite.NewFileRepository(db, gq)
|
||||
jobRepo := sqlite.NewTranscriptJobRepository(db, gq)
|
||||
|
||||
// Журнал проверкам не нужен: судят они по ответу и по базе. А ветка отказа
|
||||
// метаданных теперь проходится нарочно, и её ERROR-строки на зелёном
|
||||
// прогоне размывали бы признак, по которому отличают новый красный шаг
|
||||
// гейта от объявленного долга.
|
||||
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
|
||||
// Журнал уходит в буфер, а не в никуда: по нему судит проверка запрета на
|
||||
// имя отправителя. Вывод прогона от этого не меняется — ERROR-строки ветки
|
||||
// отказа по-прежнему не попадают на экран и не размывают признак, по
|
||||
// которому отличают новый красный шаг гейта от объявленного долга.
|
||||
journal := &journalBuffer{}
|
||||
logger := slog.New(slog.NewTextHandler(journal, nil))
|
||||
|
||||
// Второй писатель журнала приёма — HTTP-транспорт: он пишет через стандартный
|
||||
// `log` (расхождение записано в docs/conventions/logging.md). В бою `main.go`
|
||||
// зовёт `slog.SetDefault`, и такая запись садится в `msg` строки `slog`;
|
||||
// повторяем это здесь, чтобы оракул видел ту же цепочку, что и прод, а не
|
||||
// свою. Без перехвата оракул был бы уже требования, которое накрывает все
|
||||
// журнальные записи приёма.
|
||||
//
|
||||
// Подмена процессная, а не своя у случая: `t.Parallel()` в этом файле
|
||||
// запрещён. При параллельных случаях вывод указывал бы на буфер соседа, и
|
||||
// проверка запрета прошла бы, ничего не прочитав.
|
||||
prevDefault := slog.Default()
|
||||
slog.SetDefault(logger)
|
||||
t.Cleanup(func() { slog.SetDefault(prevDefault) })
|
||||
|
||||
trsService := service.NewTranscribeService(
|
||||
jobRepo,
|
||||
@@ -134,7 +179,12 @@ func setupTestEnv(t *testing.T, metaviewer contract.AudioMetaViewer) *testEnv {
|
||||
|
||||
handler := NewTranscribeHandler(jobRepo, trsService)
|
||||
|
||||
// Роутер собирается той же цепочкой, что и боевой (main.go): у приёма три
|
||||
// пишущих в журнал потока, и middleware — третий. Без него требование «ни
|
||||
// одна журнальная запись приёма» проверялось бы шире, чем оракул смотрит.
|
||||
router := gin.New()
|
||||
router.Use(sloggin.New(logger))
|
||||
router.Use(gin.Recovery())
|
||||
router.MaxMultipartMemory = 32 << 20 // 32 MiB
|
||||
|
||||
api := router.Group("/api")
|
||||
@@ -143,7 +193,7 @@ func setupTestEnv(t *testing.T, metaviewer contract.AudioMetaViewer) *testEnv {
|
||||
api.GET("/status/:id", handler.GetTranscribeJobStatus)
|
||||
}
|
||||
|
||||
return &testEnv{router: router, handler: handler, db: db, storageDir: storageDir}
|
||||
return &testEnv{router: router, handler: handler, db: db, storageDir: storageDir, journal: journal}
|
||||
}
|
||||
|
||||
// createMultipartRequest собирает запрос из имени и содержимого. Файла на диске
|
||||
@@ -382,6 +432,163 @@ func TestCreateTranscribeJob_MetaViewerFailure(t *testing.T) {
|
||||
assert.Equal(t, 0, countJobs(t, env))
|
||||
}
|
||||
|
||||
// senderNameMarker — метка внутри имени, которое даёт отправитель. ASCII и
|
||||
// заведомо уникальна: в остальном выводе прогона такой строки нет, поэтому
|
||||
// находка означает утечку, а не совпадение. Ищется она **значением**, а не
|
||||
// именем журнального поля: имя, вернувшееся под другим ключом, поиск по ключу
|
||||
// пропустил бы.
|
||||
const senderNameMarker = "SENDERNAMELEAKMARKER7Q2"
|
||||
|
||||
// Тексты, по которым проверки находят журнальные строки. Оба — записанный долг
|
||||
// `docs/conventions/logging.md`: `msg` обязан стать короткой категорией, а
|
||||
// транспорту не положено логировать вовсе. Когда долг закроют, правка будет
|
||||
// здесь и одна, а смысл утверждений менять не придётся.
|
||||
const (
|
||||
msgIntake = "Creating transcribe job"
|
||||
msgTransportErr = "Err:"
|
||||
msgMiddleware = "Incoming request"
|
||||
)
|
||||
|
||||
func TestCreateTranscribeJob_SenderFileNameNotLogged(t *testing.T) {
|
||||
env := setupTestEnv(t, readableMetaViewer())
|
||||
|
||||
// Метка стоит в основе имени, а расширение обычное: расширение запретом не
|
||||
// накрыто и в журнале остаётся законно.
|
||||
req := createMultipartRequest(t, senderNameMarker+".mp3", []byte("запись"))
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
env.router.ServeHTTP(w, req)
|
||||
|
||||
require.Equal(t, http.StatusCreated, w.Code)
|
||||
|
||||
journal := env.journal.String()
|
||||
|
||||
// Сперва — что поток middleware вообще перехвачен. Он третий писатель
|
||||
// журнала приёма, и без этого утверждения снятие его из тестового роутера
|
||||
// сузило бы оракул молча.
|
||||
require.Contains(t, journal, msgMiddleware,
|
||||
"строка middleware о запросе попадает в перехваченный журнал")
|
||||
|
||||
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.router.ServeHTTP(w, req)
|
||||
|
||||
require.Equal(t, http.StatusInternalServerError, w.Code)
|
||||
|
||||
journal := env.journal.String()
|
||||
|
||||
// Сперва — что второй поток журнала вообще перехвачен. Без этого
|
||||
// утверждения снятие `slog.SetDefault` из окружения оставило бы проверку
|
||||
// зелёной, а оракул критического инварианта молча сузился бы вдвое.
|
||||
require.Contains(t, journal, msgTransportErr,
|
||||
"строка транспорта, идущая мимо slog, попадает в перехваченный журнал")
|
||||
|
||||
assert.NotContains(t, journal, senderNameMarker,
|
||||
"имя отправителя не пишется в журнал и на пути отказа")
|
||||
}
|
||||
|
||||
func TestCreateTranscribeJob_JournalTracesRecord(t *testing.T) {
|
||||
env := setupTestEnv(t, readableMetaViewer())
|
||||
|
||||
content := []byte("содержимое записи")
|
||||
req := createMultipartRequest(t, "sample.mp3", content)
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
env.router.ServeHTTP(w, req)
|
||||
|
||||
require.Equal(t, http.StatusCreated, w.Code)
|
||||
|
||||
var response CreateTranscribeJobResponse
|
||||
require.NoError(t, json.Unmarshal(w.Body.Bytes(), &response))
|
||||
|
||||
job, err := env.handler.jobRepo.GetByID(response.JobID)
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, job.FileID)
|
||||
|
||||
// Отбор по идентификатору **этого** прогона: иначе утверждение прошло бы по
|
||||
// строке, оставленной соседней проверкой, и прослеживаемость числилась бы
|
||||
// сохранённой при пустом журнале.
|
||||
journal := env.journal.String()
|
||||
assert.Contains(t, journal, *job.FileID, "по журналу видно, какой файл заведён")
|
||||
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.router.ServeHTTP(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",
|
||||
"незнакомое расширение приведено к общему значению")
|
||||
|
||||
// А на диске расширение остаётся пришедшим: раскладка каталога записей
|
||||
// объявлена необратимой, и приведение сюда не распространяется.
|
||||
files := storedFiles(t, env)
|
||||
require.Len(t, files, 1)
|
||||
assert.Equal(t, "."+senderNameMarker, filepath.Ext(files[0]))
|
||||
}
|
||||
|
||||
func TestGetTranscribeJobStatus_Success(t *testing.T) {
|
||||
env := setupTestEnv(t, readableMetaViewer())
|
||||
|
||||
|
||||
Reference in New Issue
Block a user