Files
transcriber/internal/controller/http/ownership_test.go
T
av 3a2da3004b приём и чтение записей сведены к одному контракту приложения
- адреса приложения переехали в своё пространство `/app/`, опрос готовности
  убран целиком: рубеж и причину остановки владелец узнаёт карточкой записи,
  текст — отдельным адресом названного вида
- заведена единая точка отображения доменной ошибки и слой, приводящий к той же
  форме отказы библиотеки: тело несёт машиночитаемый код рядом с сообщением
- у записи появились имя файла отправителя, длительность и размер своими
  колонками, а у ленты владельца — свой индекс: без него страница сканировала
  весь архив сервиса
2026-08-15 13:51:23 +03:00

200 lines
9.0 KiB
Go

package http
import (
"encoding/json"
"net/http"
"net/http/httptest"
"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/entity"
)
// Проверки разграничения записей по владельцу. Все идут через собранный роутер:
// сужение живёт в хранилище, но судится по тому, что видит отправитель.
// newSecondAccount заводит вторую учётную запись с собственной сессией.
// Постоянный адрес почты первой занят, и повторное сохранение отвергается —
// адрес здесь свой.
func newSecondAccount(t *testing.T, app core.App) (*core.Record, string) {
t.Helper()
users, err := app.FindCollectionByNameOrId("users")
require.NoError(t, err)
record := core.NewRecord(users)
record.Set("email", "stranger@example.com")
record.Set("verified", true)
record.SetRandomPassword()
require.NoError(t, app.Save(record))
token, err := record.NewAuthToken()
require.NoError(t, err)
return record, token
}
// withSessionHeader предъявляет сессию заголовком. Собственная поверхность
// хранилища читается только так: слой, перекладывающий куку в заголовок, на неё
// намеренно не наведён — часть её защищена ровно тем, что браузер заголовка сам
// не шлёт.
func withSessionHeader(req *http.Request, session string) *http.Request {
req.Header.Set("Authorization", session)
return req
}
// serveAs шлёт запрос от имени названной сессии, а не сессии окружения.
func serveAs(env *testEnv, session string, w http.ResponseWriter, req *http.Request) {
req.AddCookie(&http.Cookie{Name: SessionCookieName, Value: session})
env.mux.ServeHTTP(w, req)
}
// Чужая задача неотличима от несуществующей: тот же код и то же тело. Разница
// ответов обратила бы опрос в перебор — по ней считывается, какие задачи
// заведены.
func TestGetTranscribeJobStatus_ForeignJobLooksMissing(t *testing.T) {
env := setupTestEnv(t, readableMetaViewer())
job := jobWithFile(t, env)
_, stranger := newSecondAccount(t, env.app)
foreign := httptest.NewRecorder()
serveAs(env, stranger, foreign, httptest.NewRequest("GET", "/app/audiorecords/"+job.Id, http.NoBody))
unknown := httptest.NewRecorder()
serveAs(env, stranger, unknown, httptest.NewRequest("GET", "/app/audiorecords/unknown0000000000", http.NoBody))
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 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)
record, err := env.app.FindRecordById(migrations.RecordsCollection, response.ID)
require.NoError(t, err)
assert.Equal(t, env.account.Id, record.GetString("owner"), "владелец задачи — предъявитель")
fileRecord, err := env.app.FindRecordById("files", record.GetString("original_file"))
require.NoError(t, err)
assert.Equal(t, env.account.Id, fileRecord.GetString("owner"), "владелец файла — он же")
}
// Владельца не задают запросом: своё значение в форме на результат не влияет.
func TestCreateTranscribeJob_OwnerFieldFromRequestIgnored(t *testing.T) {
env := setupTestEnv(t, readableMetaViewer())
_, stranger := newSecondAccount(t, env.app)
req := createMultipartRequest(t, "sample.mp3", []byte("запись"))
query := req.URL.Query()
query.Set("owner", stranger)
req.URL.RawQuery = query.Encode()
w := httptest.NewRecorder()
env.serve(w, req)
require.Equal(t, http.StatusCreated, w.Code)
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"))
}
// Предъявитель, чья сессия не даёт учётной записи пользователя, получает отказ
// до чтения тела. Владелец панели — именно такой: узнан он узнан, а записи в
// коллекции пользователей у него нет, и владельцем записи он стать не может.
//
// Отказ **до** укладки обязателен: позже пришлось бы убирать уже сохранённый
// файл, а уборки файлов сервис не умеет вовсе.
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)
w := httptest.NewRecorder()
serveAs(env, token, w, createMultipartRequest(t, "sample.mp3", []byte("запись")))
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)
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, withSessionHeader(
httptest.NewRequest("GET", link+"?token="+fileToken(t, env, env.session), http.NoBody), env.session))
foreign := httptest.NewRecorder()
env.mux.ServeHTTP(foreign, withSessionHeader(
httptest.NewRequest("GET", link+"?token="+fileToken(t, env, stranger), http.NoBody), stranger))
require.Equal(t, http.StatusOK, mine.Code, "свой файл отдаётся")
assert.Equal(t, "запись", mine.Body.String(), "и отдаётся содержимым")
assert.NotEqual(t, http.StatusOK, foreign.Code, "чужой файл не отдаётся")
assert.NotContains(t, foreign.Body.String(), "запись", "содержимого в отказе нет")
}
// fileToken берёт у хранилища токен файла для названной сессии. Токен выдаётся
// на предъявителя: о файле хранилище при выдаче не спрашивает.
func fileToken(t *testing.T, env *testEnv, session string) string {
t.Helper()
w := httptest.NewRecorder()
env.mux.ServeHTTP(w, withSessionHeader(
httptest.NewRequest("POST", "/api/files/token", http.NoBody), session))
require.Equal(t, http.StatusOK, w.Code, "токен файла выдаётся всякому вошедшему")
var body struct {
Token string `json:"token"`
}
require.NoError(t, json.Unmarshal(w.Body.Bytes(), &body))
require.NotEmpty(t, body.Token)
return body.Token
}