хранилище переехало с PocketBase на SQLite со своим каталогом файлов
- база своя: два пула, захват одним UPDATE ... RETURNING, шаги схемы на goose под файловым замком, одна миграция начальной схемы вместо семи прежних - транспорт переписан на net/http: свои слои, свой ограничитель частоты, отдача файла с проверкой владельца; панель /_/ и пространство /api/ исчезли - по находкам ревью: журнал не пишет путь под корнем приложения, ключ бюджета читается справа налево, узнавание известного идёт читающим пулом
This commit is contained in:
@@ -2,88 +2,25 @@ package http
|
||||
|
||||
import (
|
||||
"io/fs"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
"testing/fstest"
|
||||
"time"
|
||||
|
||||
"github.com/pocketbase/pocketbase/apis"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"git.vakhrushev.me/av/transcriber/internal/adapter/recognizer"
|
||||
pbrepo "git.vakhrushev.me/av/transcriber/internal/adapter/repo/pocketbase"
|
||||
"git.vakhrushev.me/av/transcriber/internal/entity"
|
||||
"git.vakhrushev.me/av/transcriber/internal/service"
|
||||
)
|
||||
|
||||
const testMarkup = `<!doctype html><html lang="ru"><body>приложение</body></html>`
|
||||
|
||||
// builtDist — собранное приложение, каким его видит раздача.
|
||||
func builtDist() fs.FS {
|
||||
return fstest.MapFS{
|
||||
"index.html": {Data: []byte(testMarkup)},
|
||||
"assets/index-abc123.js": {Data: []byte("console.log(1)")},
|
||||
"favicon.ico": {Data: []byte("значок")},
|
||||
}
|
||||
}
|
||||
|
||||
// webappEnv — окружение проверок раздачи. Роутер собирается тем же способом,
|
||||
// что и боевой: маршруты порождает перечень, а не перечисление в проверке.
|
||||
// webappEnv — окружение проверок раздачи. Поверхность собирается тем же
|
||||
// способом, что и боевая: маршруты порождает перечень адресного пространства, а
|
||||
// не перечисление в проверке.
|
||||
type webappEnv struct {
|
||||
mux http.Handler
|
||||
journal *journalBuffer
|
||||
login string
|
||||
env *testEnv
|
||||
}
|
||||
|
||||
func setupWebappEnv(t *testing.T, dist fs.FS, built bool) *webappEnv {
|
||||
t.Helper()
|
||||
|
||||
app := newTestStorage(t)
|
||||
pbrepo.BindPanelRules(app)
|
||||
|
||||
recordRepo := pbrepo.NewAudioRecordRepository(app)
|
||||
textRepo := pbrepo.NewTextRepository(app)
|
||||
structureRepo := pbrepo.NewStructureRepository(app)
|
||||
|
||||
journal := &journalBuffer{}
|
||||
logger := slog.New(slog.NewTextHandler(journal, nil))
|
||||
|
||||
trsService := service.NewTranscribeService(
|
||||
service.Repositories{
|
||||
Records: recordRepo,
|
||||
Files: pbrepo.NewFileRepository(app),
|
||||
Texts: textRepo,
|
||||
Structures: structureRepo,
|
||||
Recognitions: pbrepo.NewRecognitionRepository(app),
|
||||
Events: pbrepo.NewRecordEventRepository(app),
|
||||
},
|
||||
readableMetaViewer(),
|
||||
&stubConverter{},
|
||||
&recognizer.MemoryAudioRecognizer{},
|
||||
entity.StuckLimits{Own: time.Hour, Foreign: 24 * time.Hour},
|
||||
logger,
|
||||
)
|
||||
|
||||
appHandler := NewAppHandler(recordRepo, textRepo, structureRepo, trsService, logger)
|
||||
|
||||
mounts := ServiceMounts(appHandler, http.NotFoundHandler())
|
||||
|
||||
r, err := apis.NewRouter(app)
|
||||
require.NoError(t, err)
|
||||
|
||||
r.Bind(TrustedHeaderIdentity(app, mounts, testTrustedNetworks(t), logger))
|
||||
RegisterServiceRoutes(r, mounts)
|
||||
NewWebappHandler(dist, built, mounts, logger).Register(r)
|
||||
|
||||
mux, err := r.BuildMux()
|
||||
require.NoError(t, err)
|
||||
|
||||
_, login := newTestAccount(t, app)
|
||||
|
||||
return &webappEnv{mux: mux, journal: journal, login: login}
|
||||
return &webappEnv{env: setupEnv(t, envOptions{dist: dist, built: built})}
|
||||
}
|
||||
|
||||
func (e *webappEnv) get(path string) *httptest.ResponseRecorder {
|
||||
@@ -93,15 +30,20 @@ func (e *webappEnv) get(path string) *httptest.ResponseRecorder {
|
||||
func (e *webappEnv) do(method, path string, identified bool) *httptest.ResponseRecorder {
|
||||
req := httptest.NewRequest(method, path, nil)
|
||||
if identified {
|
||||
asUser(req, e.login)
|
||||
asUser(req, e.env.login)
|
||||
}
|
||||
|
||||
rec := httptest.NewRecorder()
|
||||
e.mux.ServeHTTP(rec, req)
|
||||
e.env.mux.ServeHTTP(rec, req)
|
||||
|
||||
return rec
|
||||
}
|
||||
|
||||
// journal отдаёт перехваченный журнал окружения.
|
||||
func (e *webappEnv) journal() string {
|
||||
return e.env.journal.String()
|
||||
}
|
||||
|
||||
// Обновление страницы посреди приложения открывает тот же экран: адреса у
|
||||
// приложения обычные, а не после решётки, и сервер обязан отдать разметку.
|
||||
func TestWebappServesMarkupOutsideServiceRoots(t *testing.T) {
|
||||
@@ -117,16 +59,33 @@ func TestWebappServesMarkupOutsideServiceRoots(t *testing.T) {
|
||||
|
||||
// Путь внутри корня сервиса в приложение не проваливается никогда: иначе
|
||||
// программа, ошибшаяся адресом, приняла бы разметку с кодом 200 за ответ.
|
||||
//
|
||||
// Корень у сервиса остался один — корень приложения; `/api` и `/_` ушли вместе
|
||||
// со встроенным хранилищем, и пути под ними стали обычными путями вне корней.
|
||||
func TestWebappNeverAnswersInsideServiceRoots(t *testing.T) {
|
||||
env := setupWebappEnv(t, builtDist(), true)
|
||||
|
||||
for _, path := range []string{"/api/nope", "/_/nope"} {
|
||||
for _, path := range []string{"/app/nope", "/app"} {
|
||||
res := env.get(path)
|
||||
|
||||
assert.NotContains(t, res.Body.String(), "приложение", path)
|
||||
}
|
||||
}
|
||||
|
||||
// Прежние корни хранилища и панели отвечают разметкой — тем же, чем отвечает
|
||||
// всякий путь вне корней. Знак, записанный своим кодом, попадает в то же
|
||||
// правило: правило одно, и особого случая у него нет.
|
||||
func TestWebappServesFormerStorageAndPanelPaths(t *testing.T) {
|
||||
env := setupWebappEnv(t, builtDist(), true)
|
||||
|
||||
for _, path := range []string{"/api/nope", "/_/", "/%5f/"} {
|
||||
res := env.get(path)
|
||||
|
||||
assert.Equal(t, http.StatusOK, res.Code, path)
|
||||
assert.Contains(t, res.Body.String(), "приложение", path)
|
||||
}
|
||||
}
|
||||
|
||||
// Под корнем приложения отказ уходит его собственной формой, и код отвечает
|
||||
// причине: без сессии — «предъяви себя», с сессией — «такого адреса нет».
|
||||
func TestWebappLeavesAppRootToItsOwnFailureForm(t *testing.T) {
|
||||
@@ -147,8 +106,14 @@ func TestWebappLeavesAppRootToItsOwnFailureForm(t *testing.T) {
|
||||
func TestWebappRootMatchNeedsExactOrSlash(t *testing.T) {
|
||||
env := setupWebappEnv(t, builtDist(), true)
|
||||
|
||||
bare := env.get(StorageRoot)
|
||||
assert.NotContains(t, bare.Body.String(), "приложение", "голый корень разметкой не подменяется")
|
||||
bare := env.do(http.MethodGet, AppRoot, true)
|
||||
assert.Equal(t, http.StatusNotFound, bare.Code, "голый корень разметкой не подменяется")
|
||||
assert.NotContains(t, bare.Body.String(), "приложение")
|
||||
|
||||
bareAnonymous := env.get(AppRoot)
|
||||
assert.Equal(t, http.StatusUnauthorized, bareAnonymous.Code,
|
||||
"голый корень неузнанному отвечает как прочие адреса под ним")
|
||||
assert.NotContains(t, bareAnonymous.Body.String(), "приложение")
|
||||
|
||||
neighbour := env.get(AppRoot + "le")
|
||||
assert.Equal(t, http.StatusOK, neighbour.Code)
|
||||
@@ -245,5 +210,5 @@ func TestWebappNotBuilt(t *testing.T) {
|
||||
|
||||
// Владелец сервиса узнаёт об этом журналом подъёма, а не от человека,
|
||||
// открывшего страницу.
|
||||
assert.Contains(t, env.journal.String(), "Webapp is not built")
|
||||
assert.Contains(t, env.journal(), "Webapp is not built")
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user