Files
transcriber/internal/controller/http/webapp_test.go
T
av c9b7765646 хранилище переехало с PocketBase на SQLite со своим каталогом файлов
- база своя: два пула, захват одним UPDATE ... RETURNING, шаги схемы на goose
  под файловым замком, одна миграция начальной схемы вместо семи прежних
- транспорт переписан на net/http: свои слои, свой ограничитель частоты,
  отдача файла с проверкой владельца; панель /_/ и пространство /api/ исчезли
- по находкам ревью: журнал не пишет путь под корнем приложения, ключ бюджета
  читается справа налево, узнавание известного идёт читающим пулом
2026-08-23 08:06:04 +03:00

215 lines
9.1 KiB
Go

package http
import (
"io/fs"
"net/http"
"net/http/httptest"
"testing"
"testing/fstest"
"github.com/stretchr/testify/assert"
)
// webappEnv — окружение проверок раздачи. Поверхность собирается тем же
// способом, что и боевая: маршруты порождает перечень адресного пространства, а
// не перечисление в проверке.
type webappEnv struct {
env *testEnv
}
func setupWebappEnv(t *testing.T, dist fs.FS, built bool) *webappEnv {
t.Helper()
return &webappEnv{env: setupEnv(t, envOptions{dist: dist, built: built})}
}
func (e *webappEnv) get(path string) *httptest.ResponseRecorder {
return e.do(http.MethodGet, path, false)
}
func (e *webappEnv) do(method, path string, identified bool) *httptest.ResponseRecorder {
req := httptest.NewRequest(method, path, nil)
if identified {
asUser(req, e.env.login)
}
rec := httptest.NewRecorder()
e.env.mux.ServeHTTP(rec, req)
return rec
}
// journal отдаёт перехваченный журнал окружения.
func (e *webappEnv) journal() string {
return e.env.journal.String()
}
// Обновление страницы посреди приложения открывает тот же экран: адреса у
// приложения обычные, а не после решётки, и сервер обязан отдать разметку.
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 за ответ.
//
// Корень у сервиса остался один — корень приложения; `/api` и `/_` ушли вместе
// со встроенным хранилищем, и пути под ними стали обычными путями вне корней.
func TestWebappNeverAnswersInsideServiceRoots(t *testing.T) {
env := setupWebappEnv(t, builtDist(), true)
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) {
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.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)
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(), "Webapp is not built")
}