- заведён каталог web/ — Vue 3, роутер пятой версии, сборка Vite; собранное вшивается через go:embed и раздаётся корневым маршрутом: разметка на неизвестном пути вне корней сервиса, отказ контракта внутри корня - перечень корней сервиса стал единой точкой и порождает регистрацию маршрутов, а не описывает её; журнал раздачи пишет исход и длину пути, но не сам путь - шаг front зовёт Node контейнером docker — Biome, юнит-тесты Vue и сборка входят в гейт, а в Dockerfile появилась ступень приложения
254 lines
9.8 KiB
Go
254 lines
9.8 KiB
Go
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 — окружение проверок раздачи. Роутер собирается тем же способом,
|
|
// что и боевой: маршруты порождает перечень, а не перечисление в проверке.
|
|
type webappEnv struct {
|
|
mux http.Handler
|
|
journal *journalBuffer
|
|
session string
|
|
}
|
|
|
|
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)
|
|
authHandler := NewAuthHandler(app, AuthHandlerConfig{
|
|
AuthURL: "https://provider.example/authorize",
|
|
RedirectURL: "https://service.example/auth/callback",
|
|
ClientID: "client",
|
|
}, logger)
|
|
|
|
mounts := ServiceMounts(appHandler, authHandler, http.NotFoundHandler())
|
|
|
|
r, err := apis.NewRouter(app)
|
|
require.NoError(t, err)
|
|
|
|
RegisterServiceRoutes(r, mounts)
|
|
NewWebappHandler(dist, built, mounts, logger).Register(r)
|
|
|
|
mux, err := r.BuildMux()
|
|
require.NoError(t, err)
|
|
|
|
_, session := newTestAccount(t, app)
|
|
|
|
return &webappEnv{mux: mux, journal: journal, session: session}
|
|
}
|
|
|
|
func (e *webappEnv) get(path string) *httptest.ResponseRecorder {
|
|
return e.do(http.MethodGet, path, false)
|
|
}
|
|
|
|
func (e *webappEnv) do(method, path string, withSession bool) *httptest.ResponseRecorder {
|
|
req := httptest.NewRequest(method, path, nil)
|
|
if withSession {
|
|
req.AddCookie(&http.Cookie{Name: SessionCookieName, Value: e.session})
|
|
}
|
|
|
|
rec := httptest.NewRecorder()
|
|
e.mux.ServeHTTP(rec, req)
|
|
|
|
return rec
|
|
}
|
|
|
|
// Обновление страницы посреди приложения открывает тот же экран: адреса у
|
|
// приложения обычные, а не после решётки, и сервер обязан отдать разметку.
|
|
func TestWebappServesMarkupOutsideServiceRoots(t *testing.T) {
|
|
env := setupWebappEnv(t, builtDist(), true)
|
|
|
|
for _, path := range []string{"/", "/records", "/records/abc123def456ghi"} {
|
|
res := env.get(path)
|
|
|
|
assert.Equal(t, http.StatusOK, res.Code, path)
|
|
assert.Contains(t, res.Body.String(), "приложение", path)
|
|
}
|
|
}
|
|
|
|
// Путь внутри корня сервиса в приложение не проваливается никогда: иначе
|
|
// программа, ошибшаяся адресом, приняла бы разметку с кодом 200 за ответ.
|
|
func TestWebappNeverAnswersInsideServiceRoots(t *testing.T) {
|
|
env := setupWebappEnv(t, builtDist(), true)
|
|
|
|
for _, path := range []string{"/api/nope", "/_/nope", "/auth/nope"} {
|
|
res := env.get(path)
|
|
|
|
assert.NotContains(t, res.Body.String(), "приложение", path)
|
|
}
|
|
}
|
|
|
|
// Под корнем приложения отказ уходит его собственной формой, и код отвечает
|
|
// причине: без сессии — «предъяви себя», с сессией — «такого адреса нет».
|
|
func TestWebappLeavesAppRootToItsOwnFailureForm(t *testing.T) {
|
|
env := setupWebappEnv(t, builtDist(), true)
|
|
|
|
anonymous := env.do(http.MethodGet, "/app/nope", false)
|
|
assert.Equal(t, http.StatusUnauthorized, anonymous.Code)
|
|
assert.NotContains(t, anonymous.Body.String(), "приложение")
|
|
|
|
signedIn := env.do(http.MethodGet, "/app/nope", true)
|
|
assert.Equal(t, http.StatusNotFound, signedIn.Code)
|
|
assert.Contains(t, signedIn.Body.String(), CodeNotFound)
|
|
}
|
|
|
|
// Принадлежность корню — два условия разом. По одному префиксу корню досталось
|
|
// бы постороннее имя, по одному префиксу с косой чертой голый корень не достался
|
|
// бы никому и уехал бы разметкой.
|
|
func TestWebappRootMatchNeedsExactOrSlash(t *testing.T) {
|
|
env := setupWebappEnv(t, builtDist(), true)
|
|
|
|
bare := env.get(StorageRoot)
|
|
assert.NotContains(t, bare.Body.String(), "приложение", "голый корень разметкой не подменяется")
|
|
|
|
neighbour := env.get(AppRoot + "le")
|
|
assert.Equal(t, http.StatusOK, neighbour.Code)
|
|
assert.Contains(t, neighbour.Body.String(), "приложение", "посторонний путь остаётся приложению")
|
|
}
|
|
|
|
// Разметка прежней сборки называет ресурсы прежней сборки. Подменить их
|
|
// разметкой значит ответить 200 на то, чего нет.
|
|
func TestWebappMissingAssetAnswersNotFound(t *testing.T) {
|
|
env := setupWebappEnv(t, builtDist(), true)
|
|
|
|
res := env.get("/assets/index-obsolete.js")
|
|
|
|
assert.Equal(t, http.StatusNotFound, res.Code)
|
|
assert.NotContains(t, res.Body.String(), "приложение")
|
|
}
|
|
|
|
// Сам каталог сборщика — тот же случай, что и файл под ним: правило держится
|
|
// парой условий, и по одному префиксу с косой чертой голый `assets` уехал бы
|
|
// разметкой с кодом `200`.
|
|
func TestWebappAssetsDirItselfAnswersNotFound(t *testing.T) {
|
|
env := setupWebappEnv(t, builtDist(), true)
|
|
|
|
for _, path := range []string{"/assets", "/assets/"} {
|
|
res := env.get(path)
|
|
|
|
assert.Equal(t, http.StatusNotFound, res.Code, path)
|
|
assert.NotContains(t, res.Body.String(), "приложение", path)
|
|
}
|
|
}
|
|
|
|
// Метка ответа даёт `no-cache` его смысл: без неё вшитый файл не несёт времени
|
|
// правки, и браузер получает полное тело вместо подтверждения.
|
|
func TestWebappMarkupCarriesEntityTag(t *testing.T) {
|
|
env := setupWebappEnv(t, builtDist(), true)
|
|
|
|
tag := env.get("/").Result().Header.Get("ETag")
|
|
|
|
assert.NotEmpty(t, tag)
|
|
}
|
|
|
|
// Адреса наблюдения приложением не подменяются.
|
|
func TestWebappLeavesObservationAddresses(t *testing.T) {
|
|
env := setupWebappEnv(t, builtDist(), true)
|
|
|
|
res := env.get(HealthPath)
|
|
|
|
assert.Equal(t, http.StatusOK, res.Code)
|
|
assert.Contains(t, res.Body.String(), "ok")
|
|
}
|
|
|
|
// Открывающими страницу считаются GET и HEAD, и только они.
|
|
func TestWebappMethods(t *testing.T) {
|
|
env := setupWebappEnv(t, builtDist(), true)
|
|
|
|
head := env.do(http.MethodHead, "/records", false)
|
|
assert.Equal(t, http.StatusOK, head.Code)
|
|
|
|
post := env.do(http.MethodPost, "/records", false)
|
|
assert.Equal(t, http.StatusMethodNotAllowed, post.Code)
|
|
assert.NotContains(t, post.Body.String(), "приложение")
|
|
}
|
|
|
|
// Долгий срок хранения ставится по каталогу, а не по виду файла: отозвать его у
|
|
// браузера сервису нечем.
|
|
func TestWebappCacheHeaders(t *testing.T) {
|
|
env := setupWebappEnv(t, builtDist(), true)
|
|
|
|
asset := env.get("/assets/index-abc123.js").Result().Header.Get("Cache-Control")
|
|
assert.Contains(t, asset, "immutable")
|
|
assert.Contains(t, asset, "max-age=31536000")
|
|
|
|
markup := env.get("/").Result().Header.Get("Cache-Control")
|
|
assert.Equal(t, "no-cache", markup)
|
|
|
|
// Файл с постоянным именем лежит в сборке, но вне каталога сборщика:
|
|
// «неизменяемо» ему не достаётся.
|
|
permanent := env.get("/favicon.ico").Result().Header.Get("Cache-Control")
|
|
assert.Equal(t, "no-cache", permanent)
|
|
}
|
|
|
|
// «Приложения нет» отличается от «адреса нет»: первое чинится сборкой, а не
|
|
// поиском опечатки в адресе. Проба здоровья при этом остаётся зелёной — сервис
|
|
// принимает записи и расшифровывает их, не работает только показ.
|
|
func TestWebappNotBuilt(t *testing.T) {
|
|
env := setupWebappEnv(t, fstest.MapFS{}, false)
|
|
|
|
res := env.get("/")
|
|
assert.Equal(t, http.StatusServiceUnavailable, res.Code)
|
|
assert.Contains(t, res.Body.String(), "не собрано")
|
|
|
|
health := env.get(HealthPath)
|
|
assert.Equal(t, http.StatusOK, health.Code)
|
|
|
|
// Владелец сервиса узнаёт об этом журналом подъёма, а не от человека,
|
|
// открывшего страницу.
|
|
assert.Contains(t, env.journal.String(), "Webapp is not built")
|
|
}
|