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

304 lines
13 KiB
Go

package http
import (
"encoding/json"
"net/http"
"net/http/httptest"
"strings"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"git.vakhrushev.me/av/transcriber/internal/contract"
"git.vakhrushev.me/av/transcriber/internal/entity"
)
// Проверки разграничения записей по владельцу. Все идут через собранную
// поверхность: сужение живёт в хранилище, но судится по тому, что видит
// отправитель.
// unknownRecordID — идентификатор годного вида, которого нет ни у одной записи.
const unknownRecordID = "00000000000000000000000000"
// newSecondAccount заводит вторую учётную запись со своим логином.
func newSecondAccount(t *testing.T, env *testEnv) *contract.UserAccount {
t.Helper()
account, created, err := env.users.EnsureUser(contract.Identity{
Login: "stranger",
Name: "Посторонний",
Email: "stranger@example.com",
})
require.NoError(t, err)
require.True(t, created)
return account
}
// Чужая запись неотличима от несуществующей: тот же код и то же тело. Разница
// ответов обратила бы чтение в перебор — по ней считывается, какие записи
// заведены.
func TestGetTranscribeJobStatus_ForeignJobLooksMissing(t *testing.T) {
env := setupTestEnv(t, readableMetaViewer())
job := jobWithFile(t, env)
newSecondAccount(t, env)
foreign := env.getAs("stranger", "/app/audiorecords/"+job.Id)
unknown := env.getAs("stranger", "/app/audiorecords/"+unknownRecordID)
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)
}
// Нечитаемый по виду идентификатор отвечает тем же, чем неизвестный: разбор идёт
// на границе, и до базы такой запрос не доходит вовсе.
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())
w := httptest.NewRecorder()
env.serve(w, createMultipartRequest(t, "sample.mp3", []byte("запись")))
require.Equal(t, http.StatusCreated, w.Code)
response := intakeItemOf(t, w)
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, "владелец записи — предъявитель")
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)
req := createMultipartRequest(t, "sample.mp3", []byte("запись"))
query := req.URL.Query()
query.Set("owner", stranger.ID)
req.URL.RawQuery = query.Encode()
w := httptest.NewRecorder()
env.serve(w, req)
require.Equal(t, http.StatusCreated, w.Code)
response := intakeItemOf(t, w)
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 TestFileDownload_NarrowedByOwner(t *testing.T) {
env := setupTestEnv(t, readableMetaViewer())
job := jobWithFile(t, env)
newSecondAccount(t, env)
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(), "и отдаётся содержимым")
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(), "запись", "содержимого в отказе нет")
}
// Негодное значение копии у чужой записи неотличимо от неизвестной записи:
// владение судится **до** разбора значения копии. Разойдись эти ответы — по их
// разнице перебирался бы список заведённых записей одним негодным параметром.
func TestFileDownload_UnknownCopyOnForeignRecordLooksLikeUnknownRecord(t *testing.T) {
env := setupTestEnv(t, readableMetaViewer())
job := jobWithFile(t, env)
newSecondAccount(t, env)
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")
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")
}