diff --git a/next.config.mjs b/next.config.mjs index ced4ad27..87f8b205 100644 --- a/next.config.mjs +++ b/next.config.mjs @@ -14,8 +14,15 @@ const nextConfig = { new URL('https://i.ytimg.com/**'), // Instagram media thumbnails (campaign home content board, S3) come from // the Graph API's CDN (hosts vary per region) — - // see `src/utilities/socialFeed/instagramFeed.ts`. - new URL('https://*.cdninstagram.com/**'), + // see `src/utilities/socialFeed/instagramFeed.ts`. Plain objects (not + // `new URL`) on purpose: the URL form pins `search: ''`, and Graph API + // media URLs carry a signed query string — the optimizer would reject + // them with 400 even on an allowed host. + { protocol: 'https', hostname: '*.cdninstagram.com', pathname: '/**' }, + // Meta moved Instagram Graph API media to `*.fbcdn.net` + // (`instagram..fna.fbcdn.net`, `scontent..fna.fbcdn.net`) — + // without this the optimizer answers 400 and every IG cover breaks. + { protocol: 'https', hostname: '**.fbcdn.net', pathname: '/**' }, // e2e-only: the YouTube/Instagram stubs (tests/e2e/*-stub.mjs) serve the // fixture thumbnails locally so specs never touch the real network. { protocol: 'http', hostname: 'localhost', pathname: '/thumbs/**' }, diff --git a/src/components/CampaignContentCard.tsx b/src/components/CampaignContentCard.tsx index 8571b793..4a533827 100644 --- a/src/components/CampaignContentCard.tsx +++ b/src/components/CampaignContentCard.tsx @@ -56,14 +56,16 @@ export const CampaignContentCard = ({ card, featured = false }: CampaignContentC

{card.title}

{featured && card.subtitle ? ( -

{card.subtitle}

+

+ {card.subtitle} +

) : null} {card.meta}
diff --git a/tests/e2e/frontend.e2e.spec.ts b/tests/e2e/frontend.e2e.spec.ts index 8bd49b52..4dc3445c 100644 --- a/tests/e2e/frontend.e2e.spec.ts +++ b/tests/e2e/frontend.e2e.spec.ts @@ -1263,6 +1263,15 @@ test.describe('Campaign home content section', () => { // Instagram cards render the 1:1 cover of the approved draft. await expect(featured.locator('.aspect-square').first()).toBeVisible() + // Long captions are clamped with an ellipsis so a single post cannot + // stretch the bento row (production captions run into hundreds of + // characters; the stub's reel caption is deliberately long). + const longCaptionTitle = section + .getByRole('link', { name: /E2e Reel da caravana/ }) + .locator('h3') + await expect(longCaptionTitle).toHaveCSS('-webkit-line-clamp', '3') + await expect(longCaptionTitle).toHaveCSS('overflow', 'hidden') + await expect( section.getByRole('link', { name: /E2e Artigo no mix IG/ }).first(), ).toBeVisible() diff --git a/tests/e2e/instagram-stub.mjs b/tests/e2e/instagram-stub.mjs index b083778f..fab8b2ff 100644 --- a/tests/e2e/instagram-stub.mjs +++ b/tests/e2e/instagram-stub.mjs @@ -36,7 +36,13 @@ const POSTS = [ }, { id: 'e2e-ig-reel-2', - caption: 'E2e Reel da caravana', + // Deliberately long: the card must clamp the caption (line-clamp) instead + // of stretching the bento row — production captions run into hundreds of + // characters. + caption: + 'E2e Reel da caravana — legenda longa de propósito para o card não esticar. ' + + 'A caminhada segue pelo interior da Bahia, com caravanas, encontros, ' + + 'reuniões e muito trabalho de base em cada município visitado. '.repeat(4), media_type: 'REEL', minutesAgo: 90, }, diff --git a/tests/unit/nextImageRemotePatterns.unit.spec.ts b/tests/unit/nextImageRemotePatterns.unit.spec.ts new file mode 100644 index 00000000..91f8d920 --- /dev/null +++ b/tests/unit/nextImageRemotePatterns.unit.spec.ts @@ -0,0 +1,64 @@ +// @vitest-environment node + +import { matchRemotePattern } from 'next/dist/shared/lib/match-remote-pattern' +import { describe, expect, it } from 'vitest' + +import { parseInstagramMediaResponse } from '@/utilities/socialFeed/instagramFeed' + +import nextConfig from '../../next.config.mjs' + +/** + * Regression guard for the production incident of 2026-09-11: Instagram Graph + * API media URLs come from Meta's CDN on `*.fbcdn.net` + * (`instagram..fna.fbcdn.net`, `scontent..fna.fbcdn.net`), not only + * from `*.cdninstagram.com` as the S3 implementation assumed. A media URL + * outside `images.remotePatterns` makes the Next image optimizer answer + * `400 "url" parameter is not allowed` and every Instagram card renders with a + * broken cover. The e2e Instagram stub serves thumbnails from localhost (the + * sandbox never reaches the real CDN), so it can never catch a missing real + * host — this guard pins the allowlist against the hosts production serves. + * + * The fixtures carry the signed query string (`?stp=...&_nc_ohc=...&oe=...`) + * the Graph API actually returns: a pattern declared with `new URL()` pins + * `search: ''` and the matcher then rejects every signed URL even on an + * allowed host. + */ +const remotePatterns = nextConfig.images?.remotePatterns ?? [] + +const isAllowed = (url: string): boolean => + remotePatterns.some((pattern) => matchRemotePattern(pattern, new URL(url))) + +describe('next.config images.remotePatterns', () => { + it.each([ + 'https://instagram.fssa2-1.fna.fbcdn.net/v/t51.82787-15/798590021_n.jpg?stp=dst-jpg_e35_tt6&_nc_ohc=GhQvEtCG8Bk&oe=6AAA3F4E', + 'https://scontent.fsdu2-1.fna.fbcdn.net/v/t51.82787-15/798590021_n.jpg?stp=dst-jpg_e35_tt6&_nc_ohc=GhQvEtCG8Bk&oe=6AAA3F4E', + 'https://scontent.cdninstagram.com/v/t51.82787-15/798590021_n.jpg?stp=dst-jpg_e35_tt6&_nc_ohc=GhQvEtCG8Bk&oe=6AAA3F4E', + 'https://i.ytimg.com/vi/e2evideo001/hqdefault.jpg', + ])('allows the real media CDN URL %s', (url) => { + expect(isAllowed(url)).toBe(true) + }) + + it('keeps unrelated hosts out of the optimizer allowlist', () => { + expect(isAllowed('https://evil.example.com/tracker.jpg')).toBe(false) + }) + + it('allows the thumbnail the Instagram parser emits from a real signed response', () => { + const signed = + 'https://instagram.fssa2-1.fna.fbcdn.net/v/t51.82787-15/798590021_n.jpg?stp=dst-jpg_e35_tt6&_nc_ohc=GhQvEtCG8Bk&oe=6AAA3F4E' + const posts = parseInstagramMediaResponse({ + data: [ + { + id: 'seam-1', + caption: null, + media_type: 'IMAGE', + media_url: signed, + permalink: 'https://www.instagram.com/p/seam-1/', + timestamp: '2026-09-11T10:00:00+00:00', + }, + ], + }) + const thumbnailUrl = posts[0]?.thumbnailUrl + expect(thumbnailUrl).toBe(signed) + expect(isAllowed(thumbnailUrl as string)).toBe(true) + }) +})