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 = `приложение` // 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") }