имя файла отправителя убрано из журнала приёма

- расширение приводится к перечню известных форматов прежде метки метрики:
  страница метрик открыта, и хвост имени уезжал на неё дословно
- проверки приёма перехватывают все три потока журнала и читают реестр метрик,
  каждая падает при снятии того, что сторожит
This commit is contained in:
av
2026-08-11 16:37:58 +03:00
parent ffa36e96c7
commit bd6001cd8d
18 changed files with 1797 additions and 20 deletions
+214 -7
View File
@@ -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())