- база своя: два пула, захват одним UPDATE ... RETURNING, шаги схемы на goose под файловым замком, одна миграция начальной схемы вместо семи прежних - транспорт переписан на net/http: свои слои, свой ограничитель частоты, отдача файла с проверкой владельца; панель /_/ и пространство /api/ исчезли - по находкам ревью: журнал не пишет путь под корнем приложения, ключ бюджета читается справа налево, узнавание известного идёт читающим пулом
304 lines
13 KiB
Go
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")
|
|
}
|