Files
glchat/internal/store/link_previews_test.go
grendervill a9b1073877 feat(unfurl): превью ссылок с защитой от SSRF и кэшем в БД (Фаза 7)
Сервер сам загружает заголовок, описание и картинку страницы по ссылке из
сообщения и отдаёт клиенту готовую карточку.

Безопасность (главное здесь):
- только http/https и без userinfo; запрет петли, частных сетей, link-local
  (169.254.169.254), CGNAT, multicast и IPv4-mapped вариантов;
- проверка идёт по адресу, к которому реально открывается TCP
  (`net.Dialer.Control`), поэтому подмена DNS между проверкой и соединением
  (DNS rebinding) ничего не даёт;
- не больше 3 редиректов, каждый хоп проверяется заново; таймаут 5 с, тело
  ≤ 512 КБ, только `text/html`; прокси из окружения игнорируются, cookie и
  авторизация не отправляются; в логи попадают только хост и код причины;
- картинка по ссылке не скачивается — проверяется лишь её URL: экономия CPU на
  1 vCPU и минус класс атак через декодирование.

Кэш: таблица `link_previews` (миграция 00022, ключ — sha256 нормализованного
URL), TTL по статусу (ok — сутки, empty/blocked — час, error — 10 минут).
Ручка `GET /api/v1/link-previews?url=…` отвечает статусом
(ok/empty/blocked/error) и карточкой только при ok; 20 новых загрузок в минуту
на пользователя, кэшированные ответы лимит не тратят. `UNFURL_ENABLED=false`
выключает функцию целиком, `features.unfurl_enabled` виден в `/meta`. Retention
убирает истёкшие записи кэша.

Тесты: 21 в `internal/unfurl` (включая DNS rebinding через локальный
DNS-сервер, редирект во внутреннюю сеть, таймаут, лимиты размера и типа),
ручки, store и миграция.
2026-09-26 16:15:00 +03:00

201 lines
6.4 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package store
import (
"context"
"errors"
"path/filepath"
"testing"
"time"
"glchat/internal/database"
)
// newLinkPreviewTestStore открывает временную БД со всеми миграциями.
// В пакете store это первый тест, поэтому помощник живёт здесь.
func newLinkPreviewTestStore(t *testing.T) *Store {
t.Helper()
db, err := database.Open(context.Background(), database.Options{
Path: filepath.Join(t.TempDir(), "glchat.db"),
ReadPool: 2,
Migrate: true,
})
if err != nil {
t.Fatalf("open test database: %v", err)
}
t.Cleanup(func() {
if err := db.Close(); err != nil {
t.Errorf("close test database: %v", err)
}
})
return New(db)
}
func TestLinkPreviewSaveAndGet(t *testing.T) {
ctx := context.Background()
st := newLinkPreviewTestStore(t)
fetchedAt := time.Now().UTC()
params := SaveLinkPreviewParams{
URLHash: "hash-1",
URL: "https://example.com/page",
Title: "Заголовок",
Description: "Описание",
SiteName: "Пример",
ImageURL: "https://example.com/pic.png",
Status: LinkPreviewStatusOK,
FetchedAt: fetchedAt,
ExpiresAt: fetchedAt.Add(time.Hour),
}
if err := st.SaveLinkPreview(ctx, params); err != nil {
t.Fatalf("SaveLinkPreview() вернул ошибку: %v", err)
}
preview, err := st.GetLinkPreview(ctx, params.URLHash)
if err != nil {
t.Fatalf("GetLinkPreview() вернул ошибку: %v", err)
}
if preview.URLHash != params.URLHash || preview.URL != params.URL {
t.Errorf("URLHash/URL = %q/%q, want %q/%q", preview.URLHash, preview.URL, params.URLHash, params.URL)
}
if preview.Title != params.Title || preview.Description != params.Description {
t.Errorf("Title/Description = %q/%q", preview.Title, preview.Description)
}
if preview.SiteName != params.SiteName || preview.ImageURL != params.ImageURL {
t.Errorf("SiteName/ImageURL = %q/%q", preview.SiteName, preview.ImageURL)
}
if preview.Status != LinkPreviewStatusOK {
t.Errorf("Status = %q, want %q", preview.Status, LinkPreviewStatusOK)
}
if want := st.Timestamp(params.FetchedAt); preview.FetchedAt != want {
t.Errorf("FetchedAt = %q, want %q", preview.FetchedAt, want)
}
if want := st.Timestamp(params.ExpiresAt); preview.ExpiresAt != want {
t.Errorf("ExpiresAt = %q, want %q", preview.ExpiresAt, want)
}
}
func TestLinkPreviewUpsert(t *testing.T) {
ctx := context.Background()
st := newLinkPreviewTestStore(t)
now := time.Now().UTC()
first := SaveLinkPreviewParams{
URLHash: "hash-upsert",
URL: "https://example.com/page",
Title: "Старый заголовок",
Status: LinkPreviewStatusEmpty,
FetchedAt: now,
ExpiresAt: now.Add(time.Hour),
}
if err := st.SaveLinkPreview(ctx, first); err != nil {
t.Fatalf("SaveLinkPreview() вернул ошибку: %v", err)
}
second := SaveLinkPreviewParams{
URLHash: first.URLHash,
URL: first.URL,
Title: "Новый заголовок",
Description: "Новое описание",
ImageURL: "https://example.com/new.png",
Status: LinkPreviewStatusOK,
FetchedAt: now.Add(time.Minute),
ExpiresAt: now.Add(2 * time.Hour),
}
if err := st.SaveLinkPreview(ctx, second); err != nil {
t.Fatalf("SaveLinkPreview() при перезаписи вернул ошибку: %v", err)
}
preview, err := st.GetLinkPreview(ctx, first.URLHash)
if err != nil {
t.Fatalf("GetLinkPreview() вернул ошибку: %v", err)
}
if preview.Title != second.Title || preview.Description != second.Description {
t.Errorf("после upsert Title/Description = %q/%q, want %q/%q",
preview.Title, preview.Description, second.Title, second.Description)
}
if preview.Status != LinkPreviewStatusOK {
t.Errorf("после upsert Status = %q, want %q", preview.Status, LinkPreviewStatusOK)
}
if want := st.Timestamp(second.ExpiresAt); preview.ExpiresAt != want {
t.Errorf("после upsert ExpiresAt = %q, want %q", preview.ExpiresAt, want)
}
var count int
if err := st.Reader().QueryRowContext(ctx,
`SELECT COUNT(*) FROM link_previews WHERE url_hash = ?`, first.URLHash).Scan(&count); err != nil {
t.Fatalf("подсчёт строк: %v", err)
}
if count != 1 {
t.Errorf("строк с тем же url_hash = %d, want 1", count)
}
}
func TestLinkPreviewExpiredNotFound(t *testing.T) {
ctx := context.Background()
st := newLinkPreviewTestStore(t)
now := time.Now().UTC()
params := SaveLinkPreviewParams{
URLHash: "hash-expired",
URL: "https://example.com/old",
Title: "Просроченное",
Status: LinkPreviewStatusOK,
FetchedAt: now.Add(-2 * time.Hour),
ExpiresAt: now.Add(-time.Hour),
}
if err := st.SaveLinkPreview(ctx, params); err != nil {
t.Fatalf("SaveLinkPreview() вернул ошибку: %v", err)
}
if _, err := st.GetLinkPreview(ctx, params.URLHash); !errors.Is(err, ErrNotFound) {
t.Fatalf("GetLinkPreview() err = %v, want ErrNotFound", err)
}
}
func TestDeleteExpiredLinkPreviews(t *testing.T) {
ctx := context.Background()
st := newLinkPreviewTestStore(t)
now := time.Now().UTC()
fresh := SaveLinkPreviewParams{
URLHash: "hash-fresh",
URL: "https://example.com/fresh",
Title: "Свежее",
Status: LinkPreviewStatusOK,
FetchedAt: now,
ExpiresAt: now.Add(time.Hour),
}
expired := SaveLinkPreviewParams{
URLHash: "hash-old",
URL: "https://example.com/old",
Title: "Просроченное",
Status: LinkPreviewStatusError,
FetchedAt: now.Add(-2 * time.Hour),
ExpiresAt: now.Add(-time.Hour),
}
for _, params := range []SaveLinkPreviewParams{fresh, expired} {
if err := st.SaveLinkPreview(ctx, params); err != nil {
t.Fatalf("SaveLinkPreview(%q) вернул ошибку: %v", params.URLHash, err)
}
}
deleted, err := st.DeleteExpiredLinkPreviews(ctx, now)
if err != nil {
t.Fatalf("DeleteExpiredLinkPreviews() вернул ошибку: %v", err)
}
if deleted != 1 {
t.Errorf("удалено записей = %d, want 1", deleted)
}
var left int
if err := st.Reader().QueryRowContext(ctx, `SELECT COUNT(*) FROM link_previews`).Scan(&left); err != nil {
t.Fatalf("подсчёт строк: %v", err)
}
if left != 1 {
t.Errorf("в таблице осталось %d записей, want 1", left)
}
if _, err := st.GetLinkPreview(ctx, fresh.URLHash); err != nil {
t.Errorf("свежая запись недоступна: %v", err)
}
}