From 50a8435c5eccaedc0b3cc3d8a1bf021ee4de629b Mon Sep 17 00:00:00 2001 From: Francisco Solla Date: Wed, 23 Sep 2026 13:48:29 -0300 Subject: [PATCH] =?UTF-8?q?fix(S29-FOLLOWUP):=20.ics=20do=20link=20de=20an?= =?UTF-8?q?=C3=BAncio=20e=20feed=20=E2=80=94=20folding=20RFC=205545=20(75?= =?UTF-8?q?=20octets)=20no=20owner=20ical.ts=20(Closes=20#1281)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../2026-09-23-s29-followup-ics-folding.md | 1 + ...ics-do-link-de-anuncio-folding-rfc-impl.md | 97 ++++++++++++ src/lib/calendarEvent.ts | 4 +- src/lib/ical.ts | 36 +++++ src/utilities/calendarFeed.ts | 4 +- tests/e2e/campaignAgendaFeed.e2e.spec.ts | 13 +- tests/int/calendarFeed.int.spec.ts | 10 +- tests/unit/ical.unit.spec.ts | 141 ++++++++++++++++++ 8 files changed, 295 insertions(+), 11 deletions(-) create mode 100644 docs/changelog/2026-09-23-s29-followup-ics-folding.md create mode 100644 docs/plans/s29-followup-ics-do-link-de-anuncio-folding-rfc-impl.md create mode 100644 tests/unit/ical.unit.spec.ts diff --git a/docs/changelog/2026-09-23-s29-followup-ics-folding.md b/docs/changelog/2026-09-23-s29-followup-ics-folding.md new file mode 100644 index 00000000..2d219a4f --- /dev/null +++ b/docs/changelog/2026-09-23-s29-followup-ics-folding.md @@ -0,0 +1 @@ +**S29-FOLLOWUP — `.ics` do link de anúncio: folding RFC 5545 (75 octets) no owner `ical.ts` (Issue #1281, 2026-09-23):** débito triado na review do S29 (#1268/PR #1280) — o `.ics` do link de anúncio e o feed da agenda emitiam `DESCRIPTION` (e qualquer linha) inteira numa linha só, podendo passar de 75 octets; Apple/Outlook toleravam, parser estrito poderia truncar. O owner compartilhado `src/lib/ical.ts` ganha `foldICalLine`: linha lógica → físicas dobradas com `CRLF + 1 espaço` (1ª ≤75 octets, continuações com payload ≤74, o espaço conta), medindo octets UTF-8 reais com `TextEncoder`/`TextDecoder` (web-standard — o módulo continua client-safe, sem `server-only`/`Buffer`, porque `calendarEvent` chega ao bundle pelo `ShareLinkAgendaMenu`) e cortando sempre em boundary de code point (nunca parte "ç"/"í"/"中"); linha ≤75 sai idêntica (fast-path), então nenhum conteúdo/ETag de feed curto muda. Wiring nos 2 call sites (`buildCalendarEventIcs` e `generateICalFeed`) via `lines.map(foldICalLine).join('\r\n')`; sem migration, sem rota, sem URL nova. Unit novo `tests/unit/ical.unit.spec.ts` (8 casos: identidade, 76 dobrando, ASCII longo com boundary 75/74, multi-byte sem `\uFFFD`, backoff em boundary e na continuação, os 2 builders) e o e2e `campaignAgendaFeed` passou a desdobrar (`unfoldICal`, `replace(/\r\n /g, '')`) antes de casar título lógico — o run local afetado pegou as 2 asserções ingênuas de substring cru; 9/9 verdes. Deferidos com gatilho no `*-impl.md`: escaping do `DESCRIPTION` do feed (newline cru do usuário) e helper de unfold compartilhado unit/e2e. diff --git a/docs/plans/s29-followup-ics-do-link-de-anuncio-folding-rfc-impl.md b/docs/plans/s29-followup-ics-do-link-de-anuncio-folding-rfc-impl.md new file mode 100644 index 00000000..a9a2c757 --- /dev/null +++ b/docs/plans/s29-followup-ics-do-link-de-anuncio-folding-rfc-impl.md @@ -0,0 +1,97 @@ +# Impl: S29-FOLLOWUP — .ics do link de anúncio: folding RFC 5545 (75 octets) no owner ical.ts + +Status: aprovado +Atualizado em: 2026-09-23 +Issue: #1281 +Intenção: Issue #1281 (body = spec; sem arquivo de intenção no repo — triagem da review do S29, #1268 / PR #1280) +Appetite restante: herdado — P3/chore de review, sem appetite explícito na intenção; corte explícito: ~meio dia (1 primitiva pura no owner + 2 wirings + 1 spec unit), sem tocar rota, admin, schema ou UI. + +## Leitura da intenção + +- **Outcome**: o `.ics` do link de anúncio e o feed da agenda passam a emitir linhas físicas ≤75 octets (RFC 5545 §3.1), dobrando com `CRLF + 1 espaço` e sem partir caractere multi-byte. Após unfold (`replace(/\r\n /g, '')`), o texto lógico — campos, escapes, datas, ordem — é byte-a-byte o de hoje; o contrato das rotas (`/slug/evento.ics`, feed `/campanha/agenda/ical/...`) e o comportamento de Apple/Outlook ficam idênticos. +- **O que NÃO negociar**: owner único `src/lib/ical.ts` (escopo explícito do issue); os 2 call sites `lib/calendarEvent.ts` e `utilities/calendarFeed.ts`; `lib/` client-safe (sem `server-only`/`Buffer` — `calendarEvent` é importado por `ShareLinkAgendaMenu` `'use client'`); separador CRLF; specs existentes verdes (asserções exatas de CRLF em `calendarEvent.unit.spec.ts:91-99`); sem schema/migration, sem mudança de URL ou de conteúdo. +- **O que reavaliar**: nada de produto neste delivery. Se um parser estrito real quebrar, o gatilho é a evidência concreta. O `DESCRIPTION` do feed hoje interpola as partes cruas (sem `escapeICalText`) — concern pré-existente e separado, não entra aqui. + +## Abordagem recomendada + +```mermaid +flowchart LR + A["buildCalendarEventIcs
lines: string[]"] --> C["lines.map(foldICalLine)
.join(CRLF)"] + B["generateICalFeed
lines: string[]"] --> C + C --> D["src/lib/ical.ts
foldICalLine"] + D --> E["TextEncoder
octets UTF-8"] + E --> F["1ª física ≤75 octets
continuação = CRLF + espaço + ≤74
corte em boundary de code point"] +``` + +### Decisões registradas (só o caro de reverter) + +- **D1 — Assinatura do folding**: Opções: A `foldICalLine(line: string): string` (linha lógica → texto dobrado com `\r\n ` embutido) | B `foldICalLines(lines: string[]): string[]` | C folding inline em cada call site. **Recomendação: A — porque** é a menor primitiva que casa com o estilo do owner (`escapeICalText`/`formatICalDate` são 1 valor → 1 valor), mantém o `lines.join('\r\n')` existente (vira `lines.map(foldICalLine).join('\r\n')`) e dá ao unit exatamente a unidade RFC: uma linha lógica → suas físicas. **Alternativas rejeitadas**: B é pass-through raso (`lines.map` embrulhado no owner) que não adiciona conhecimento — depth check manda não criar; C duplicaria a mesma máquina nos 2 call sites e contraria o escopo ("folding no owner"). +- **D2 — Unidade de medida**: Opções: A `TextEncoder` (web-standard, global em Node ≥11, browser e jsdom) | B `Buffer.byteLength` (node-only) | C `line.length` (code units UTF-16). **Recomendação: A — porque** mede octets UTF-8 reais sem `server-only`, preservando o contrato client-safe de `lib/` (o `ical.ts` chega ao bundle via `ShareLinkAgendaMenu`). **Alternativas rejeitadas**: B quebra o client boundary (o precedente `src/lib/formData.ts:213` só é válido porque aquele arquivo é `server-only`); C mede pt-BR acentuado a menos (ex. "ç"/"í" = 2 octets mas 1 code unit) e deixaria passar linha >75 octets. +- **D3 — Regra de corte e forma de retorno**: Opções: A recebe linha lógica sem CRLF e devolve a linha com dobras (`\r\n` + 1 espaço; 1ª física ≤75 octets, continuações com payload ≤74; corte sempre em boundary de code point) | B devolver `string[]` de físicas para o call site fazer `flatMap(...).join('\r\n')`. **Recomendação: A — porque** a semântica de dobra fica inteira no owner (uma string iCal válida de saída), o join por `\r\n` continua sendo só o separador entre linhas lógicas, e o invariante de teste fica direto: `foldICalLine` curta = identidade byte-a-byte. **Alternativa rejeitada**: B espalha a semântica no call site e deixa a interface menos profunda, sem ganho. +- **D4 — Onde aplicar**: Opções: A nos 2 call sites, `lines.map(foldICalLine).join('\r\n')` antes do join | B helper de join no owner (`joinICalLines`) | C folding na rota/handler. **Recomendação: A — porque** é o único ponto onde as linhas lógicas existem por inteiro, cobre todas as linhas (não só `DESCRIPTION`) e não muda os contratos. **Alternativas rejeitadas**: B adiciona um segundo conceito ao owner para 2 usos, sem ganho sobre `.map` (e o join já é trivial); C fica longe do dado, duplicaria por superfície e não cobriria consumidores futuros do owner. +- **D5 — Testes**: Opções: A novo `tests/unit/ical.unit.spec.ts` (função + os 2 builders), mantendo os specs existentes como regressão | B só adicionar casos aos specs existentes. **Recomendação: A — porque** o owner é compartilhado e passa a ter spec próprio (o comentário do topo promete "pinned by unit tests"); B duplicaria o helper de boundary em 2 arquivos e não fixaria a primitiva. **Rejeitada**: B. + +### Componentes / mudanças + +1. `src/lib/ical.ts` — novo export `foldICalLine(line: string): string` com JSDoc do contrato (linha lógica sem CRLF; identidade se ≤75 octets; continuação `CRLF + 1 espaço`; nunca parte multi-byte). Constantes privadas `MAX_OCTETS = 75` e `CONTINUATION_PAYLOAD_OCTETS = 74` (não exportar — RFC, não API; o unit assere literais 75/74 de propósito). Algoritmo: codifica a linha uma vez com `TextEncoder`; se ≤75 octets devolve a própria string; senão caminha os bytes e escolhe o maior corte ≤75 (e ≤`end + 74` nas continuações) cujo byte seja lead de sequência UTF-8 (`(byte & 0xc0) !== 0x80`), decodificando os pedaços com `TextDecoder` e unindo com `\r\n`. Há sempre boundary válido (nenhum code point passa de 4 octets), sem loop infinito. +2. `src/lib/calendarEvent.ts` — importar `foldICalLine` e trocar o `return lines.join('\r\n')` de `buildCalendarEventIcs:93` por `lines.map(foldICalLine).join('\r\n')`. +3. `src/utilities/calendarFeed.ts` — idem em `generateICalFeed:76` (import na linha 7). +4. `tests/unit/ical.unit.spec.ts` — novo spec (detalhe na fase 1/2). +5. `docs/changelog/2026-09-23-s29-followup-ics-folding.md` — entrada de 1 linha, intro em negrito. + +Sem schema, sem migration, sem UI, sem rota nova. + +### Dados → forma + +- Entrada: `lines: string[]` lógicas, já escapadas/formatadas (`escapeICalText`/`formatICalDate`); nunca contêm CRLF real (newlines viram `\n` literal). +- Saída: o mesmo texto com dobras; `unfold` (`replace(/\r\n /g, '')`) restaura byte-a-byte o pré-folding — é o invariante dos testes. +- Exemplo (DESCRIPTION ~160 octets): 1ª física com 75 octets + `\r\n ` + 74 + `\r\n ` + resto; cada física ≤75 contando o espaço da continuação. +- Linhas curtas (todas as existentes em produção hoje além do `DESCRIPTION` longo) saem idênticas — zero diff de conteúdo/ETag para feeds sem descrição longa. + +## Fases verificáveis + +1. **Tracer — owner + unit (RED→GREEN)**: adicionar `foldICalLine` e `tests/unit/ical.unit.spec.ts` com (a) linha curta = identidade (sem CRLF acrescentado), (b) exatamente 75 octets intacta e 76 dobrando, (c) >150 octets ASCII com múltiplas continuações — cada física ≤75 e boundary exato 75/74, unfold === original, (d) acento multi-byte ("Comício" repetido) sem partir caractere: sem `\uFFFD` e unfold === original. Rodar `pnpm test:unit tests/unit/ical.unit.spec.ts`. +2. **Wiring dos 2 call sites + testes de builder**: `.map(foldICalLine)` antes do join em `buildCalendarEventIcs` e `generateICalFeed`; no mesmo spec, casos com descrição longa nos 2 builders — nenhuma física >75, unfold contém a linha lógica exata (`DESCRIPTION:...`), `BEGIN:VCALENDAR\r\n`/`END:VCALENDAR` e demais linhas intactos. Regressão: `tests/unit/calendarEvent.unit.spec.ts` e `tests/unit/calendarFeed.unit.spec.ts` verdes. +3. **Gates**: `pnpm gate:fast`; e2e afetados localmente — `pnpm test:e2e:affected` resolve `campaignAgendaFeed` + `frontendShareLink` pelo manifest (`scripts/lib/e2e-affected-manifest.mjs:112`); `pnpm push` (roda `gate:push` → `gate:ci`); changelog adicionado. Int não muda (sem access/write path), mas roda no `gate:ci`. + +## Execução — achado do e2e local (2026-09-23) + +O run local dos 2 specs afetados (`--no-deps --workers=1`) pegou 2 falhas reais: `campaignAgendaFeed.e2e.spec.ts` assertava `toContain(title)` com títulos longos de fixture (`Comício do feed completo-`) como substring crua — depois do folding a UID/SUMMARY quebram em `CRLF + espaço`. É o consumidor ingênuo que a RFC manda desdobrar: o spec ganhou o helper local `unfoldICal` (`replace(/\r\n /g, '')`) e os corpos passam a ser desdobrados antes do match; 9/9 verdes. Nenhuma mudança de produção — a superfície `campaignAgendaFeed` era exatamente a que o manifest mapeia para o owner. O `gate:ci` pegou a MESMA classe em `tests/int/calendarFeed.int.spec.ts:524` (título longo de fixture como substring crua no teste C113): mesmo helper `unfoldICal` aplicado aos 3 corpos; 18/18 verdes. + +## Rabbit holes / Não escopo (engenharia) + +- **Rate limiting da rota `/api/share-link/[slug]/live`** — deferido (fora do issue); gatilho: pico anômalo de requisições na rota. +- **Settle dev-only de 6s no e2e `frontendShareLink`** — deferido; gatilho: primeira flake local do spec. +- **Unfold/parse de ICS de entrada** (imports C165) — só geração aqui; nenhuma leitura de calendário neste delivery. +- **Escaping do `DESCRIPTION` do feed** (partes cruas, sem `escapeICalText`) — concern pré-existente; não tocar para não misturar com folding. Gatilho: quebra real de parser/consumidor por newline cru (o fold exige linha lógica sem CRLF e o call site não garante). +- **Helper de unfold compartilhado entre unit e e2e** (`unfold` × `unfoldICal`, one-liner em 2 camadas) — deferido. Gatilho: 3º consumidor de unfold. +- **Refatorar os 2 builders para um line-array compartilhado** — redesign fora de escopo; o owner `ical.ts` já é o ponto de reuso. +- **Quoted-printable/base64/parameter folding** — não emitimos esses formatos; só valores `text` simples. +- **Tornar o limite configurável/exportar constantes** — YAGNI; a RFC fixa 75 e o unit pina o literal. + +## Riscos e mitigação + +- **Quebrar as asserções exatas existentes** → identidade para ≤75 é o fast path; caso (a) + regressão dos specs de `calendarEvent`/`calendarFeed`. +- **Partir caractere multi-byte** → corte só em lead byte; caso (d) com "Comício" e ausência de `\uFFFD`. +- **Off-by-one da continuação (o espaço conta nos 75)** → caso (c) assere física ≤75 e boundary 75/74. +- **LF em vez de CRLF** → dobras usam CRLF; unfold do teste é literal `\r\n `; specs existentes pinam CRLF. +- **ETag do feed muda uma vez** para feeds com descrição longa (corpo muda) → esperado e correto (hash de conteúdo); clientes revalidam uma vez. +- **Export órfão (knip)** → `foldICalLine` tem 2 usos em `src/`. +- **`TextEncoder`/`TextDecoder` no bundle client** → web-standard, globais em Node ≥11, jsdom e browser; sem polyfill. +- **Consumidor ingênuo de corpo cru** → confirmado no e2e local: asserção de título longo como substring crua quebra com o fold. Consertado no spec com `unfoldICal`; qualquer outro consumidor de corpo cru deve desdobrar (RFC 5545 §3.1). + +## Aceite de engenharia + +- [ ] Aceite de produto da intenção ainda coberto: `.ics` e feed com nenhuma linha >75 octets, dobra `CRLF + espaço`, conteúdo lógico idêntico após unfold; Apple/Outlook e parser estrito aceitam. +- [ ] Invariantes AGENTS/engineering-standards: edit-owner sem twinning; `lib/` client-safe (sem `server-only`/`Buffer`); sem schema/migration/URL; pt-BR só em copy, identificadores em inglês; knip sem órfão; sem Consent/PII; sem write multi-collection. +- [ ] Testes de domínio previstos (unit/int) onde access/write paths mudam: não há access/write path novo — unit novo `tests/unit/ical.unit.spec.ts` (função + 2 builders) e regressão dos specs existentes; int inalterado; e2e afetados (`campaignAgendaFeed`, `frontendShareLink`) rodados localmente antes do push. + +## Self-score (decision-quality) + +1. **Decisões caras com rejeitadas:** 5 — D1 (A/B/C), D2 (A/B/C), D3 (A/B), D4 (A/B/C), D5 (A/B), todas com recomendação e porquê. +2. **Cabe no appetite:** 5 — ~meio dia: 1 função pura no owner, 2 linhas de wiring e 1 spec; sem rota/admin/UI/schema. +3. **Rabbit holes nomeados:** 5 — rate limit da `live`, settle do e2e, unfold de entrada, escaping do DESCRIPTION do feed, refactor dos builders, formatos QP/base64, limite configurável. +4. **Reusa shells/helpers:** 5 — reusa o owner `src/lib/ical.ts` (não cria helper novo) e os builders existentes; nenhuma abstração nova com <3 call sites. +5. **Intenção satisfeita:** 5 — escopo exato do issue (folding no owner + 2 call sites + unit de >75/continuação); os "Fora" seguem deferidos com gatilho. + +Média 5.0/5 — plano aprovado no modo `--auto`. diff --git a/src/lib/calendarEvent.ts b/src/lib/calendarEvent.ts index 3869847f..5af26241 100644 --- a/src/lib/calendarEvent.ts +++ b/src/lib/calendarEvent.ts @@ -1,4 +1,4 @@ -import { escapeICalText, formatICalDate } from '@/lib/ical' +import { escapeICalText, foldICalLine, formatICalDate } from '@/lib/ical' /** * S29 — pure builders for the calendar options of an announcement link @@ -90,5 +90,5 @@ export const buildCalendarEventIcs = ({ if (event.location) lines.push(`LOCATION:${escapeICalText(event.location)}`) lines.push('END:VEVENT', 'END:VCALENDAR') - return lines.join('\r\n') + return lines.map(foldICalLine).join('\r\n') } diff --git a/src/lib/ical.ts b/src/lib/ical.ts index 50fcf20e..3295b1af 100644 --- a/src/lib/ical.ts +++ b/src/lib/ical.ts @@ -10,3 +10,39 @@ export const escapeICalText = (value: string): string => export const formatICalDate = (isoString: string): string => isoString.replace(/[-:]/g, '').replace(/\.\d{3}/, '') + +// RFC 5545 §3.1 counts octets, not UTF-16 code units: the limit is 75 octets +// per physical line, and each continuation line spends one octet on its +// leading space (so 74 octets of payload). Never split a code point — the cut +// backs off to the last UTF-8 boundary. +const MAX_OCTETS = 75 +const CONTINUATION_PAYLOAD_OCTETS = MAX_OCTETS - 1 + +const textEncoder = new TextEncoder() +const textDecoder = new TextDecoder() + +/** + * Folds one logical content line into its RFC 5545 physical lines. Lines of up + * to 75 octets come back untouched; longer ones are split at the octet + * boundary with `CRLF + 1 space` and never split a multi-byte character. The + * input must not contain CRLF (logical lines only). + */ +export const foldICalLine = (line: string): string => { + const bytes = textEncoder.encode(line) + if (bytes.length <= MAX_OCTETS) return line + + const segments: string[] = [] + let start = 0 + let chunkOctets = MAX_OCTETS + + while (start < bytes.length) { + let end = Math.min(start + chunkOctets, bytes.length) + while (end > start && (bytes[end] & 0xc0) === 0x80) end -= 1 + + segments.push(textDecoder.decode(bytes.subarray(start, end))) + start = end + chunkOctets = CONTINUATION_PAYLOAD_OCTETS + } + + return segments.join('\r\n ') +} diff --git a/src/utilities/calendarFeed.ts b/src/utilities/calendarFeed.ts index f2147935..85da5492 100644 --- a/src/utilities/calendarFeed.ts +++ b/src/utilities/calendarFeed.ts @@ -4,7 +4,7 @@ import { createHash } from 'node:crypto' import { allDayCivilDateOf, allDayExclusiveEndDate } from '@/lib/activityAllDay' import { buildActivityDescriptionParts } from '@/lib/activityDescription' -import { escapeICalText, formatICalDate } from '@/lib/ical' +import { escapeICalText, foldICalLine, formatICalDate } from '@/lib/ical' import type { Activity, CalendarFeed } from '@/payload-types' import { advisorMunicipalityScopeWhere } from '@/utilities/access/shared' import type { Payload, Where } from 'payload' @@ -73,7 +73,7 @@ export const generateICalFeed = ( } lines.push('END:VCALENDAR') - return lines.join('\r\n') + return lines.map(foldICalLine).join('\r\n') } const computeFeedLastModified = (activities: Activity[], feedUpdatedAt?: string): string => { diff --git a/tests/e2e/campaignAgendaFeed.e2e.spec.ts b/tests/e2e/campaignAgendaFeed.e2e.spec.ts index d3498c27..7d6e9ea5 100644 --- a/tests/e2e/campaignAgendaFeed.e2e.spec.ts +++ b/tests/e2e/campaignAgendaFeed.e2e.spec.ts @@ -12,6 +12,11 @@ import { campaignPageChrome, expect, test } from './fixtures/campaignE2EFixtures * fetch the feed via `campaign.baseURL + path` — never the canonical origin, * which is deliberately non-resolving. */ + +// The feed folds content lines longer than 75 octets (RFC 5545 §3.1), so a +// subscriber must unfold (`CRLF + space`) before matching logical content. +const unfoldICal = (body: string): string => body.replace(/\r\n /g, '') + test.describe('Agenda — link de import (C98)', () => { test.setTimeout(90_000) @@ -65,7 +70,7 @@ test.describe('Agenda — link de import (C98)', () => { const response = await page.request.get(`${campaign.baseURL}${new URL(feedUrl).pathname}`) expect(response.status()).toBe(200) expect(response.headers()['content-type']).toContain('text/calendar') - const body = await response.text() + const body = unfoldICal(await response.text()) expect(body).toContain('BEGIN:VCALENDAR') expect(body).toContain(title) }) @@ -170,7 +175,7 @@ test.describe('Agenda — link de import (C98)', () => { expect(first.status()).toBe(200) expect(first.headers()['cache-control']).toBe('public, no-cache') expect(first.headers()['etag']).toMatch(/^"[0-9a-f]{64}"$/) - const firstBody = await first.text() + const firstBody = unfoldICal(await first.text()) expect(firstBody).toContain(titleA) expect(firstBody).not.toContain(titleB) expect(firstBody).toContain('X-PUBLISHED-TTL:PT1H') @@ -192,7 +197,7 @@ test.describe('Agenda — link de import (C98)', () => { // commitment created after the first, with a changed validator. const second = await request.get(feedUrl) expect(second.status()).toBe(200) - const secondBody = await second.text() + const secondBody = unfoldICal(await second.text()) expect(secondBody).toContain(titleA) expect(secondBody).toContain(titleB) expect(second.headers()['etag']).not.toBe(firstEtag) @@ -230,7 +235,7 @@ test.describe('Agenda — link de import (C98)', () => { }) const cancelled = await request.get(feedUrl) expect(cancelled.status()).toBe(200) - const cancelledBody = await cancelled.text() + const cancelledBody = unfoldICal(await cancelled.text()) expect(cancelledBody).not.toContain(titleB) expect(cancelledBody).toContain(titleA) diff --git a/tests/int/calendarFeed.int.spec.ts b/tests/int/calendarFeed.int.spec.ts index cf766117..ec012d5c 100644 --- a/tests/int/calendarFeed.int.spec.ts +++ b/tests/int/calendarFeed.int.spec.ts @@ -34,6 +34,10 @@ const requireOk = (result: CalendarFeedLinkResult): CalendarFeedOk => { return result } +// The feed folds content lines longer than 75 octets (RFC 5545 §3.1), so a +// subscriber must unfold (`CRLF + space`) before matching logical content. +const unfoldICal = (body: string): string => body.replace(/\r\n /g, '') + const validActivityInput = (municipalityId: number, title: string) => ({ title, tags: ['Caminhada'], @@ -520,7 +524,7 @@ describe('campaign calendar feed domain', () => { expect(first.headers.get('Cache-Control')).toBe('public, no-cache') expect(first.headers.get('ETag')).toMatch(/^"[0-9a-f]{64}"$/) expect(first.headers.get('Last-Modified')).toMatch(/ GMT$/) - const firstBody = await first.text() + const firstBody = unfoldICal(await first.text()) expect(firstBody).toContain(titleA) expect(firstBody).not.toContain(titleB) expect(firstBody).toContain('X-PUBLISHED-TTL:PT1H') @@ -537,7 +541,7 @@ describe('campaign calendar feed domain', () => { // with a changed validator — the C113 acceptance. const second = await getFeed() expect(second.status).toBe(200) - const secondBody = await second.text() + const secondBody = unfoldICal(await second.text()) expect(secondBody).toContain(titleA) expect(secondBody).toContain(titleB) expect(second.headers.get('ETag')).not.toBe(firstEtag) @@ -576,7 +580,7 @@ describe('campaign calendar feed domain', () => { overrideAccess: false, }) const cancelled = await getFeed() - const cancelledBody = await cancelled.text() + const cancelledBody = unfoldICal(await cancelled.text()) expect(cancelledBody).not.toContain(titleB) expect(cancelledBody).toContain(titleA) diff --git a/tests/unit/ical.unit.spec.ts b/tests/unit/ical.unit.spec.ts new file mode 100644 index 00000000..9b95dbfe --- /dev/null +++ b/tests/unit/ical.unit.spec.ts @@ -0,0 +1,141 @@ +import { describe, expect, it } from 'vitest' + +import { buildCalendarEventIcs } from '@/lib/calendarEvent' +import { foldICalLine } from '@/lib/ical' +import { generateICalFeed } from '@/utilities/calendarFeed' + +const encoder = new TextEncoder() + +const octets = (value: string): number => encoder.encode(value).length + +/** RFC 5545 unfold: a CRLF followed by a single space is a fold, not a line break. */ +const unfold = (value: string): string => value.replace(/\r\n /g, '') + +const physicalLines = (value: string): string[] => value.split('\r\n') + +const expectAllPhysicalLinesWithin75Octets = (value: string): void => { + for (const [index, line] of physicalLines(value).entries()) { + expect(octets(line), `physical line ${index}: ${line}`).toBeLessThanOrEqual(75) + } +} + +describe('foldICalLine', () => { + it('returns lines of up to 75 octets untouched', () => { + const short = 'SUMMARY:Comício em Salvador' + expect(foldICalLine(short)).toBe(short) + + const exact = `SUMMARY:${'a'.repeat(67)}` + expect(octets(exact)).toBe(75) + expect(foldICalLine(exact)).toBe(exact) + }) + + it('folds a 76-octet line at the boundary with a one-space continuation', () => { + const over = `SUMMARY:${'a'.repeat(68)}` + expect(octets(over)).toBe(76) + + const folded = foldICalLine(over) + const lines = physicalLines(folded) + + expect(lines).toEqual([`SUMMARY:${'a'.repeat(67)}`, ' a']) + expect(unfold(folded)).toBe(over) + }) + + it('folds long ASCII lines into physical lines of at most 75 octets', () => { + const line = `DESCRIPTION:${'x'.repeat(160)}` + const folded = foldICalLine(line) + const lines = physicalLines(folded) + + expect(lines.length).toBeGreaterThan(1) + expectAllPhysicalLinesWithin75Octets(folded) + expect(octets(lines[0])).toBe(75) + expect(octets(lines[1])).toBe(75) + expect(lines.slice(1).every((part) => part.startsWith(' '))).toBe(true) + expect(unfold(folded)).toBe(line) + }) + + it('never splits a multi-byte character across physical lines', () => { + const line = `DESCRIPTION:${'Comício '.repeat(20)}` + const folded = foldICalLine(line) + + expect(physicalLines(folded).length).toBeGreaterThan(1) + expectAllPhysicalLinesWithin75Octets(folded) + expect(folded).not.toContain('\uFFFD') + expect(unfold(folded)).toBe(line) + }) + + it('backs off to the last whole character when the boundary falls mid-character', () => { + const line = `DESCRIPTION:${'á'.repeat(40)}` + const folded = foldICalLine(line) + const [first] = physicalLines(folded) + + expect(octets(first)).toBeLessThanOrEqual(75) + expect(first.endsWith('á')).toBe(true) + expect(folded).not.toContain('\uFFFD') + expect(unfold(folded)).toBe(line) + }) + + it('backs off on a continuation line whose payload does not fit whole characters', () => { + // 74 continuation octets hold 24 three-octet characters (72) — the 25th + // would cross the limit, so the chunk must close at 72, not 74. + const line = `DESCRIPTION:${'中'.repeat(60)}` + const folded = foldICalLine(line) + const lines = physicalLines(folded) + + expectAllPhysicalLinesWithin75Octets(folded) + expect(octets(lines[0])).toBe(75) + expect(octets(lines[1])).toBe(73) + expect(folded).not.toContain('\uFFFD') + expect(unfold(folded)).toBe(line) + }) +}) + +describe('folded .ics builders', () => { + const STARTS_AT = '2026-10-03T22:00:00.000Z' + const ENDS_AT = '2026-10-04T00:00:00.000Z' + const LONG_DESCRIPTION = 'Encontro da campanha com a militância. '.repeat(6) + + it('buildCalendarEventIcs folds a long DESCRIPTION and preserves the logical content', () => { + const body = buildCalendarEventIcs({ + uid: 'plenaria@teqo.jorgesolla.com.br', + title: 'Plenária da saúde', + description: LONG_DESCRIPTION, + location: 'Online', + startsAt: STARTS_AT, + endsAt: ENDS_AT, + updatedAt: '2026-09-23T12:00:00.000Z', + }) as string + + expectAllPhysicalLinesWithin75Octets(body) + expect(unfold(body)).toContain(`DESCRIPTION:${LONG_DESCRIPTION}`) + expect(body.startsWith('BEGIN:VCALENDAR\r\n')).toBe(true) + expect(body.endsWith('END:VCALENDAR')).toBe(true) + }) + + it('generateICalFeed folds a long activity DESCRIPTION and preserves the logical content', () => { + const locality = 'Centro histórico, ao lado do elevador Lacerda e do mercado modelo '.repeat(3) + const activities = [ + { + id: 1, + slug: 'comicio-salvador', + title: 'Comício em Salvador', + status: 'confirmado' as const, + startAt: '2026-08-15T19:00:00.000Z', + endAt: '2026-08-15T22:00:00.000Z', + municipality: 1, + locality, + tags: ['comício', 'campanha'], + deputyPresent: true, + updatedAt: '2026-08-01T10:00:00.000Z', + createdAt: '2026-08-01T10:00:00.000Z', + }, + ] as unknown as Parameters[0] + + const body = generateICalFeed(activities, 'Agenda do Candidato', new Map([[1, 'Salvador']])) + + expectAllPhysicalLinesWithin75Octets(body) + expect(unfold(body)).toContain(`Local: ${locality}`) + expect(body).toContain('BEGIN:VCALENDAR\r\n') + expect(body).toContain('UID:comicio-salvador@teqo.jorgesolla.com.br\r\n') + expect(body).toContain('SUMMARY:[Salvador] Comício em Salvador\r\n') + }) +})