package server import ( "net/http" "net/http/httptest" "net/url" "strconv" "strings" "testing" "time" "glchat/internal/store" "glchat/internal/unfurl" ) // withUnfurl подменяет загрузчик превью на тестовый: httptest-серверы живут // на петле, поэтому AllowPrivate разрешён только здесь. func withUnfurl(t *testing.T, srv *Server, st *store.Store) { t.Helper() srv.unfurl = &linkPreviewer{ enabled: true, fetcher: unfurl.NewFetcher(unfurl.Options{AllowPrivate: true}), store: st, now: func() time.Time { return time.Now().UTC() }, } } // htmlServer отдаёт страницу с метаданными Open Graph. func htmlServer(t *testing.T, body string) *httptest.Server { t.Helper() server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { w.Header().Set("Content-Type", "text/html; charset=utf-8") _, _ = w.Write([]byte(body)) })) t.Cleanup(server.Close) return server } func TestLinkPreviewReturnsCardAndCaches(t *testing.T) { srv, st := newPushTestServer(t, true) withUnfurl(t, srv, st) cookie := registerAndLogin(t, srv, "unfurl_user", "unfurl@example.com") page := htmlServer(t, ` текст`) rec := doJSON(t, srv, http.MethodGet, "/api/v1/link-previews?url="+urlQueryEscape(page.URL+"/page?utm=1"), "", cookie) if rec.Code != http.StatusOK { t.Fatalf("превью = %d, body = %s", rec.Code, rec.Body.String()) } payload := decodeResponse[struct { Status string `json:"status"` Preview struct { URL string `json:"url"` Title string `json:"title"` Description string `json:"description"` SiteName string `json:"site_name"` ImageURL string `json:"image_url"` } `json:"preview"` }](t, rec) if payload.Status != store.LinkPreviewStatusOK { t.Fatalf("статус = %q, body = %s", payload.Status, rec.Body.String()) } if payload.Preview.Title != "Заголовок страницы" || payload.Preview.Description != "Краткое описание" { t.Fatalf("неожиданная карточка: %+v", payload.Preview) } if payload.Preview.SiteName != "Example" { t.Fatalf("site_name = %q", payload.Preview.SiteName) } if !strings.HasPrefix(payload.Preview.ImageURL, page.URL) { t.Fatalf("относительная картинка не развёрнута: %q", payload.Preview.ImageURL) } // Второй запрос обслуживается кэшем: страница уже недоступна. page.Close() rec = doJSON(t, srv, http.MethodGet, "/api/v1/link-previews?url="+urlQueryEscape(page.URL+"/page?utm=1"), "", cookie) if rec.Code != http.StatusOK { t.Fatalf("повторный запрос = %d, body = %s", rec.Code, rec.Body.String()) } cached := decodeResponse[struct { Status string `json:"status"` Preview struct { Title string `json:"title"` } `json:"preview"` }](t, rec) if cached.Status != store.LinkPreviewStatusOK || cached.Preview.Title != "Заголовок страницы" { t.Fatalf("кэш не отдал превью: %s", rec.Body.String()) } } func TestLinkPreviewEmptyPage(t *testing.T) { srv, st := newPushTestServer(t, true) withUnfurl(t, srv, st) cookie := registerAndLogin(t, srv, "unfurl_empty", "unfurl-empty@example.com") page := htmlServer(t, `нет метаданных`) rec := doJSON(t, srv, http.MethodGet, "/api/v1/link-previews?url="+urlQueryEscape(page.URL), "", cookie) if rec.Code != http.StatusOK { t.Fatalf("ответ = %d, body = %s", rec.Code, rec.Body.String()) } payload := decodeResponse[struct { Status string `json:"status"` Preview any `json:"preview"` }](t, rec) // пустой: карточки нет. if payload.Status != store.LinkPreviewStatusEmpty || payload.Preview != nil { t.Fatalf("ожидался empty без карточки: %s", rec.Body.String()) } } func TestLinkPreviewRejectsInternalAndUnsupported(t *testing.T) { srv, st := newPushTestServer(t, true) withUnfurl(t, srv, st) cookie := registerAndLogin(t, srv, "unfurl_bad", "unfurl-bad@example.com") // Проверяем боевую конфигурацию: AllowPrivate в тестовом загрузчике // разрешает петлю, поэтому здесь собираем загрузчик как в продакшене. srv.unfurl.fetcher = unfurl.NewFetcher(unfurl.Options{}) cases := map[string]string{ "метаданные облака": "https://169.254.169.254/latest/meta-data/", "частная сеть": "https://10.1.2.3/secret", "петля": "https://127.0.0.1:8080/api/v1/meta", "file": "file:///etc/passwd", "javascript": "javascript:alert(1)", "data": "data:text/html,

x

", } for name, target := range cases { t.Run(name, func(t *testing.T) { rec := doJSON(t, srv, http.MethodGet, "/api/v1/link-previews?url="+urlQueryEscape(target), "", cookie) if rec.Code != http.StatusOK { t.Fatalf("ответ = %d, body = %s", rec.Code, rec.Body.String()) } payload := decodeResponse[struct { Status string `json:"status"` Preview any `json:"preview"` }](t, rec) if payload.Status != store.LinkPreviewStatusBlocked { t.Fatalf("статус = %q, ожидался blocked (%s)", payload.Status, rec.Body.String()) } if payload.Preview != nil { t.Fatalf("карточка не должна отдаваться: %s", rec.Body.String()) } }) } } func TestLinkPreviewNonHTMLIsError(t *testing.T) { srv, st := newPushTestServer(t, true) withUnfurl(t, srv, st) cookie := registerAndLogin(t, srv, "unfurl_json", "unfurl-json@example.com") server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { w.Header().Set("Content-Type", "application/json") _, _ = w.Write([]byte(`{"title":"я не страница"}`)) })) defer server.Close() rec := doJSON(t, srv, http.MethodGet, "/api/v1/link-previews?url="+urlQueryEscape(server.URL), "", cookie) payload := decodeResponse[struct { Status string `json:"status"` }](t, rec) if payload.Status != store.LinkPreviewStatusError { t.Fatalf("статус = %q, ожидался error (%s)", payload.Status, rec.Body.String()) } } func TestLinkPreviewDisabled(t *testing.T) { srv, _ := newPushTestServer(t, true) srv.unfurl.enabled = false cookie := registerAndLogin(t, srv, "unfurl_off", "unfurl-off@example.com") rec := doJSON(t, srv, http.MethodGet, "/api/v1/link-previews?url=https%3A%2F%2Fexample.com%2F", "", cookie) if rec.Code != http.StatusServiceUnavailable { t.Fatalf("ответ = %d, ожидался 503", rec.Code) } if code := errorCodeOf(t, rec); code != "unfurl.disabled" { t.Fatalf("код ошибки = %q", code) } } func TestLinkPreviewRequiresSession(t *testing.T) { srv, _ := newPushTestServer(t, true) rec := doJSON(t, srv, http.MethodGet, "/api/v1/link-previews?url=https%3A%2F%2Fexample.com%2F", "") if rec.Code != http.StatusUnauthorized { t.Fatalf("ответ = %d, ожидался 401", rec.Code) } } // Кэш не расходует лимит, а новые загрузки — расходуют: иначе один клиент // может гонять чужие страницы пачками. func TestLinkPreviewRateLimit(t *testing.T) { srv, st := newPushTestServer(t, true) withUnfurl(t, srv, st) cookie := registerAndLogin(t, srv, "unfurl_flood", "unfurl-flood@example.com") page := htmlServer(t, ``) limited := false for i := 0; i < 40; i++ { target := page.URL + "/page-" + itoa(i) rec := doJSON(t, srv, http.MethodGet, "/api/v1/link-previews?url="+urlQueryEscape(target), "", cookie) if rec.Code == http.StatusTooManyRequests { limited = true break } } if !limited { t.Fatal("лимит новых загрузок превью не сработал") } } func TestMetaExposesUnfurlFlag(t *testing.T) { srv, _ := newPushTestServer(t, true) rec := doJSON(t, srv, http.MethodGet, "/api/v1/meta", "") payload := decodeResponse[struct { Features struct { UnfurlEnabled bool `json:"unfurl_enabled"` } `json:"features"` }](t, rec) if !payload.Features.UnfurlEnabled { t.Fatal("meta не сообщает о включённом unfurl") } } // urlQueryEscape кодирует URL для query-параметра. func urlQueryEscape(value string) string { return url.QueryEscape(value) } // itoa нужен для уникальных адресов страниц в тесте лимита. func itoa(value int) string { return strconv.Itoa(value) }