хранилище переехало с PocketBase на SQLite со своим каталогом файлов
- база своя: два пула, захват одним UPDATE ... RETURNING, шаги схемы на goose под файловым замком, одна миграция начальной схемы вместо семи прежних - транспорт переписан на net/http: свои слои, свой ограничитель частоты, отдача файла с проверкой владельца; панель /_/ и пространство /api/ исчезли - по находкам ревью: журнал не пишет путь под корнем приложения, ключ бюджета читается справа налево, узнавание известного идёт читающим пулом
This commit is contained in:
@@ -4,71 +4,71 @@ import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/pocketbase/pocketbase/core"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"git.vakhrushev.me/av/transcriber/internal/adapter/repo/pocketbase/migrations"
|
||||
"git.vakhrushev.me/av/transcriber/internal/contract"
|
||||
"git.vakhrushev.me/av/transcriber/internal/entity"
|
||||
)
|
||||
|
||||
// Проверки разграничения записей по владельцу. Все идут через собранный роутер:
|
||||
// сужение живёт в хранилище, но судится по тому, что видит отправитель.
|
||||
// Проверки разграничения записей по владельцу. Все идут через собранную
|
||||
// поверхность: сужение живёт в хранилище, но судится по тому, что видит
|
||||
// отправитель.
|
||||
|
||||
// newSecondAccount заводит вторую учётную запись со своим логином. Постоянный
|
||||
// адрес почты первой занят, и повторное сохранение отвергается — адрес здесь
|
||||
// свой.
|
||||
func newSecondAccount(t *testing.T, app core.App) (*core.Record, string) {
|
||||
// unknownRecordID — идентификатор годного вида, которого нет ни у одной записи.
|
||||
const unknownRecordID = "00000000000000000000000000"
|
||||
|
||||
// newSecondAccount заводит вторую учётную запись со своим логином.
|
||||
func newSecondAccount(t *testing.T, env *testEnv) *contract.UserAccount {
|
||||
t.Helper()
|
||||
|
||||
const login = "stranger"
|
||||
|
||||
users, err := app.FindCollectionByNameOrId("users")
|
||||
account, created, err := env.users.EnsureUser(contract.Identity{
|
||||
Login: "stranger",
|
||||
Name: "Посторонний",
|
||||
Email: "stranger@example.com",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
require.True(t, created)
|
||||
|
||||
record := core.NewRecord(users)
|
||||
record.Set(migrations.ProviderLoginField, login)
|
||||
record.Set("email", "stranger@example.com")
|
||||
record.Set("verified", true)
|
||||
record.SetRandomPassword()
|
||||
require.NoError(t, app.Save(record))
|
||||
|
||||
return record, login
|
||||
return account
|
||||
}
|
||||
|
||||
// serveAs шлёт запрос от имени названного логина, а не логина окружения.
|
||||
func serveAs(env *testEnv, login string, w http.ResponseWriter, req *http.Request) {
|
||||
env.mux.ServeHTTP(w, asUser(req, login))
|
||||
}
|
||||
|
||||
// Чужая задача неотличима от несуществующей: тот же код и то же тело. Разница
|
||||
// ответов обратила бы опрос в перебор — по ней считывается, какие задачи
|
||||
// Чужая запись неотличима от несуществующей: тот же код и то же тело. Разница
|
||||
// ответов обратила бы чтение в перебор — по ней считывается, какие записи
|
||||
// заведены.
|
||||
func TestGetTranscribeJobStatus_ForeignJobLooksMissing(t *testing.T) {
|
||||
env := setupTestEnv(t, readableMetaViewer())
|
||||
|
||||
job := jobWithFile(t, env)
|
||||
_, stranger := newSecondAccount(t, env.app)
|
||||
newSecondAccount(t, env)
|
||||
|
||||
foreign := httptest.NewRecorder()
|
||||
serveAs(env, stranger, foreign, httptest.NewRequest("GET", "/app/audiorecords/"+job.Id, http.NoBody))
|
||||
foreign := env.getAs("stranger", "/app/audiorecords/"+job.Id)
|
||||
unknown := env.getAs("stranger", "/app/audiorecords/"+unknownRecordID)
|
||||
|
||||
unknown := httptest.NewRecorder()
|
||||
serveAs(env, stranger, unknown, httptest.NewRequest("GET", "/app/audiorecords/unknown0000000000", http.NoBody))
|
||||
|
||||
require.Equal(t, http.StatusNotFound, foreign.Code, "чужая задача не отдаётся")
|
||||
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)
|
||||
assert.NotContains(t, foreign.Body.String(), "transcription_text")
|
||||
}
|
||||
|
||||
// Владельцем принятой записи становится предъявитель сессии — и у задачи, и у
|
||||
// её файла.
|
||||
// Нечитаемый по виду идентификатор отвечает тем же, чем неизвестный: разбор идёт
|
||||
// на границе, и до базы такой запрос не доходит вовсе.
|
||||
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())
|
||||
|
||||
@@ -78,24 +78,26 @@ func TestCreateTranscribeJob_OwnerIsSession(t *testing.T) {
|
||||
|
||||
response := intakeItemOf(t, w)
|
||||
|
||||
record, err := env.app.FindRecordById(migrations.RecordsCollection, response.ID)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, env.account.Id, record.GetString("owner"), "владелец задачи — предъявитель")
|
||||
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, "владелец записи — предъявитель")
|
||||
|
||||
fileRecord, err := env.app.FindRecordById("files", record.GetString("original_file"))
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, env.account.Id, fileRecord.GetString("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.app)
|
||||
stranger := newSecondAccount(t, env)
|
||||
|
||||
req := createMultipartRequest(t, "sample.mp3", []byte("запись"))
|
||||
query := req.URL.Query()
|
||||
query.Set("owner", stranger)
|
||||
query.Set("owner", stranger.ID)
|
||||
req.URL.RawQuery = query.Encode()
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
@@ -104,97 +106,198 @@ func TestCreateTranscribeJob_OwnerFieldFromRequestIgnored(t *testing.T) {
|
||||
|
||||
response := intakeItemOf(t, w)
|
||||
|
||||
record, err := env.app.FindRecordById(migrations.RecordsCollection, response.ID)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, env.account.Id, record.GetString("owner"))
|
||||
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 TestCreateTranscribeJob_SuperuserSessionRejected(t *testing.T) {
|
||||
env := setupTestEnv(t, readableMetaViewer())
|
||||
|
||||
superusers, err := env.app.FindCollectionByNameOrId(core.CollectionNameSuperusers)
|
||||
require.NoError(t, err)
|
||||
|
||||
admin := core.NewRecord(superusers)
|
||||
admin.Set("email", "owner@example.com")
|
||||
admin.SetRandomPassword()
|
||||
require.NoError(t, env.app.Save(admin))
|
||||
|
||||
token, err := admin.NewAuthToken()
|
||||
require.NoError(t, err)
|
||||
|
||||
// Владелец панели предъявляет **свой токен**, а не заголовок: заголовок ему
|
||||
// никто не ставит, и узнавание по нему его учётной записи не касается.
|
||||
req := createMultipartRequest(t, "sample.mp3", []byte("запись"))
|
||||
req.Header.Set("Authorization", token)
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
env.mux.ServeHTTP(w, req)
|
||||
|
||||
assert.Equal(t, http.StatusForbidden, w.Code, "узнан, но не запись коллекции пользователей")
|
||||
assert.Equal(t, 0, countJobs(t, env), "задачи не заведено")
|
||||
assert.Equal(t, 0, countFiles(t, env), "и файла тоже")
|
||||
}
|
||||
|
||||
// Чужой файл не отдаётся по ссылке, а свой отдаётся. Проверяется именно переход
|
||||
// по ссылке: токен файла хранилище выдаёт на предъявителя, а не на файл, и отказ
|
||||
// наступает на скачивании, где правило просмотра судит владельца. Проверка,
|
||||
// написанная на выдачу токена, зеленела бы, не касаясь пути, по которому аудио и
|
||||
// уходит.
|
||||
// Отказ наступает **на самом обращении за файлом**: другого места, где он мог бы
|
||||
// наступить, у сервиса не осталось — значений на предъявителя он не выдаёт.
|
||||
func TestFileDownload_NarrowedByOwner(t *testing.T) {
|
||||
env := setupTestEnv(t, readableMetaViewer())
|
||||
|
||||
job := jobWithFile(t, env)
|
||||
_, stranger := newSecondAccount(t, env.app)
|
||||
newSecondAccount(t, env)
|
||||
|
||||
record, err := env.app.FindRecordById("files", *job.OriginalFileID)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, env.account.Id, record.GetString("owner"))
|
||||
|
||||
link := "/api/files/files/" + record.Id + "/" + record.GetString("file")
|
||||
|
||||
// Переход по ссылке идёт **без** заголовка: файл судится своим коротким
|
||||
// токеном, а не узнаванием. Так же по ней пойдёт и браузер.
|
||||
mine := httptest.NewRecorder()
|
||||
env.mux.ServeHTTP(mine,
|
||||
httptest.NewRequest("GET", link+"?token="+fileToken(t, env, env.login), http.NoBody))
|
||||
|
||||
foreign := httptest.NewRecorder()
|
||||
env.mux.ServeHTTP(foreign,
|
||||
httptest.NewRequest("GET", link+"?token="+fileToken(t, env, stranger), http.NoBody))
|
||||
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(), "и отдаётся содержимым")
|
||||
|
||||
assert.NotEqual(t, http.StatusOK, foreign.Code, "чужой файл не отдаётся")
|
||||
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(), "запись", "содержимого в отказе нет")
|
||||
}
|
||||
|
||||
// fileToken берёт у хранилища токен файла для названного логина. Токен выдаётся
|
||||
// на предъявителя: о файле хранилище при выдаче не спрашивает.
|
||||
//
|
||||
// Адрес выдачи лежит в пространстве хранилища, а не приложения, и узнавание на
|
||||
// нём работает поимённо — иначе весь путь «узнавание → токен файла → ссылка»
|
||||
// обрывался бы на первом шаге, и файл записи стал бы недостижим для браузера.
|
||||
func fileToken(t *testing.T, env *testEnv, login string) string {
|
||||
t.Helper()
|
||||
// Негодное значение копии у чужой записи неотличимо от неизвестной записи:
|
||||
// владение судится **до** разбора значения копии. Разойдись эти ответы — по их
|
||||
// разнице перебирался бы список заведённых записей одним негодным параметром.
|
||||
func TestFileDownload_UnknownCopyOnForeignRecordLooksLikeUnknownRecord(t *testing.T) {
|
||||
env := setupTestEnv(t, readableMetaViewer())
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
env.mux.ServeHTTP(w, asUser(
|
||||
httptest.NewRequest("POST", "/api/files/token", http.NoBody), login))
|
||||
require.Equal(t, http.StatusOK, w.Code, "токен файла выдаётся всякому узнанному")
|
||||
job := jobWithFile(t, env)
|
||||
newSecondAccount(t, env)
|
||||
|
||||
var body struct {
|
||||
Token string `json:"token"`
|
||||
}
|
||||
require.NoError(t, json.Unmarshal(w.Body.Bytes(), &body))
|
||||
require.NotEmpty(t, body.Token)
|
||||
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")
|
||||
|
||||
return body.Token
|
||||
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")
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user