приложение собрано каркасом и вшито в бинарник
- заведён каталог web/ — Vue 3, роутер пятой версии, сборка Vite; собранное вшивается через go:embed и раздаётся корневым маршрутом: разметка на неизвестном пути вне корней сервиса, отказ контракта внутри корня - перечень корней сервиса стал единой точкой и порождает регистрацию маршрутов, а не описывает её; журнал раздачи пишет исход и длину пути, но не сам путь - шаг front зовёт Node контейнером docker — Biome, юнит-тесты Vue и сборка входят в гейт, а в Dockerfile появилась ступень приложения
This commit is contained in:
@@ -93,8 +93,14 @@ func (h *AuthHandler) Register(r *router.Router[*core.RequestEvent]) {
|
||||
// своим, и перехватить его можно только слоем.
|
||||
r.Bind(BlockSessionRefresh())
|
||||
|
||||
r.GET("/auth/login", h.Login)
|
||||
r.GET("/auth/callback", h.Callback)
|
||||
// Адреса вешаются группой корня, а не литералами: корень объявлен единой
|
||||
// точкой адресного пространства, и порознь записанный он разошёлся бы с ней
|
||||
// молча — раздача приложения начала бы отдавать разметку на возврат от
|
||||
// провайдера, без единой ошибки в журнале.
|
||||
auth := r.Group(AuthRoot)
|
||||
|
||||
auth.GET("/login", h.Login)
|
||||
auth.GET("/callback", h.Callback)
|
||||
// Выход берёт POST намеренно: по GET его срабатывание уносится переходом по
|
||||
// чужой ссылке.
|
||||
//
|
||||
@@ -102,7 +108,7 @@ func (h *AuthHandler) Register(r *router.Router[*core.RequestEvent]) {
|
||||
// обесценивать, — он убрал бы куку и отчитался успехом, оставив унесённое
|
||||
// значение годным. Требования сессии при этом нет: выход без неё убирает
|
||||
// куку и молчит.
|
||||
r.POST("/auth/logout", h.Logout).Bind(SessionFromCookie())
|
||||
auth.POST("/logout", h.Logout).Bind(SessionFromCookie())
|
||||
}
|
||||
|
||||
// Login уводит человека к провайдеру, запомнив состояние и проверочный код
|
||||
|
||||
@@ -0,0 +1,339 @@
|
||||
package http
|
||||
|
||||
import (
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"io/fs"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"path"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/pocketbase/pocketbase/apis"
|
||||
"github.com/pocketbase/pocketbase/core"
|
||||
"github.com/pocketbase/pocketbase/tools/router"
|
||||
)
|
||||
|
||||
// Корни адресного пространства и отдельные адреса наблюдения.
|
||||
//
|
||||
// `/api` и `/_` принадлежат хранилищу: первый — его наборам адресов, второй —
|
||||
// панели владельца. Поменять их нельзя, это литералы библиотеки.
|
||||
const (
|
||||
StorageRoot = "/api"
|
||||
PanelRoot = "/_"
|
||||
AuthRoot = "/auth"
|
||||
|
||||
HealthPath = "/health"
|
||||
MetricsPath = "/metrics"
|
||||
)
|
||||
|
||||
// assetsDir — каталог, который наполняет сборщик приложения.
|
||||
//
|
||||
// Имена в нём строит он же и несёт в них отпечаток содержимого, поэтому
|
||||
// изменившийся ресурс приезжает под новым именем. Отсюда два правила разом:
|
||||
// такой ресурс отдаётся с долгим сроком хранения, а не совпавший с файлом путь
|
||||
// под этим каталогом отвечает отсутствием, а не разметкой.
|
||||
const assetsDir = "assets"
|
||||
|
||||
// Срок хранения ресурса сборщика — год.
|
||||
const assetMaxAgeSeconds = 31536000
|
||||
|
||||
// Mount — часть адресного пространства, принадлежащая сервису.
|
||||
//
|
||||
// Перечень этих частей — **единственное** описание того, что сервису
|
||||
// принадлежит, и он не описывает регистрацию, а порождает её: корень,
|
||||
// заведённый мимо перечня, не получит обработчика вовсе. Прежде такой перечень
|
||||
// был бы вторым описанием таблицы маршрутов, которую ведут три места, и корень,
|
||||
// забытый в нём, молча отдавал бы разметку там, где программа ждёт отказ
|
||||
// контракта.
|
||||
type Mount struct {
|
||||
// Path — корень либо точный адрес.
|
||||
Path string
|
||||
|
||||
// Exact — путь является точным адресом, а не корнем: `/health` накрывает
|
||||
// только сам себя, а `/app` — всё, что под ним.
|
||||
Exact bool
|
||||
|
||||
// Bind вешает обработчики этой части. Пусто у того, что вешает библиотека.
|
||||
Bind func(r *router.Router[*core.RequestEvent])
|
||||
}
|
||||
|
||||
// Covers говорит, принадлежит ли путь этой части адресного пространства.
|
||||
//
|
||||
// Условий два, и оба обязательны: точное совпадение либо префикс **вместе с
|
||||
// косой чертой**. По одному префиксу корню `/app` достался бы посторонний
|
||||
// `/apple`; по одному префиксу с косой чертой голый `/api` не достался бы
|
||||
// никому и уехал бы разметкой приложения.
|
||||
func (m Mount) Covers(requestPath string) bool {
|
||||
if m.Exact {
|
||||
return requestPath == m.Path
|
||||
}
|
||||
|
||||
return requestPath == m.Path || strings.HasPrefix(requestPath, m.Path+"/")
|
||||
}
|
||||
|
||||
// ServiceMounts перечисляет адресное пространство сервиса целиком.
|
||||
func ServiceMounts(
|
||||
appHandler *AppHandler,
|
||||
authHandler *AuthHandler,
|
||||
metricsHandler http.Handler,
|
||||
) []Mount {
|
||||
return []Mount{
|
||||
{Path: StorageRoot},
|
||||
{Path: PanelRoot},
|
||||
{Path: AppRoot, Bind: appHandler.Register},
|
||||
{Path: AuthRoot, Bind: authHandler.Register},
|
||||
{Path: HealthPath, Exact: true, Bind: bindHealth},
|
||||
{Path: MetricsPath, Exact: true, Bind: bindMetrics(metricsHandler)},
|
||||
}
|
||||
}
|
||||
|
||||
// RegisterServiceRoutes вешает всё, что сервис вешает сам.
|
||||
func RegisterServiceRoutes(r *router.Router[*core.RequestEvent], mounts []Mount) {
|
||||
for _, mount := range mounts {
|
||||
if mount.Bind != nil {
|
||||
mount.Bind(r)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// IsServiceAddress говорит, принадлежит ли путь сервису хоть какой-то частью.
|
||||
func IsServiceAddress(mounts []Mount, requestPath string) bool {
|
||||
for _, mount := range mounts {
|
||||
if mount.Covers(requestPath) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
// IsObservationAddress говорит, что путь — адрес наблюдения.
|
||||
//
|
||||
// Опрос здоровья и метрик идёт постоянно и полезного не несёт, поэтому уровень
|
||||
// журнала у него свой. Перечень при этом тот же самый: второе перечисление этих
|
||||
// адресов разошлось бы с первым молча.
|
||||
func IsObservationAddress(mounts []Mount, requestPath string) bool {
|
||||
for _, mount := range mounts {
|
||||
if mount.Exact && mount.Covers(requestPath) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
func bindHealth(r *router.Router[*core.RequestEvent]) {
|
||||
r.GET(HealthPath, func(e *core.RequestEvent) error {
|
||||
return e.JSON(http.StatusOK, map[string]string{
|
||||
"status": "ok",
|
||||
"message": "Transcriber service is running",
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
func bindMetrics(handler http.Handler) func(r *router.Router[*core.RequestEvent]) {
|
||||
return func(r *router.Router[*core.RequestEvent]) {
|
||||
r.GET(MetricsPath, func(e *core.RequestEvent) error {
|
||||
handler.ServeHTTP(e.Response, e.Request)
|
||||
return nil
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// notBuiltPage — что видит человек у бинарника без собранного приложения.
|
||||
//
|
||||
// Состояние возможно только у собранного мимо набора проверок: и набор
|
||||
// проверок, и сборка образа собирают приложение раньше бинарника.
|
||||
const notBuiltPage = `<!doctype html>
|
||||
<html lang="ru">
|
||||
<head><meta charset="utf-8"><title>Приложение не собрано</title></head>
|
||||
<body><h1>Приложение не собрано</h1>
|
||||
<p>Бинарник собран без приложения. Соберите его и соберите бинарник заново.</p>
|
||||
</body>
|
||||
</html>`
|
||||
|
||||
// Исход раздачи для журнала. Сам путь в журнал не идёт: множеством его значений
|
||||
// распоряжается спрашивающий, а до появления раздачи такой путь ловил отказ
|
||||
// маршрутизатора и успешным ответом не был.
|
||||
const (
|
||||
OutcomeMarkup = "markup"
|
||||
OutcomeAsset = "asset"
|
||||
OutcomeFailure = "failure"
|
||||
)
|
||||
|
||||
// journalOutcomeKey — под каким ключом раздача оставляет исход слою журнала.
|
||||
const journalOutcomeKey = "transcriberWebappOutcome"
|
||||
|
||||
// WebappOutcome отдаёт исход, оставленный раздачей, либо пустую строку, если
|
||||
// запрос до неё не дошёл.
|
||||
func WebappOutcome(e *core.RequestEvent) string {
|
||||
outcome, ok := e.Get(journalOutcomeKey).(string)
|
||||
if !ok {
|
||||
return ""
|
||||
}
|
||||
|
||||
return outcome
|
||||
}
|
||||
|
||||
// WebappHandler раздаёт собранное приложение и держит правило неизвестного пути.
|
||||
type WebappHandler struct {
|
||||
dist fs.FS
|
||||
built bool
|
||||
mounts []Mount
|
||||
fingerprint string
|
||||
logger *slog.Logger
|
||||
}
|
||||
|
||||
// NewWebappHandler собирает раздачу приложения.
|
||||
//
|
||||
// Файловая система приходит параметром, а не тянется пакетом: так тест
|
||||
// подставляет свою сборку, не собирая приложение.
|
||||
func NewWebappHandler(dist fs.FS, built bool, mounts []Mount, logger *slog.Logger) *WebappHandler {
|
||||
if logger == nil {
|
||||
logger = slog.Default()
|
||||
}
|
||||
|
||||
if !built {
|
||||
logger.Warn("Webapp is not built, service will answer with a placeholder page")
|
||||
return &WebappHandler{dist: dist, built: built, mounts: mounts, logger: logger}
|
||||
}
|
||||
|
||||
// Отпечаток вшитой сборки — единственное, чем «не та сборка» отличается от
|
||||
// «той». Вне набора проверок порядок шагов ничем не задан: `go run .`
|
||||
// вшивает то, что лежит с прошлого раза, а приложение при этом открывается и
|
||||
// ведёт себя как прежняя версия. Он же уходит меткой ответа с разметкой.
|
||||
fingerprint := buildFingerprint(dist)
|
||||
logger.Info("Webapp is embedded", "webapp.build", fingerprint)
|
||||
|
||||
return &WebappHandler{
|
||||
dist: dist,
|
||||
built: built,
|
||||
mounts: mounts,
|
||||
fingerprint: fingerprint,
|
||||
logger: logger,
|
||||
}
|
||||
}
|
||||
|
||||
// buildFingerprint — короткий отпечаток разметки вшитой сборки.
|
||||
//
|
||||
// Считается по самой разметке, а не по файлу, который положил бы сборщик: файл
|
||||
// пришлось бы заводить настройкой сборки, а разметка меняется вместе с именами
|
||||
// ресурсов, то есть при всякой пересборке с изменениями.
|
||||
func buildFingerprint(dist fs.FS) string {
|
||||
markup, err := fs.ReadFile(dist, "index.html")
|
||||
if err != nil {
|
||||
return "unknown"
|
||||
}
|
||||
|
||||
sum := sha256.Sum256(markup)
|
||||
|
||||
return hex.EncodeToString(sum[:])[:12]
|
||||
}
|
||||
|
||||
// Register вешает раздачу на корень.
|
||||
//
|
||||
// Маршрут стоит ровно на `/`, а не на `/{path...}`: сборка маршрутов сама
|
||||
// вешает на `/` отказ «ничего не совпало», если такого маршрута ещё нет, и
|
||||
// второй всепокрывающий образец рядом с ним спорил бы с ним за путь.
|
||||
func (h *WebappHandler) Register(r *router.Router[*core.RequestEvent]) {
|
||||
// Успешная раздача в журнал хранилища не пишется.
|
||||
//
|
||||
// Журнал хранилища — второй, помимо журнала контейнера, и в него библиотека
|
||||
// кладёт путь запроса целиком вместе с адресом отправителя, храня строки
|
||||
// пять суток. Путь здесь выбирает спрашивающий, и без этого отказа всякое
|
||||
// открытие приложения оставляло бы там его текст и его адрес. Готовая
|
||||
// раздача статики ставит тот же признак первой строкой; своя написана мимо
|
||||
// неё, и признак перенесён руками.
|
||||
//
|
||||
// Отказы записываются по-прежнему: признак снимает только успех.
|
||||
r.Any("/", h.Serve).Bind(apis.SkipSuccessActivityLog())
|
||||
}
|
||||
|
||||
// Serve отдаёт приложение либо отказ — по порядку, объявленному дизайном.
|
||||
func (h *WebappHandler) Serve(e *core.RequestEvent) error {
|
||||
requestPath := e.Request.URL.Path
|
||||
|
||||
// Путь принадлежит сервису — значит под этим корнем такого адреса просто
|
||||
// нет. Отказ уходит формой библиотеки, то есть тем же, чем этот корень
|
||||
// отвечает и сегодня: своя форма здесь была бы второй.
|
||||
if IsServiceAddress(h.mounts, requestPath) {
|
||||
e.Set(journalOutcomeKey, OutcomeFailure)
|
||||
return router.NewNotFoundError("", nil)
|
||||
}
|
||||
|
||||
if e.Request.Method != http.MethodGet && e.Request.Method != http.MethodHead {
|
||||
e.Set(journalOutcomeKey, OutcomeFailure)
|
||||
return router.NewApiError(http.StatusMethodNotAllowed, "", nil)
|
||||
}
|
||||
|
||||
if !h.built {
|
||||
e.Set(journalOutcomeKey, OutcomeFailure)
|
||||
return e.HTML(http.StatusServiceUnavailable, notBuiltPage)
|
||||
}
|
||||
|
||||
name := strings.TrimPrefix(path.Clean(requestPath), "/")
|
||||
if name == "" || name == "." {
|
||||
return h.serveIndex(e)
|
||||
}
|
||||
|
||||
if info, err := fs.Stat(h.dist, name); err == nil && !info.IsDir() {
|
||||
e.Set(journalOutcomeKey, OutcomeAsset)
|
||||
h.setCacheHeader(e, name)
|
||||
return e.FileFS(h.dist, name)
|
||||
}
|
||||
|
||||
// Разметка прежней сборки называет ресурсы прежней сборки. Подменить их
|
||||
// разметкой значит ответить `200` на то, чего нет: браузер отвергнет её по
|
||||
// типу содержимого, человек увидит пустой экран, а в кодах ответов сервиса
|
||||
// не останется ничего.
|
||||
if underAssets(name) {
|
||||
e.Set(journalOutcomeKey, OutcomeFailure)
|
||||
return router.NewNotFoundError("", nil)
|
||||
}
|
||||
|
||||
return h.serveIndex(e)
|
||||
}
|
||||
|
||||
func (h *WebappHandler) serveIndex(e *core.RequestEvent) error {
|
||||
e.Set(journalOutcomeKey, OutcomeMarkup)
|
||||
h.setCacheHeader(e, "index.html")
|
||||
|
||||
// Отпечаток идёт метке ответа: `no-cache` обещает дешёвую проверку —
|
||||
// «спроси заново и получи подтверждение», — а вшитый файл не несёт времени
|
||||
// правки вовсе, и без метки браузеру каждый раз отдаётся полное тело вместо
|
||||
// подтверждения. Отпечаток уже посчитан при подъёме, второго счёта не надо.
|
||||
if h.fingerprint != "" {
|
||||
e.Response.Header().Set("ETag", `"`+h.fingerprint+`"`)
|
||||
}
|
||||
|
||||
return e.FileFS(h.dist, "index.html")
|
||||
}
|
||||
|
||||
// setCacheHeader назначает срок хранения по каталогу, а не по виду файла.
|
||||
//
|
||||
// Вид файла признака не даёт: в сборке лежат и файлы с постоянными именами —
|
||||
// иконка, манифест, — и такой файл, однажды отданный как неизменяемый, не
|
||||
// отзывается со стороны сервиса ничем. Запросов он больше не увидит.
|
||||
// underAssets говорит, ведёт ли путь в каталог сборщика.
|
||||
//
|
||||
// Условий два, и оба обязательны — та же пара, что у принадлежности корню: сам
|
||||
// каталог и всё, что под ним. По одному префиксу с косой чертой голый `assets`
|
||||
// не попал бы никуда и уехал бы разметкой с кодом `200` — то есть ровно тем
|
||||
// ответом, который правило запрещает.
|
||||
func underAssets(name string) bool {
|
||||
return name == assetsDir || strings.HasPrefix(name, assetsDir+"/")
|
||||
}
|
||||
|
||||
func (h *WebappHandler) setCacheHeader(e *core.RequestEvent, name string) {
|
||||
if underAssets(name) {
|
||||
e.Response.Header().Set(
|
||||
"Cache-Control",
|
||||
"public, max-age="+strconv.Itoa(assetMaxAgeSeconds)+", immutable",
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
e.Response.Header().Set("Cache-Control", "no-cache")
|
||||
}
|
||||
@@ -0,0 +1,253 @@
|
||||
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")
|
||||
}
|
||||
Reference in New Issue
Block a user