Files
transcriber/internal/controller/http/journal_test.go
T
av c9b7765646 хранилище переехало с PocketBase на SQLite со своим каталогом файлов
- база своя: два пула, захват одним UPDATE ... RETURNING, шаги схемы на goose
  под файловым замком, одна миграция начальной схемы вместо семи прежних
- транспорт переписан на net/http: свои слои, свой ограничитель частоты,
  отдача файла с проверкой владельца; панель /_/ и пространство /api/ исчезли
- по находкам ревью: журнал не пишет путь под корнем приложения, ключ бюджета
  читается справа налево, узнавание известного идёт читающим пулом
2026-08-23 08:06:04 +03:00

150 lines
6.6 KiB
Go

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,
"строка журнала растёт вместе с длиной запрошенного пути")
}