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") }