From 9ad96e3a5372dc9abe50905a70d6de9fcd16f321 Mon Sep 17 00:00:00 2001 From: Francisco Solla Date: Fri, 2 Oct 2026 09:39:35 -0300 Subject: [PATCH] feat(archive): varredura e reparo de integridade do acervo no Garage (C246) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adiciona a CLI pnpm archive:integrity: varredura read-only de todas as archivePhoto (download + decode pelo mesmo caminho da rota pública e do índice facial; sai 1 enquanto houver mídia quebrada approved) e --apply que re-ingere do Flickr pelo flickrId, substitui o objeto na mesma chave via payload.update filePath (sem tocar curadoria/status) e rebaixa a draft as irrecuperáveis (removed intocado). Dono da leitura ganha inspectPrivateMediaObject e isPrivateMediaMissingError (S3 NoSuchKey/404 + ENOENT local; checksum nunca é missing); archivePhotoIngest ganha repairArchivePhotoObject e withdrawArchivePhotoFromPublic; decisão do reparo em scripts/lib/archiveIntegrityRepair.mjs com deps injetadas e puro de CLI/recibo em scripts/lib/archiveIntegrityPlan.mjs. Sem migration, sem UI. Guardas: ARCHIVE_INTEGRITY_CONFIRM + TEQO_ENV exato + 4 S3_* (em qualquer modo no alvo não-local) + FLICKR_API_KEY. Runbook C246, changelog e unit/int/e2e afetado. --- docs/changelog/2026-10-02-c246.md | 1 + docs/ops/teqo-1313-deploy.md | 73 ++++ docs/plans/acervo-integridade-garage-impl.md | 175 +++++++++ .../acervo-publish-preflight-integridade.md | 43 +++ package.json | 1 + scripts/check-archive-integrity.mjs | 344 ++++++++++++++++++ scripts/lib/archiveIntegrityPlan.mjs | 275 ++++++++++++++ scripts/lib/archiveIntegrityRepair.mjs | 146 ++++++++ scripts/lib/flickrApi.mjs | 17 +- scripts/lib/test-affected-core.mjs | 2 + src/lib/privateMedia.ts | 30 +- src/utilities/flickr/archivePhotoIngest.ts | 97 ++++- .../privateMedia/privateMediaResponse.ts | 88 ++++- tests/int/archiveIntegrity.int.spec.ts | 337 +++++++++++++++++ tests/unit/archiveIntegrityCli.unit.spec.ts | 106 ++++++ tests/unit/archiveIntegrityPlan.unit.spec.ts | 322 ++++++++++++++++ .../unit/archiveIntegrityRepair.unit.spec.ts | 231 ++++++++++++ tests/unit/flickrApi.unit.spec.ts | 35 +- tests/unit/privateMedia.unit.spec.ts | 25 ++ 19 files changed, 2326 insertions(+), 22 deletions(-) create mode 100644 docs/changelog/2026-10-02-c246.md create mode 100644 docs/plans/acervo-integridade-garage-impl.md create mode 100644 docs/plans/acervo-publish-preflight-integridade.md create mode 100644 scripts/check-archive-integrity.mjs create mode 100644 scripts/lib/archiveIntegrityPlan.mjs create mode 100644 scripts/lib/archiveIntegrityRepair.mjs create mode 100644 tests/int/archiveIntegrity.int.spec.ts create mode 100644 tests/unit/archiveIntegrityCli.unit.spec.ts create mode 100644 tests/unit/archiveIntegrityPlan.unit.spec.ts create mode 100644 tests/unit/archiveIntegrityRepair.unit.spec.ts diff --git a/docs/changelog/2026-10-02-c246.md b/docs/changelog/2026-10-02-c246.md new file mode 100644 index 00000000..0acf187b --- /dev/null +++ b/docs/changelog/2026-10-02-c246.md @@ -0,0 +1 @@ +**C246 — Integridade do acervo no Garage: varredura e recuperação das fotos quebradas (2026-10-02):** o acervo do C231/C242 ganha o comando `pnpm archive:integrity` — varredura read-only de TODAS as `archivePhoto` que baixa cada objeto pelo MESMO caminho da rota pública e do índice facial (o SDK S3 valida o checksum na resposta) e força o decode com sharp: objeto ausente é `missing`, falha de leitura (checksum) ou arquivo que não decodifica é `corrupt` com stage/motivo, e a varredura sai **1** enquanto houver mídia quebrada não-removida (critério de convergência) gravando recibo JSON em `data/archive/reports/`. Com `--apply`, cada foto acusada é recuperada do Flickr pelo `flickrId` (mesmo fallback `getSizes` da ingestão), o original baixado é decodificado como prova, o objeto é substituído **na mesma chave** pelo caminho de escrita da collection (`repairArchivePhotoObject` → `payload.update` + `filePath`, sem tocar curadoria/metadados/`publicationStatus`; a foto `approved` volta a servir sozinha) e o resultado é verificado por uma segunda passada; fonte sumida ou que não decodifica **sai do público** (`approved → draft` via `withdrawArchivePhotoFromPublic`, com purge dos descritores faciais e revalidação pelos hooks; `removed` nunca é tocado — takedown continua takedown). O dono da leitura privada ganha `inspectPrivateMediaObject` e o classificador puro `isPrivateMediaMissingError` (`src/lib/privateMedia.ts`, S3 NoSuchKey/404 + ENOENT local; checksum nunca é `missing`); a decisão do reparo é um módulo com deps injetadas (`scripts/lib/archiveIntegrityRepair.mjs`) e o puro de CLI/recibo em `scripts/lib/archiveIntegrityPlan.mjs` (`--only`/`--limit`/`--concurrency 3`/`--out`, map com concorrência limitada, recibos honestos de varredura e reparo). Guardas: `--apply` exige `ARCHIVE_INTEGRITY_CONFIRM=1` + `TEQO_ENV` casando o nome exato do banco + as 4 `S3_*` + `FLICKR_API_KEY`; alvo não-local exige S3 em QUALQUER modo (sem S3 a varredura mediria o storage errado); `ALLOW_REMOTE_DB` recusado. Sem migration e sem UI (Impeccable A); runbook com canário 80/83, varredura completa, reparo, convergência, cache-bust da tag `archivePhotos` e lembrete do `pnpm faces:index` em `docs/ops/teqo-1313-deploy.md` (C246). Testes: unit do parser/recibo/map com concorrência, das guardas da CLI por spawn, do classificador e das nove decisões do reparo com fakes; int sobre `teqo_test` do probe ok/missing/corrupt, do reparo preservando filename/curadoria/status/filesize na mesma chave, do downgrade com purge de descritores sem canal e do `removed` intocado; e2e afetado `frontendFotos`/`frontendFotosSelfie` verde. Impl plan: `docs/plans/acervo-integridade-garage-impl.md`. diff --git a/docs/ops/teqo-1313-deploy.md b/docs/ops/teqo-1313-deploy.md index 5d1d7f55..10874b54 100644 --- a/docs/ops/teqo-1313-deploy.md +++ b/docs/ops/teqo-1313-deploy.md @@ -1667,6 +1667,79 @@ aditiva; o rollback de código pode deixar a tabela (inofensiva). Desligar o comando (`FACE_FIGURE_SEED_CONFIRM`/`FACE_FIGURE_CONFIRM`). As imagens de referência são locais (nunca entram no repo nem no S3); recibos JSON em `data/face/reports/` sem vetor nem bytes. +## C246 — integridade do acervo no Garage (fotos quebradas) + +O comando `pnpm archive:integrity` varre os originais do acervo (`archivePhoto`): +baixa **cada** objeto pelo mesmo caminho da rota pública e do índice facial (o SDK +S3 valida o checksum na resposta) e força o decode com sharp. Objeto ausente é +`ausente`; falha de leitura (checksum) ou arquivo que não decodifica é +`corrompida` — o recibo nomeia cada uma com o motivo. A varredura é **read-only**, +sai **1** enquanto houver mídia quebrada **pública** (linha `approved` quebrada — +o critério de convergência; uma `draft` quebrada é registrada no recibo mas não +bloqueia, senão o rebaixamento do `--apply` nunca convergiria) e sempre grava o +recibo em `data/archive/reports/`. + +Com `--apply`, cada foto quebrada é recuperada do Flickr pelo `flickrId` (mesmo +fallback `getSizes` da ingestão C231): o original baixado é decodificado para +provar que serve, o objeto é substituído **na mesma chave** pelo caminho de +escrita da própria collection (`payload.update` + `filePath`) — curadoria, +metadados e `publicationStatus` intocados — e o recibo guarda a URL-fonte, os +bytes e o sha256. Foto cuja fonte sumiu (ou não decodifica) **sai do público** +(`approved → draft`) com registro; `removed` nunca é tocado. Rodar `--only` no +canário primeiro: + +```bash +# no homeserver: +ssh homeserver +cd ~/stack +# 1) canário read-only nas fotos do achado (80/83) — não escreve: +docker compose --profile maintenance run --rm \ + -v /srv/archive-reports:/app/data/archive \ + teqo-1313-migrate pnpm archive:integrity --only 80,83 +# 2) reparo do canário (exige TEQO_ENV + confirmação; as 4 S3_* e FLICKR_API_KEY): +docker compose --profile maintenance run --rm \ + -e TEQO_ENV=production -e ARCHIVE_INTEGRITY_CONFIRM=1 \ + -v /srv/archive-reports:/app/data/archive \ + teqo-1313-migrate pnpm archive:integrity --apply --only 80,83 +# 3) conferir as duas URLs públicas respondendo 200 (antes do lote): +# https://jorgesolla1313.com.br/fotos/80/midia e /fotos/83/midia +# 4) varredura completa (read-only; ~6,5k downloads no bucket local; sai 1 se achar): +docker compose --profile maintenance run --rm \ + -v /srv/archive-reports:/app/data/archive \ + teqo-1313-migrate pnpm archive:integrity +# 5) reparo do que a varredura acusou (mesma linha com --apply): +docker compose --profile maintenance run --rm \ + -e TEQO_ENV=production -e ARCHIVE_INTEGRITY_CONFIRM=1 \ + -v /srv/archive-reports:/app/data/archive \ + teqo-1313-migrate pnpm archive:integrity --apply +# 6) convergência: re-rodar a varredura; 0 pendências = aceite +``` + +Guardas: fora de teste o `--apply` exige `TEQO_ENV` casando o **nome exato** do +banco (`teqo_staging`/`teqo_1313`), `ARCHIVE_INTEGRITY_CONFIRM=1` e +`FLICKR_API_KEY`; alvo não-local exige as 4 `S3_*` **em qualquer modo** (sem S3 a +varredura mediria o disco efêmero do container); `ALLOW_REMOTE_DB` é recusado. +`--concurrency` (default 3) e `--limit`/`--only` são os canários; o recibo traz +varridas/ok/ausentes/corrompidas/removidas, e no reparo recuperadas/ +irrecuperáveis/falhas. A varredura **não** conserta nada: só o `--apply` escreve. + +Depois de um `--apply` que rebaixou alguma foto, buste a tag pública em um +processo que a possui (o write do CLI não revalida o servidor Next): + +```bash +curl -X POST "https://jorgesolla1313.com.br/api/revalidate?tag=archivePhotos" \ + -H "x-revalidate-secret: $REVALIDATE_SECRET" +``` + +Fotos reparadas que continuam `approved` voltam a servir sozinhas (mesma chave, +rota `force-dynamic`) — e o índice facial reprocessa sozinho o que tiver +marcador stale (`pnpm faces:index`). Uma foto rebaixada a `draft` **não** deve +voltar em lote: o `pnpm archive:publish` aprova todo draft sem checar o objeto, +então só rode o publish depois de uma varredura limpa (a foto rebaixada só sai +de draft quando a fonte for recuperada e o operador decidir publicá-la). +Rollback: sem migration nesta entrega; o comando nunca apaga objeto nem linha — +uma foto rebaixada por engano volta a `approved` pela ficha no admin (com o +canal de remoção configurado). ## C230-followup — importação do Instagram para a Central de Conteúdos diff --git a/docs/plans/acervo-integridade-garage-impl.md b/docs/plans/acervo-integridade-garage-impl.md new file mode 100644 index 00000000..8034909b --- /dev/null +++ b/docs/plans/acervo-integridade-garage-impl.md @@ -0,0 +1,175 @@ +# Impl: C246 — Integridade do acervo no Garage: achar e recuperar as fotos quebradas + +Status: aprovado (modo --auto — apresentado no chat sem pausa) +Atualizado em: 2026-10-01 +Issue: #1418 +Intenção: docs/plans/acervo-integridade-garage.md +Appetite restante: herdado (~0,5–1 dia eng + ops) — cortes explícitos: sem migration, sem UI, sem varredura de `media`/vídeos, sem paralelismo de download do Flickr, varredura com concorrência limitada (`--concurrency`, default 3), reparo só do que a varredura acusar; a execução real em produção é ops (runbook no homeserver), não o agente. + +## Leitura da intenção + +- **Outcome:** o álbum público nunca serve mídia quebrada — uma varredura read-only de TODAS as fotos acha objeto ausente/corrompido com motivo, a recuperação re-ingere do Flickr pelo `flickrId` sem duplicar linha nem tocar em curadoria, e o recibo diz o que foi varrido, achado, recuperado e falhou (irrecuperável sai do público com registro). +- **O que NÃO negociar:** sem tocar em curadoria (campos curados nunca sobrescritos — régua C231/C232); sem duplicar linha (`flickrId` unique); irrecuperável não pode ficar quebrada no público (D3); fora de escopo `media`/OPS52, vídeos e outras plataformas; read-only por padrão, escrita só com confirm explícito; produção via runbook do homeserver; nada de migration nem de `push`. +- **O que reavaliar:** (a) `sourceUrl` é a **página** do Flickr, não o arquivo (`ArchivePhoto.ts:521-524` "Página no Flickr"; `src/lib/archivePhoto.ts:107-116` monta `/photos///`) — a fonte real de recuperação é `getLargestSize` por `flickrId`, o mesmo fallback documentado da ingestão; (b) "re-ingestão existente" **não cura** linha existente: `ingestArchivePhoto` retorna cedo em `existing` sem tocar no objeto (`archivePhotoIngest.ts:95-103`) — o reparo é função nova no mesmo módulo (D1); (c) "volta ao público pelo fluxo normal" **não exige re-approve**: uma foto `approved` cujo objeto é substituído na MESMA chave volta a servir sozinha, e `draft` segue o `pnpm archive:publish` (D5); (d) o guard de canal de remoção só cobre a entrada em `approved` (`ArchivePhoto.ts:248`) — downgrade `approved → draft` nunca é bloqueado (resolve a dúvida do D3); (e) sem `S3_*` a varredura mede o disco local, não o bucket (D7). + +## Abordagem recomendada + +```mermaid +flowchart LR + subgraph CLI["scripts/check-archive-integrity.mjs — varredura read-only por padrão"] + G["guardas: DB por TEQO_ENV, S3 completo no alvo não-local; ARCHIVE_INTEGRITY_CONFIRM só no --apply"] --> L["listArchivePhotos: todas as linhas (approved/draft/removed)"] + L --> S["por foto: inspectPrivateMediaObject — download (mesmo caminho do índice facial) + decode sharp failOn:'error'"] + S -- "ok" --> R["recibo: ok"] + S -- "ausente/corrompido" --> P{"--apply?"} + P -- "não (varredura)" --> R + P -- "sim" --> F["Flickr getSizes (flickrId) → downloadUrlToFile (UA, teto) → valida decode"] + F -- "fonte ok" --> U["repairArchivePhotoObject: payload.update com filePath na MESMA chave"] + F -- "fonte sumiu" --> D["approved → draft + registro (purge/revalidação pelos hooks)"] + U --> R + D --> R + end + S --> S3[("bucket Garage (objeto privado)")] + U --> S3 + U --> DB[("linha archivePhoto")] + D --> DB + R --> J["data/archive/reports/archive-integrity-*.json + stdout"] +``` + +**Opções consideradas:** A) CLI nova de varredura + CLI de recuperação com `PutObject` direto (precedente `recover-media.mjs`) | B) estender `ingestArchivePhoto` para "curar" o `existing` | C) uma CLI nova (`archive:integrity`) cuja varredura reusa o caminho de leitura do índice facial e cujo reparo reusa o dono da escrita do upload (`payload.update` com `filePath`), com função nova no módulo de ingestão. +**Recomendação:** C — é o único desenho em que a varredura mede exatamente o que o índice facial e o proxy público medem (download real, com a validação de checksum do SDK e o decode do sharp), o reparo passa pelo dono da escrita de arquivo da collection (hooks de revalidação/purge preservados, metadados coerentes) e nada é duplicado entre `media` e `archivePhoto`; a rede do Flickr fica isolada no client já existente (`flickrApi.mjs`) e a escrita Payload fica testável em int. +**Rejeitadas:** A porque faria a CLI um segundo dono da escrita de objeto do `archivePhoto` (client S3 próprio) e pularia os hooks do ciclo de vida — o `PutObject` direto do `recover-media` existe porque lá o problema é DB-intocável, o que não é o caso; B porque mudaria o contrato de idempotência do `existing` (re-executar nunca re-baixa) e misturaria reparo com ingestão. + +### Decisões de engenharia + +**D1 — Onde vive a recuperação** + +- Opções: A) CLI dedicada com `PutObject` direto (precedente `recover-media.mjs:235`) | B) estender `ingestArchivePhoto` para curar o `existing` | C) `payload.update({ id, filePath, overwriteExistingFiles: true })` orquestrado pela CLI, com `repairArchivePhotoObject` no módulo de ingestão. +- Recomendação: C — o `payload.update` da Local API aceita `filePath` e `overwriteExistingFiles`; com o basename do temp fixado em `row.filename`, o nome/chave não mudam (`generateFileData` só renomeia quando `!overwriteExistingFiles`) e o objeto é sobrescrito na mesma chave; os hooks seguem donos da revalidação (`ArchivePhoto.ts:276`) e do purge de descritores (`:292`); `filesize`/dimensões ficam coerentes com o objeto; nenhum segundo escritor de objeto para `archivePhoto`. A função entra em `src/utilities/flickr/archivePhotoIngest.ts`, irmã de `updateArchivePhotoExif` (precedente de escrita de manutenção no mesmo módulo). +- Rejeitadas: A porque duplicaria o dono da escrita de objeto e pularia os hooks (e `recover-media` é DB-intocável por natureza, caso diferente); B porque `existing` é contrato de idempotência da ingestão ("reexecutar nunca re-baixa") e reparo não é ingestão — a extensão viraria um modo escondido do C231. + +**D2 — Varredura read-only** + +- Opções: A) baixar cada objeto pelo caminho do índice facial (`downloadPrivateMediaToFile`) e validar a decodificação com sharp `failOn:'error'` | B) `headObject` + comparação de `x-amz-checksum-crc32` sem baixar | C) amostragem. +- Recomendação: A — o GET real é a fonte da verdade: é exatamente o caminho em que as fotos 80/83 falham (o SDK valida o checksum na resposta) e o mesmo decode que a grade pública roda; um HEAD devolve o metadado armazenado (não o conteúdo recalculado) e não vê corrupção; a amostra de 20 já provou que o achado é raro e escapa. Implementar `inspectPrivateMediaObject({ media, staticDir })` no dono `privateMediaResponse.ts`: reusa `downloadPrivateMediaToFile` para um temp (limpo por foto), força o decode (`sharp({ failOn:'error' }).rotate().resize(...)`), mede bytes e classifica `ok | missing | corrupt` com `stage: 'download' | 'decode'` e motivo; reusa o classificador de ausência existente (exportar o atual `isMissingObject` como `isPrivateMediaMissingError`, uso interno intacto). CLI com `--concurrency` (default 3) e `--limit` (canary). +- Rejeitadas: B porque checksum armazenado não detecta corrupção de conteúdo (o mismatch só aparece no GET) e objeto truncado sem checksum passa; C porque o problema é raro e a amostragem falha no único caso que importa. + +**D3 — Fotos irrecuperáveis** + +- Opções: A) `draft` + registro no recibo | B) `removed` | C) ficar como está e só reportar. +- Recomendação: A — `payload.update({ publicationStatus: 'draft' })` com `overrideAccess: true` (ator confiável sem sessão, precedente C231/C242); o guard `requireRemovalChannelForApproval` só dispara na transição PARA `approved` (`ArchivePhoto.ts:248`), então o downgrade é permitido mesmo sem canal configurado; os afterChange hooks purgam os descritores (`:292-297`) e revalidam a listagem (`:276`), tirando a foto do público e da busca por selfie. O recibo registra id, flickrId, motivo e status anterior/novo. Linhas `removed` nunca são reparadas nem rebaixadas (takedown continua takedown — C242): a varredura reporta e o reparo registra `skippedRemoved`. +- Rejeitadas: B porque `removed` é takedown de pessoa e um arquivo quebrado não é pedido de remoção — além de esconder a causa; C porque viola o aceite ("nunca ficam quebradas"). + +**D4 — Recuperação da fonte** + +- Opções: A) re-`getSizes` (maior size) pelo `flickrId` via API | B) baixar o `sourceUrl` persistido | C) A com fallback B. +- Recomendação: A — `sourceUrl` é a página humana (fato acima), não o arquivo; o `originalUrl` não é persistido; `getLargestSize` (`scripts/lib/flickrApi.mjs:187-200`) é o mesmo fallback documentado da ingestão (inclui "Original" quando disponível; quando o original é restrito, é exatamente o que foi ingerido). Só as fotos acusadas chamam a API (1 req/foto — rate limit irrelevante); `FLICKR_API_KEY` é exigida só no `--apply`, fail-fast antes de qualquer rede; o download usa `downloadUrlToFile` com `User-Agent` do Flickr e teto de 512 MiB/2 min (constantes do C231). O recibo grava a URL-fonte, bytes e sha256 (proveniência). +- Rejeitadas: B porque baixaria HTML (e scraping é anti-goal); C porque o fallback não existe de verdade — sem size usável, a foto é irrecuperável e segue o D3. + +**D5 — Reaprovação pós-recuperação** + +- Opções: A) a CLI não toca `publicationStatus` no reparo (approved continua approved; draft segue draft) | B) re-aprovar automaticamente se estava approved antes | C) deixar sempre draft. +- Recomendação: A — a linha nunca foi mutada; só o objeto foi substituído na mesma chave. O gate público é metadado (`approved` + `filename` inalterado, `archivePhotoReads.ts:68`) e a rota de mídia lê a linha fresca e faz stream do objeto (`route.ts:33-72`, `force-dynamic` + `no-store`), então a foto aprovada volta a servir sem nenhuma escrita de status; `draft` permanece `draft` e segue o fluxo normal `pnpm archive:publish` (que exige canal de remoção). O recibo lembra de rodar `pnpm faces:index` — foto cujo índice falhou fica stale (o marcador só é gravado no sucesso, `faceDescriptorIndex.ts:180-252`) e é reprocessada sozinha. +- Rejeitadas: B porque reparo não é ato de curadoria e um re-approve re-dispararia o guard de canal (podendo transformar um reparo em falha); C porque despublicaria fotos boas (viola "voltar ao público pelo fluxo normal"). + +**D6 — Recibo** + +- Opções: A) JSON datado em `data/archive/reports/archive-integrity-.json` (varredura) e `...-repair.json` (`--apply`) + resumo humano no stdout, via `scripts/lib/archiveIntegrityPlan.mjs` puro (parse/summarize/format) | B) `data/flickr/reports/` | C) só stdout. +- Recomendação: A — segue o regime do C242 (`data/archive/reports/archive-publish-.json`) e o padrão plan-lib (`archivePublishPlan.mjs`), com `writeRepoFile` do esqueleto CLI. Campos: `runAt`, `mode` (`verify|repair`), `target` (sem segredo), `scanned`, `ok`, `missing`, `corrupted[]` (id, flickrId, filename, stage, reason), `recovered[]`, `failed[]`, `unrecoverable[]` (status anterior/novo), `skippedRemoved`, `bytes`, `durationMs`; no reparo, `results[]` por foto (status, stage, sourceUrl, sha256, bytes). Exit codes: varredura sai 1 se `missing+corrupted > 0` (espelha `faces:index --verify`), 0 quando limpa — é o critério de convergência; `--apply` sai 1 se restar `failed` que não virou `unrecoverable` tratado. +- Rejeitadas: B porque o domínio é o acervo, não o Flickr; C porque o aceite pede conferência posterior e a Issue pede recibo anexável. +- **Nota de execução (triage pós-simplify, 2026-10-02):** o shape final do recibo difere do previsto acima em três pontos, sem mudar a decisão: (a) `pending` conta **só** as quebradas `approved` — é o exit 1 da varredura, e quebradas `draft`/`removed` (`draftBroken`/`removedBroken`) são registradas sem bloquear a convergência pós-`--apply`; (b) `scan` ganhou `draftBroken`, `removedScanned` e `removedBroken` (em vez do único `skippedRemoved`), e o rebaixamento de uma foto que já era `draft` é registrado sem `previousStatus`/`newStatus`; (c) o JSON é `{ runAt, mode, target, scan, repair?, results[], repairResults?, durationMs }` — os itens por foto ficam nas arrays e o formatador lê sempre `report.scan`. + +**D7 — Guardas de escrita** + +- Opções: A) `--apply` exige `ARCHIVE_INTEGRITY_CONFIRM=1` + `assertWriteConfirm` + `assertEnvironmentDatabaseTarget` (TEQO_ENV casando o banco exato) + S3 completo (`mirroredMediaRequired`) + `FLICKR_API_KEY`; a varredura é read-only, mas exige S3 completo quando o alvo não é local | B) reusar `ARCHIVE_PUBLISH_CONFIRM` | C) `assertLocalDatabase` + `ALLOW_REMOTE_DB`. +- Recomendação: A — flag própria porque o write path é outro (reparo de objeto + eventual downgrade) e a intenção de operação é diferente da publicação em lote; mesmo stack do C231/C242 (`assertWriteConfirm` + `assertEnvironmentDatabaseTarget`, que distingue staging/produção pelo nome do banco e recusa `ALLOW_REMOTE_DB`); S3 completo obrigatório no alvo não-local via `mirroredMediaRequired` (sem S3 a varredura/reparo mediriam o disco errado); `FLICKR_API_KEY` fail-fast no `--apply`. Runbook no homeserver (compose maintenance, env do stack), como `flickr:import`/`archive:publish`. +- Rejeitadas: B porque publicar e reparar são intenções distintas (o operador pode ter armado só uma); C porque não separa staging de produção e admite o override remoto que o C195/C231 recusa. + +**Migration:** sem migration — nenhum campo, índice ou collection novos. Stop-condition do modo autônomo: se aparecer necessidade de campo novo (ex.: marcador de integridade), parar e sinalizar em vez de criar schema. + +### Componentes / mudanças + +- **`inspectPrivateMediaObject`** (`src/utilities/privateMedia/privateMediaResponse.ts`, edição): novo export que baixa o objeto pelo caminho do índice facial (`downloadPrivateMediaToFile`), valida decode com sharp `failOn:'error'` e classifica `ok | missing | corrupt` (`stage: 'download' | 'decode'`, motivo, bytes); exporta o classificador de ausência atual como `isPrivateMediaMissingError` (renome do `isMissingObject` privado, uso interno intacto); temp limpo por chamada. Reusa o client S3 module-private e o fallback de disco local (dev/test). +- **`repairArchivePhotoObject`** (`src/utilities/flickr/archivePhotoIngest.ts`, edição): `payload.update({ collection: ARCHIVE_PHOTO_SLUG, id, data: {}, filePath, overwriteExistingFiles: true, overrideAccess: true })`; retorna `{ status: 'repaired' | 'failed', filename, error? }`; nunca envia campos curados nem `publicationStatus`; nome do temp = `row.filename ?? archivePhotoStorageFilename(flickrId, originalUrl)` (determinístico C231). +- **`archiveIntegrityPlan`** (`scripts/lib/archiveIntegrityPlan.mjs`, novo): `parseArchiveIntegrityCliArgs` (`--apply`, `--limit`, `--only`, `--out`, `--help`; modos exclusivos), `archiveIntegrityReportStamp`, `summarizeArchiveIntegrity`, `formatArchiveIntegrityReport`; entra em `SCRIPTS_SPEC_PINNED` (`scripts/lib/test-affected-core.mjs`). +- **`check-archive-integrity`** (`scripts/check-archive-integrity.mjs`, novo + script `"archive:integrity"` no `package.json`): varredura default read-only (`--concurrency`, `--limit`, `--out`) e `--apply` (varredura + reparo com guardas D7); por foto acusada: `client.getLargestSize(flickrId)` → `downloadUrlToFile` (UA/teto C231) → validação decode → `repairArchivePhotoObject`; fonte sumiu → downgrade a draft (D3); `removed` intocado; recibo D6; exit codes D6. +- **`docs/ops/teqo-1313-deploy.md`** (edição): seção C246 com canary, varredura completa, reparo e re-varredura, no serviço `teqo--migrate` com volume de recibos; lembrete do `curl /api/revalidate?tag=archivePhotos` quando houver downgrade (o write do CLI não revalida o servidor Next vivo). +- **`docs/changelog/2026-10-01-c246.md`** + este impl no mesmo PR. +- **Access / Consent:** nenhuma mudança de access; as chamadas são de ator confiável sem sessão (`overrideAccess: true` documentado, precedente `archivePhotoIngest.ts:43-44`); sem `Consent` novo e sem `Contact` (não há opt-in nem PII de titular nesta fatia). Escritas são single-collection (`payload.update`), sem necessidade de transação multi-collection. +- **UI:** Impeccable A — sem UI; nenhum shell/rota nova; a ficha admin é gerada pelo Payload. + +### Dados → forma + +- Forma escolhida: recibo JSON operacional (`data/archive/reports/`) + linhas humanas no stdout (contagens e falhas nomeadas), consumido por operador — não há superfície pública. +- Rejeitadas: relatório markdown separado (duplicaria o JSON e criaria segunda fonte de verdade); CSV (ninguém consome); painel/admin (não pedido; fora do appetite). + +## Fases verificáveis + +1. **Tracer / schema+server — varredura read-only (~40% do appetite):** `inspectPrivateMediaObject` no dono + `archiveIntegrityPlan.mjs` (parser/summarize/format) + modo varredura da CLI + recibo + `SCRIPTS_SPEC_PINNED`. Testes: unit do plan-lib (args, summarize, format) e do classificador; int com fixture real (`ARCHIVE_PHOTO_JPEG_BYTES`): objeto íntegro → `ok`; arquivo corrompido no staticDir → `corrupt` (stage decode); arquivo removido → `missing`; checksum error classificado como `corrupt` (unit do predicado, sem S3). Prova local: ingerir fixture, corromper o arquivo, `pnpm archive:integrity --limit 20` acusa com motivo e sai 1; varredura limpa sai 0. +2. **Recuperação (~30%):** `repairArchivePhotoObject` + modo `--apply` (Flickr `getSizes` + download + validação + update) + guardas D7. Testes: int — reparo substitui os bytes na MESMA chave, `filename` inalterado, `curatedFields`/curadoria/status intactos, `filesize` atualizado; unit — `flickrApi.getLargestSize` já pinado, `--only`/canary e guardas da CLI por spawn (espelho de `archiveCatalogCli.unit.spec.ts`: sem confirm, sem TEQO_ENV, banco ≠ ambiente, S3 incompleto). +3. **Irrecuperáveis + `removed` (~15%):** downgrade `approved → draft` com registro; int — descritores purgados no downgrade, canal de remoção ausente NÃO bloqueia o downgrade, `removed` não é tocado pelo reparo (fica no recibo como `skippedRemoved`); convergência — corromper → reparar → `inspect` volta `ok`. +4. **Gates + docs + ops (~15%):** `pnpm gate:fast`; runbook no `teqo-1313-deploy.md`; changelog; push via `pnpm push`. **Ops (fora do merge):** no homeserver, canário nas fotos 80/83 (`--only 80,83`), checar `/fotos/80/midia` e `/fotos/83/midia` 200, varredura completa (read-only) e `--apply`; anexar os recibos à Issue #1418; re-varredura limpa é o aceite final. + +## Rabbit holes / Não escopo (engenharia) + +- Não varrer `media` (OPS52), vídeos, gravações ou outras plataformas — só `archivePhoto`. +- Não re-ingerir o acervo inteiro (corte de produto): só o que a varredura acusar. +- Não apagar fotos nem objetos (corte de produto): recuperar primeiro; sair do público só quando a fonte não existir. +- Não adicionar campo/marcador de integridade (sem migration): reexecutar converge por varredura completa, que é a checagem honesta. +- Não mexer em curadoria, não re-aprovar, não publicar lote, não tocar em `removed`. +- Não paralelizar download do Flickr (poucas fotos; client C231 sequencial com retry já basta). +- Não editar migrações existentes nem usar `push`; nenhuma UI/admin nova. +- Não criar `Consent`/`Contact`/cadastro paralelo (não se aplica). + +## Riscos e mitigação + +- **Checksum/ETag multipart:** ETag de multipart não é MD5 — não usar como hash; a validação é o download real (checksum do SDK quando presente) + decode do sharp (cobre objeto truncado sem checksum). Falso negativo residual aceito e registrado no recibo. +- **Flickr auth/rate-limit:** só as fotos acusadas chamam `getSizes` (1 req/foto); `FLICKR_API_KEY` fail-fast; retry/backoff do client C231; falha de fonte → `unrecoverable` → D3. +- **Objeto curado não pode ser sobrescrito:** `repairArchivePhotoObject` só envia `filePath` (+ `overwriteExistingFiles`), nunca campos curados nem status; `deriveArchivePhotoCatalogIndex` não marca curadoria em escrita sem `req.user` (`ArchivePhoto.ts:175`); int pin de `curatedFields`/status. +- **Drift de filename/chave:** temp com basename = `row.filename` + `overwriteExistingFiles: true` (só renomeia quando `false`); int pin do filename inalterado e do objeto na mesma chave; sem filename, nome determinístico C231. Canário de produção confere as fotos 80/83 antes do lote. +- **S3 config parcial / alvo errado:** `resolveS3StorageEnv` lança em config parcial (`mediaStorage.ts:46`); `mirroredMediaRequired` exige as 4 envs no alvo não-local; sem S3 em dev/test a varredura mede o disco local (documentado; nunca é o caso de produção). +- **Fotos já `approved` sem canal de remoção:** não bloqueiam nada — o guard só cobre a entrada em `approved` e o reparo não re-aprova; se o operador quiser publicar um draft de volta, o fluxo normal exige o canal. +- **Custo de baixar todas as fotos:** ~6.492 objetos na rede local + decode; `--concurrency` (default 3) e `--limit` canary; recibo com bytes/tempo; execução no homeserver evita egress externo. +- **Falsos positivos:** a varredura não escreve nada — um falso positivo custa apenas um reparo redundante (idempotente, re-baixar da fonte é seguro) e fica no recibo com motivo. +- **Cache do servidor vivo após downgrade:** o hook de revalidação roda no processo de manutenção e não alcança o Next vivo (mesmo caveat do `archive:publish`); o runbook manda bustar `tag=archivePhotos` após `--apply` com downgrade; fotos reparadas (approved, filename inalterado) voltam a servir sem bust. +- **Janela scan→reparo:** a foto segue 500 até o reparo; curto e aceitável (o scan é o pre-flight do próprio `--apply`). +- **`payload.update` com `filePath` via plugin S3 em produção:** caminho não coberto por int sem credenciais; mitigado pelo canário `--only 80,83` + verificação pública 200 antes do lote completo. + +## Aceite de engenharia + +- [ ] Aceite de produto da intenção ainda coberto (varredura de todas as fotos, recuperação sem duplicar/curar, irrecuperável fora do público, reexecução converge, recibo honesto) +- [ ] Invariantes AGENTS/engineering-standards (overrideAccess documentado em CLI sem sessão; sem migration; sem `push`; copy pt-BR / identificadores em inglês; produção só com confirm e via runbook) +- [ ] Testes de domínio previstos: unit (plan-lib, classificador, guardas da CLI por spawn) e int (inspect ok/missing/corrupt; repair preserva filename/curadoria/status; downgrade purga descritores sem canal; `removed` intocado; convergência corromper→reparar→ok) +- [ ] Sem migration / sem campo novo (stop-condition respeitada) +- [ ] `inspectPrivateMediaObject` reusa o caminho do índice facial e classifica `ok | missing | corrupt` com motivo +- [ ] `repairArchivePhotoObject` mantém a chave/filename e não toca curadoria nem `publicationStatus` +- [ ] Varredura read-only não escreve e sai 1 com pendência; `--apply` exige `ARCHIVE_INTEGRITY_CONFIRM`, `TEQO_ENV` exato, S3 completo e `FLICKR_API_KEY` +- [ ] Recibos em `data/archive/reports/` + resumo stdout; runbook C246 no `teqo-1313-deploy.md`; changelog no PR +- [ ] `pnpm gate:fast` verde; push via `pnpm push` + +## Débitos deferidos (triage pós-simplify, 2026-10-02) + +- **S1 — pipeline de decode com donos múltiplos:** `inspectPrivateMediaObject` (`privateMediaResponse.ts:384-387`) e a prova da fonte do reparo (`archiveIntegrityRepair.mjs:105-108`) duplicam hoje o mesmo proof (`failOn:'error'` + `rotate` + `resize width 1024 withoutEnlargement`); o `prepareFaceImage` (`faceDescriptorIndex.ts:55-60`) é transform vizinho (resize com `height`/`fit` e saída `raw()`), não o mesmo contrato. **Gatilho:** um 4º consumidor do proof de decode ou a necessidade de mudar edge/transform/`failOn` — então extrair um `assertImageDecodes` no dono `privateMediaResponse` e consumir de inspect/repair (não forçar o face index no mesmo helper). +- **S3 — `mapWithConcurrency` no plan-lib de domínio** (`archiveIntegrityPlan.mjs:104`), 1 consumidor (a própria CLI). **Gatilho:** um 2º script precisar de mapa com concorrência limitada — mover para o esqueleto `scripts/lib/cli.mjs` e importar nos dois. +- **S4 — contrato do recibo em JSDoc solto em 3 arquivos** (`archiveIntegrityPlan.mjs`, `archiveIntegrityRepair.mjs`, `check-archive-integrity.mjs`). **Gatilho:** o recibo ganhar um 2º consumidor (parsing por script/painel) ou uma 4ª superfície — então typedef único no plan-lib e referência nos demais. +- **S6 — constantes de download do Flickr duplicadas** (`ingest-flickr-archive.mjs:84-85` do C231 × `archiveIntegrityRepair.mjs:25-26`). **Gatilho:** um 3º consumidor de download do Flickr ou mudança do teto/timeout — mover para o dono do client (`scripts/lib/flickrApi.mjs`) e importar nos dois. +- **S5 — registrado como C249 (depende do C246):** `pnpm archive:publish` aprova todo draft sem olhar o objeto e pode devolver ao público uma foto que o C246 rebaixou por estar quebrada; hoje só o aviso do runbook mitiga. Plano: `docs/plans/acervo-publish-preflight-integridade.md`. + +### Já resolvido no /simplify (não reabrir) + +- `isFlickrSourceGone` só reconhece códigos 1/2 + HTTP 404 (100/105/401/403/429/5xx/network viram `failed` retryável). +- `pending` conta só `approved`; `draftBroken`/`removedScanned`/`removedBroken` registram sem bloquear; runbook alinhado. +- `process.exit()` movido para depois do `finally` (temp `/tmp/archive-integrity-*` não vaza mais). +- `skipped already-draft` não mente mais `draft → draft` (sem previous/newStatus). +- Formatador sempre usa `report.scan` (fim do fallback com NaN). +- `isPrivateMediaMissingError` narrowado antes do cast. +- Docblock de `archivePhotoIngest` alinhado ao reparo/withdraw. +- Typo `jorgessolla` corrigido no int spec. +- Fila de reparo deriva de `scan.missing`/`scan.corrupted` (fim da regra duplicada de `removed`). + +### Descartado + +- **S2 — `messageOf` local no script de reparo:** one-liner cuja cópia local é a convenção dos scripts (25+ equivalentes inline) e cujo dono (`src/utilities/media/ffmpeg.ts`) é `import 'server-only'`, não importável do script; sem risco real de divergência. + +## Self-score decision-quality + +**5/5** — (1) todas as decisões caras foram deliberadas agora com opções/recomendação/rejeitadas (D1 write path, D2 método de detecção, D3 saída do público, D4 fonte, D5 status, D7 guardas); (2) depth check feito — reusa o dono de leitura de mídia privada (`privateMediaResponse`), o dono de escrita do upload (`payload.update` via `archivePhotoIngest`) e o esqueleto CLI, sem twin e sem pass-through raso; (3) a hipótese "sourceUrl é arquivo" foi corrigida contra o codebase e a intenção preservada no outcome; (4) rabbit holes de produto (re-ingerir tudo, apagar) e de engenharia (campo novo/migration, UI, varredura de `media`) explicitamente cortados; (5) riscos com mitigação verificável (canário 80/83, int pins, cache bust do downgrade) e aceite com checkboxes. diff --git a/docs/plans/acervo-publish-preflight-integridade.md b/docs/plans/acervo-publish-preflight-integridade.md new file mode 100644 index 00000000..f68b83c8 --- /dev/null +++ b/docs/plans/acervo-publish-preflight-integridade.md @@ -0,0 +1,43 @@ +# Plano curto: C249 — archive:publish com preflight de integridade + +Status: proposto (triage do C246, 2026-10-02) +Issue: C249 (a registrar; `depends: [C246]`) +Appetite: ~0,5 dia eng. Sem migration, sem UI, sem mudar contrato público. + +## Contexto + +O C246 rebaixa a `draft` a foto irrecuperável (objeto quebrado/ausente) e o +`pnpm archive:publish` (C242) aprova **todo** draft sem olhar o objeto — um +publish depois de um `--apply` pode devolver ao público uma foto quebrada +(500 em `/fotos//midia`). Hoje a mitigação é só o aviso do runbook +(`docs/ops/teqo-1313-deploy.md`, seção C246): "não rode o publish depois de um +reparo sem varredura limpa". + +## Fases + +1. **Preflight na fila do publish (~70%):** no `--apply` (e no plano, para o + operador ver antes), cada draft elegível passa pelo `inspectPrivateMediaObject` + do C246; objeto fora de `ok` **não é aprovado**, entra no recibo como + `skippedBroken` (id, stage, reason) e o comando sai 1 se houver puladas. A + query da fila passa a selecionar `filename` além de `id`. Reusa o dono da + varredura e o `scripts/lib/archivePublishPlan.mjs` (summarize/format ganham a + chave) — sem varredura própria na CLI do publish. +2. **Provas (~20%):** unit do summarize/format com `skippedBroken`; int: draft + com objeto íntegro é aprovado, draft com objeto removido/corrompido fica + `draft` e é nomeado no recibo; spawn da CLI pinando os guardas atuais. +3. **Runbook (~10%):** atualizar a seção C246/C242 do `teqo-1313-deploy.md` — + o aviso deixa de ser a única proteção. + +## Rabbit holes / Não escopo + +- Não reparar objeto no publish (reparo é C246): só pular e nomear. +- Não varrer `media`, vídeos ou outras collections; não tocar `removed`. +- Não mudar as guardas/confirm do publish; sem migration nem campo novo (o + `skippedBroken` vive no recibo, não no schema). +- Não adicionar cache/revalidação além do que o publish já manda. + +## Aceite + +- [ ] `archive:publish --apply` nunca aprova draft com objeto quebrado. +- [ ] Recibo nomeia cada pulada com stage/motivo; exit 1 se houver puladas. +- [ ] Int cobre aprovadas e puladas; guardas do publish intactos; runbook atualizado. diff --git a/package.json b/package.json index 1c80bfb2..0672c46f 100644 --- a/package.json +++ b/package.json @@ -43,6 +43,7 @@ "faces:enroll-figure": "cross-env NODE_OPTIONS=\"--no-deprecation --import=tsx/esm --import=./scripts/seed-loader.mjs\" node scripts/enroll-face-figure.mjs", "archive:publish": "cross-env NODE_OPTIONS=\"--no-deprecation --import=tsx/esm --import=./scripts/seed-loader.mjs\" node scripts/publish-archive-photos.mjs", "ops:global": "cross-env NODE_OPTIONS=\"--no-deprecation --import=tsx/esm --import=./scripts/seed-loader.mjs\" node scripts/ops-global.mjs", + "archive:integrity": "cross-env NODE_OPTIONS=\"--no-deprecation --import=tsx/esm --import=./scripts/seed-loader.mjs\" node scripts/check-archive-integrity.mjs", "seed:face-consents": "cross-env NODE_OPTIONS=\"--no-deprecation --import=tsx/esm --import=./scripts/seed-loader.mjs\" node scripts/seed-face-consents.mjs", "seed:face-figures": "cross-env NODE_OPTIONS=\"--no-deprecation --import=tsx/esm --import=./scripts/seed-loader.mjs\" node scripts/seed-face-figures.mjs", "db:seed:minimal": "cross-env NODE_OPTIONS=\"--no-deprecation --import=tsx/esm --import=./scripts/seed-loader.mjs\" node scripts/seed-minimal.mjs", diff --git a/scripts/check-archive-integrity.mjs b/scripts/check-archive-integrity.mjs new file mode 100644 index 00000000..48f8749d --- /dev/null +++ b/scripts/check-archive-integrity.mjs @@ -0,0 +1,344 @@ +/** + * C246 — integrity sweep of the private photo archive (C231): every + * `archivePhoto` row is downloaded through the SAME path the public media route + * and the face index use (the S3 SDK validates the stored checksum on the GET) + * and then decoded with sharp; a missing object is `missing`, a read failure or + * an undecodable file is `corrupt`, and the receipt names every one with its + * motive. The sweep is READ-ONLY by default and exits 1 while any non-removed + * photo is broken — that exit is the convergence criteria. + * + * With `--apply`, each broken photo is re-ingested from Flickr by `flickrId` + * (the same `getLargestSize` fallback the C231 ingestion documents), the + * downloaded original is decoded to prove it is a real image, and the object is + * replaced through the collection's own upload write path + * (`repairArchivePhotoObject`) — same key, no metadata/curation/status write. + * A photo whose Flickr source is gone (or does not decode) leaves the public + * with a record (`approved → draft`); `removed` is never touched. + * + * Modes: + * pnpm archive:integrity sweep (default) — read-only, no writes + * pnpm archive:integrity --apply sweep + repair; requires + * ARCHIVE_INTEGRITY_CONFIRM=1 outside + * local dev, the declared TEQO_ENV + * matching the exact database, the + * complete S3_* envs and FLICKR_API_KEY + * + * Options: --only (canary, comma-separated row ids), --limit (canary), + * --concurrency (default 3), --out (receipts, default `data/archive`), + * --help. + * + * Guards: --apply refuses a non-local/production target without the intent flag + * (C155) and demands the declared TEQO_ENV matching the exact database name + * (C195/C231); a non-local target also demands the complete S3_* set in BOTH + * modes (without S3 the sweep would measure the wrong storage). + * + * Runbook: on the homeserver, inside the compose maintenance service with the + * stack env file — see docs/ops/teqo-1313-deploy.md. After a `--apply` that + * withdrew a photo, bust the public cache from a process that owns it: + * curl -X POST "https://jorgesolla1313.com.br/api/revalidate?tag=archivePhotos" ... + */ +import { mkdir, mkdtemp, rm } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' + +import { getPayload } from 'payload' + +import { + ARCHIVE_INTEGRITY_DEFAULT_CONCURRENCY, + ARCHIVE_INTEGRITY_DEFAULT_OUT_DIR, + archiveIntegrityReportStamp, + formatArchiveIntegrityRepairReport, + formatArchiveIntegrityScanReport, + mapWithConcurrency, + parseArchiveIntegrityCliArgs, + summarizeArchiveIntegrityRepair, + summarizeArchiveIntegrityScan, +} from './lib/archiveIntegrityPlan.mjs' +import { repairArchivePhotoFromSource } from './lib/archiveIntegrityRepair.mjs' +import { + TEQO_ENV_DATABASE_BY_ENV, + assertEnvironmentDatabaseTarget, + assertWriteConfirm, + databaseTarget, + dieWithLabel, + loadCliEnv, + mirroredMediaRequired, + writeRepoFile, +} from './lib/cli.mjs' +import { createFlickrClient } from './lib/flickrApi.mjs' + +loadCliEnv() + +const die = dieWithLabel('archive:integrity') + +const WRITE_CONFIRM_FLAG = 'ARCHIVE_INTEGRITY_CONFIRM' +const PROGRESS_EVERY = 250 + +const envLines = Object.entries(TEQO_ENV_DATABASE_BY_ENV) + .map(([environment, database]) => `${environment} (${database})`) + .join(' | ') + +const HELP = ` +Uso: pnpm archive:integrity [opções] + +Varre a integridade dos originais do acervo privado (archivePhoto): baixa cada +objeto pelo mesmo caminho da rota pública e do índice facial e força o decode +com sharp. O recibo nomeia cada foto ausente/corrompida com o motivo. Reexecutar +converge; a varredura é read-only e sai 1 enquanto houver mídia quebrada pública. + +Modos: + (sem flag) varredura read-only — todas as fotos; sem escrita + --apply varredura + recuperação: re-ingere do Flickr pelo flickrId, + substitui o objeto na MESMA chave (sem tocar curadoria nem + status) e rebaixa a draft as irrecuperáveis (fonte sumiu) + +Opções: + --only canário: só os ids de linha informados (ex.: --only 80,83) + --limit processa no máximo n fotos (canário) + --concurrency downloads simultâneos (default ${ARCHIVE_INTEGRITY_DEFAULT_CONCURRENCY}) + --out diretório dos recibos (default ${ARCHIVE_INTEGRITY_DEFAULT_OUT_DIR}) + --help esta ajuda + +Ambiente: + DATABASE_URL alvo (obrigatório em todos os modos) + FLICKR_API_KEY chave da API do Flickr (exigida no --apply) + +--apply em alvo não-local/produção exige ${WRITE_CONFIRM_FLAG}=1 e TEQO_ENV=${envLines} +casando o banco exato. Alvo não-local exige as quatro envs S3_* em QUALQUER modo +(sem S3 a varredura mediria o storage errado). +` + +const writeReport = async (options, report, suffix = '') => { + const stamp = archiveIntegrityReportStamp(report.runAt) + const relativePath = join(options.out, 'reports', `archive-integrity-${stamp}${suffix}.json`) + await writeRepoFile({ + label: 'archive:integrity', + root: process.cwd(), + relativePath, + body: JSON.stringify(report, null, 2), + }) + return join(process.cwd(), relativePath) +} + +const selectRows = (rows, options) => { + let selected = rows + if (options.only) { + const wanted = new Set(options.only) + selected = selected.filter((row) => wanted.has(row.id)) + } + if (options.limit !== null) selected = selected.slice(0, options.limit) + return selected +} + +/** + * One read-only probe: download + decode, with the caller-owned temp removed + * right away (6k+ photos cannot accumulate on disk). + */ +const inspectRow = async ({ row, tempRoot, staticDir, inspect }) => { + const dir = join(tempRoot, String(row.id)) + await mkdir(dir, { recursive: true }) + const filename = row.filename ?? null + const destinationPath = join(dir, filename ?? `foto-${row.id}.bin`) + try { + const inspection = await inspect({ + media: { filename }, + staticDir, + destinationPath, + }) + return { + id: row.id, + flickrId: String(row.flickrId), + filename, + publicationStatus: row.publicationStatus, + ...inspection, + } + } finally { + await rm(dir, { recursive: true, force: true }).catch(() => undefined) + } +} + +async function main() { + const options = parseArchiveIntegrityCliArgs(process.argv.slice(2)) + if (options.help) { + console.log(HELP) + process.exit(0) + } + + if (!process.env.DATABASE_URL) die('DATABASE_URL não definida; recusando continuar.') + const mode = options.apply ? 'repair' : 'verify' + const runAt = new Date().toISOString() + console.log(`[archive:integrity] alvo: ${databaseTarget()} | modo: ${mode}`) + + if (mode === 'repair') { + assertWriteConfirm({ + label: 'archive:integrity', + flag: WRITE_CONFIRM_FLAG, + command: 'pnpm archive:integrity --apply', + }) + assertEnvironmentDatabaseTarget() + } + + const { resolveS3StorageEnv } = await import('../src/utilities/mediaStorage.ts') + const storage = resolveS3StorageEnv(process.env) + if (mirroredMediaRequired({ s3Enabled: storage.enabled })) { + die( + 'alvo não-local exige mídia espelhada: configure S3_BUCKET, S3_ENDPOINT, S3_ACCESS_KEY_ID e S3_SECRET_ACCESS_KEY; sem elas a varredura mediria o storage errado.', + ) + } + if (mode === 'repair' && !process.env.FLICKR_API_KEY?.trim()) { + die('FLICKR_API_KEY ausente — o reparo re-ingere do Flickr (veja .env.example).') + } + + const config = (await import('../src/payload.config.ts')).default + const { listArchivePhotos, repairArchivePhotoObject, withdrawArchivePhotoFromPublic } = + await import('../src/utilities/flickr/archivePhotoIngest.ts') + const { inspectPrivateMediaObject, resolvePrivateMediaStaticDir } = + await import('../src/utilities/privateMedia/privateMediaResponse.ts') + const { downloadUrlToFile } = await import('../src/utilities/media/downloadToFile.ts') + const payload = await getPayload({ config }) + const startedAt = Date.now() + const staticDir = resolvePrivateMediaStaticDir(payload, 'archivePhoto') + + const allRows = await listArchivePhotos(payload) + const selected = selectRows(allRows, options) + if (options.only && selected.length === 0) { + die(`--only ${options.only.join(',')} não casou nenhuma foto do acervo (ids de linha).`) + } + if (options.only && selected.length !== options.only.length) { + const found = new Set(selected.map((row) => row.id)) + console.log( + `[archive:integrity] aviso: --only ignorou id(s) sem linha no acervo: ${options.only + .filter((id) => !found.has(id)) + .join(', ')}`, + ) + } + console.log( + `[archive:integrity] fila: ${selected.length} de ${allRows.length} foto(s) (concorrência ${options.concurrency})`, + ) + + const tempRoot = await mkdtemp(join(tmpdir(), 'archive-integrity-')) + let report + let scan + let repair = null + try { + let processed = 0 + const results = await mapWithConcurrency(selected, options.concurrency, async (row) => { + const result = await inspectRow({ + row, + tempRoot, + staticDir, + inspect: inspectPrivateMediaObject, + }) + processed += 1 + if (result.status !== 'ok') { + const detail = result.status === 'missing' ? 'ausente' : `corrompida [${result.stage}]` + console.log( + `[archive:integrity] ${detail} ${result.id}: ${result.reason ?? 'objeto ausente'}`, + ) + } + if (processed % PROGRESS_EVERY === 0) { + console.log(`[archive:integrity] … ${processed}/${selected.length}`) + } + return result + }) + + scan = summarizeArchiveIntegrityScan(results) + + if (mode === 'repair') { + const rowsById = new Map(selected.map((row) => [row.id, row])) + // The summary owns the "removed never enters the repair" rule. + const brokenIds = new Set([...scan.missing, ...scan.corrupted].map((item) => item.id)) + const broken = results.filter((result) => brokenIds.has(result.id)) + + const client = createFlickrClient({ apiKey: process.env.FLICKR_API_KEY }) + const withdraw = async ({ id, flickrId, filename, reason }) => { + const result = await withdrawArchivePhotoFromPublic(payload, { id, flickrId }) + if (result.status === 'withdrawn') { + return { + id, + flickrId, + filename, + action: 'unrecoverable', + previousStatus: result.previousStatus, + newStatus: 'draft', + reason, + } + } + // An already-draft photo was already out of the public: record the + // unrecoverable verdict without pretending a status change happened. + return { id, flickrId, filename, action: 'unrecoverable', reason } + } + + const repairResults = await mapWithConcurrency(broken, options.concurrency, async (entry) => { + const row = rowsById.get(entry.id) + const result = await repairArchivePhotoFromSource({ + row, + tempRoot, + staticDir, + inspect: inspectPrivateMediaObject, + payload, + client, + repair: repairArchivePhotoObject, + download: downloadUrlToFile, + withdraw, + }) + const detail = + result.action === 'recovered' + ? `recuperada (${result.sourceUrl ?? 'fonte'})` + : `${result.action}${result.reason ? `: ${result.reason}` : ''}` + console.log(`[archive:integrity] ${entry.id}: ${detail}`) + return result + }) + + repair = summarizeArchiveIntegrityRepair(repairResults) + report = { + runAt, + mode, + target: databaseTarget(), + scan, + repair, + results, + repairResults, + durationMs: Date.now() - startedAt, + } + } else { + report = { + runAt, + mode, + target: databaseTarget(), + scan, + results, + durationMs: Date.now() - startedAt, + } + } + } finally { + await rm(tempRoot, { recursive: true, force: true }).catch(() => undefined) + } + + const reportPath = await writeReport(options, report, mode === 'repair' ? '-repair' : '') + for (const line of formatArchiveIntegrityScanReport(report)) console.log(line) + if (mode === 'repair') { + for (const line of formatArchiveIntegrityRepairReport(report)) console.log(line) + console.log( + '\n[archive:integrity] se houve saída do público, buste a tag: curl -X POST "https://jorgesolla1313.com.br/api/revalidate?tag=archivePhotos" -H "x-revalidate-secret: $REVALIDATE_SECRET"', + ) + console.log( + '[archive:integrity] lembre de rodar `pnpm faces:index` — a foto recuperada cujo índice falhou é reprocessada sozinha.', + ) + } + console.log(`[archive:integrity] recibo JSON: ${reportPath}`) + + if (mode === 'verify' && scan.pending > 0) { + die( + `${scan.pending} foto(s) com mídia quebrada pública — o acervo ainda não está íntegro; rode o --apply para recuperar.`, + ) + } + if (mode === 'repair' && repair.failed.length > 0) { + die(`${repair.failed.length} foto(s) falharam no reparo — falhas listadas no recibo.`) + } + process.exit(0) +} + +main().catch((error) => { + die(error?.message || String(error)) +}) diff --git a/scripts/lib/archiveIntegrityPlan.mjs b/scripts/lib/archiveIntegrityPlan.mjs new file mode 100644 index 00000000..53d6d6a7 --- /dev/null +++ b/scripts/lib/archiveIntegrityPlan.mjs @@ -0,0 +1,275 @@ +/** + * C246 — pure planning/reporting of the photo-archive integrity sweep: the argv + * parser, the bounded-concurrency map, the scan/repair summaries and the human + * lines + JSON receipt. No I/O of its own: the inspector, the Flickr client and + * the Payload writes are injected, so the unit spec drives everything with fakes. + */ + +import { formatArchiveBytes } from './flickrPlan.mjs' + +const USAGE = + 'uso: `pnpm archive:integrity [--apply] [--only ] [--limit ] [--concurrency ] [--out ]`' + +export const ARCHIVE_INTEGRITY_DEFAULT_OUT_DIR = 'data/archive' +export const ARCHIVE_INTEGRITY_DEFAULT_CONCURRENCY = 3 + +export const archiveIntegrityReportStamp = (runAt) => String(runAt).replace(/[:.]/g, '-') + +/** + * `--only 80,83` → `[80, 83]` (photo ids, the row primary key). Exact digits + * only: a typo must refuse the run, never silently widen or empty the selection. + * + * @param {string} value + * @returns {number[]} + */ +export const parseArchiveIntegrityOnly = (value) => { + const text = String(value ?? '').trim() + if (text === '') + throw new Error(`--only vazio — informe ao menos um id (ex.: --only 80,83) — ${USAGE}.`) + const ids = [] + for (const part of text.split(',')) { + const item = part.trim() + if (!/^\d+$/.test(item)) { + throw new Error( + `--only espera ids numéricos separados por vírgula (recebi "${value}") — ${USAGE}.`, + ) + } + const id = Number(item) + if (!Number.isSafeInteger(id) || id < 1) { + throw new Error(`--only recebeu um id fora do intervalo: "${item}" — ${USAGE}.`) + } + if (!ids.includes(id)) ids.push(id) + } + return ids +} + +/** + * `pnpm archive:integrity` argv parser — pure, so the unit spec pins it (the + * CLI owns the exit and the help text). The default mode is the read-only + * sweep; `--apply` adds the repair pass. Errors carry the usage line. + * + * @param {string[]} [argv] + * @returns {{ apply: boolean, only: number[] | null, limit: number | null, concurrency: number, out: string, help: boolean }} + */ +export const parseArchiveIntegrityCliArgs = (argv = process.argv.slice(2)) => { + const options = { + apply: false, + only: null, + limit: null, + concurrency: ARCHIVE_INTEGRITY_DEFAULT_CONCURRENCY, + out: ARCHIVE_INTEGRITY_DEFAULT_OUT_DIR, + help: false, + } + + for (let index = 0; index < argv.length; index += 1) { + const arg = argv[index] + const value = () => { + const next = argv[++index] + if (next === undefined || next.startsWith('--')) { + throw new Error(`faltou valor para ${arg} — ${USAGE}.`) + } + return next + } + if (arg === '--help' || arg === '-h') options.help = true + else if (arg === '--apply') options.apply = true + else if (arg === '--only') options.only = parseArchiveIntegrityOnly(value()) + else if (arg === '--limit') options.limit = Number(value()) + else if (arg === '--concurrency') options.concurrency = Number(value()) + else if (arg === '--out') options.out = value() + else throw new Error(`argumento desconhecido: ${arg} — ${USAGE}.`) + } + + if (options.help) return options + if (options.limit !== null && (!Number.isInteger(options.limit) || options.limit < 1)) { + throw new Error(`--limit deve ser >= 1 — ${USAGE}.`) + } + if (!Number.isInteger(options.concurrency) || options.concurrency < 1) { + throw new Error(`--concurrency deve ser >= 1 — ${USAGE}.`) + } + if (options.out.split(/[\\/]/).includes('..')) { + throw new Error('--out não pode escapar do diretório do repo (sem "..").') + } + return options +} + +/** + * Bounded-concurrency map that preserves input order. The sweep downloads and + * decodes 6k+ photos, so unbounded `Promise.all` would be reckless; the pool + * keeps at most `concurrency` workers alive and returns the results in the + * same order as the input (receipts stay deterministic). + * + * @template T, R + * @param {readonly T[]} items + * @param {number} concurrency + * @param {(item: T, index: number) => Promise} worker + * @returns {Promise} + */ +export const mapWithConcurrency = async (items, concurrency, worker) => { + const results = new Array(items.length) + const size = Math.max(1, Math.min(Math.trunc(Number(concurrency)) || 1, items.length)) + let next = 0 + + const runner = async () => { + for (;;) { + const index = next + next += 1 + if (index >= items.length) return + results[index] = await worker(items[index], index) + } + } + + await Promise.all(Array.from({ length: size }, runner)) + return results +} + +/** + * Aggregates one sweep: what was read whole, what is missing/corrupt, and the + * rows kept out of the repair (`removed` is a takedown, C242). `pending` counts + * ONLY the broken `approved` rows — the public gap the read-only sweep fails on. + * A broken `draft` (already out of the public, e.g. withdrawn by a previous + * repair) and a broken `removed` are recorded but never block: the convergence + * after an `--apply` must be reachable. + * + * @param {Array<{ id: number, flickrId: string, filename: string | null, publicationStatus: string, status: 'ok' | 'missing' | 'corrupt', stage?: string | null, reason?: string | null, bytes?: number | null }>} results + */ +export const summarizeArchiveIntegrityScan = (results) => { + const missing = [] + const corrupted = [] + const draftBroken = [] + const removedBroken = [] + let ok = 0 + let removedScanned = 0 + let pending = 0 + let bytes = 0 + + for (const result of results) { + bytes += Number(result.bytes) || 0 + const publicationStatus = result.publicationStatus + const removed = publicationStatus === 'removed' + if (removed) removedScanned += 1 + + if (result.status === 'ok') { + if (!removed) ok += 1 + continue + } + + const entry = { + id: result.id, + flickrId: result.flickrId, + filename: result.filename ?? null, + publicationStatus, + stage: result.stage ?? null, + reason: result.reason ?? null, + } + if (removed) { + removedBroken.push({ ...entry, status: result.status }) + continue + } + if (result.status === 'missing') missing.push(entry) + else corrupted.push(entry) + if (publicationStatus === 'approved') pending += 1 + else draftBroken.push({ ...entry, status: result.status }) + } + + return { + scanned: results.length, + ok, + missing, + corrupted, + draftBroken, + removedScanned, + removedBroken, + pending, + bytes, + } +} + +const PUBLICATION_LABEL = { approved: 'aprovada', draft: 'rascunho', removed: 'removida' } + +/** + * Human lines for the sweep; the JSON file keeps the full report. Every broken + * row is named with id/arquivo/estado e o motivo. + * + * @param {Record} report + * @returns {string[]} + */ +export const formatArchiveIntegrityScanReport = (report) => { + const label = '[archive:integrity]' + const scan = report.scan + const describe = (item) => + `${item.id} (${item.filename ?? 'sem arquivo'}, ${PUBLICATION_LABEL[item.publicationStatus] ?? item.publicationStatus})` + const lines = [ + `${label} varredura: ${scan.scanned} foto(s) · íntegras: ${scan.ok} · ausentes: ${scan.missing.length} · corrompidas: ${scan.corrupted.length} · quebradas fora do público: ${scan.draftBroken.length}`, + `${label} pendência pública (aprovadas quebradas): ${scan.pending}`, + `${label} removidas (takedown, não reparadas): ${scan.removedScanned} · com objeto quebrado: ${scan.removedBroken.length}`, + `${label} objeto lido: ${formatArchiveBytes(scan.bytes)} em ${(report.durationMs / 1000).toFixed(1)}s`, + ] + for (const item of scan.missing) { + lines.push(` - ausente ${describe(item)}`) + } + for (const item of scan.corrupted) { + lines.push( + ` - corrompida ${describe(item)}${item.stage ? ` [${item.stage}]` : ''}: ${item.reason ?? 'erro'}`, + ) + } + for (const item of scan.removedBroken) { + lines.push(` - removida quebrada ${describe(item)} [${item.status}]: ${item.reason ?? 'erro'}`) + } + return lines +} + +/** + * Aggregates one repair pass over the broken non-removed rows: what came back + * (`recovered`), what left the public with a record (`unrecoverable`) and what + * failed and needs another run. + * + * @param {Array<{ id: number, flickrId: string, filename: string | null, action: 'recovered' | 'unrecoverable' | 'failed', sourceUrl?: string | null, sha256?: string | null, bytes?: number | null, previousStatus?: string | null, newStatus?: string | null, stage?: string | null, reason?: string | null }>} results + */ +export const summarizeArchiveIntegrityRepair = (results) => { + const recovered = [] + const unrecoverable = [] + const failed = [] + + for (const result of results) { + if (result.action === 'recovered') recovered.push(result) + else if (result.action === 'unrecoverable') unrecoverable.push(result) + else failed.push(result) + } + + return { recovered, unrecoverable, failed } +} + +/** + * Human lines for the repair pass. Recovered photos carry provenance (source + * URL, bytes, sha256 prefix); unrecoverable ones carry the record of the exit + * from the public; failures name their stage. + * + * @param {Record} report + * @returns {string[]} + */ +export const formatArchiveIntegrityRepairReport = (report) => { + const label = '[archive:integrity]' + const repair = report.repair + const lines = [ + `${label} reparo: recuperadas: ${repair.recovered.length} · irrecuperáveis (fora do público): ${repair.unrecoverable.length} · falhas: ${repair.failed.length}`, + ] + for (const item of repair.recovered) { + lines.push( + ` - recuperada ${item.id} (${item.filename ?? 'sem arquivo'}) ${formatArchiveBytes(item.bytes)}` + + `${item.sha256 ? ` sha256:${String(item.sha256).slice(0, 12)}…` : ''}` + + `${item.sourceUrl ? ` ← ${item.sourceUrl}` : ''}`, + ) + } + for (const item of repair.unrecoverable) { + lines.push( + ` - irrecuperável ${item.id} (${item.filename ?? 'sem arquivo'})` + + `${item.previousStatus ? ` ${item.previousStatus} → ${item.newStatus ?? 'draft'}` : ''}: ${item.reason ?? 'fonte indisponível'}`, + ) + } + for (const item of repair.failed) { + lines.push( + ` - falha ${item.id} (${item.filename ?? 'sem arquivo'})${item.stage ? ` [${item.stage}]` : ''}: ${item.reason ?? 'erro'}`, + ) + } + return lines +} diff --git a/scripts/lib/archiveIntegrityRepair.mjs b/scripts/lib/archiveIntegrityRepair.mjs new file mode 100644 index 00000000..e569bfa4 --- /dev/null +++ b/scripts/lib/archiveIntegrityRepair.mjs @@ -0,0 +1,146 @@ +/** + * C246 — one repair decision of the archive integrity sweep: resolve the + * Flickr source by `flickrId`, download it, prove it decodes, replace the + * stored object through the collection's own write path and verify the result. + * Every side effect is injected (Flickr client, download, inspector, Payload + * repair, withdraw, sha256), so the unit spec drives every branch with fakes and + * the CLI stays a thin composition. No writes happen here beyond the caller's + * functions. + * + * Classification: a gone/refused source (deleted/private Flickr photo) or a + * source that does not decode is `unrecoverable` (withdraw); a transient + * source/download failure is `failed` (retried on the next run); a write or + * verification failure is `failed` too — the object was not proven good. + */ +import { mkdir, readFile, rm, stat } from 'node:fs/promises' +import { join } from 'node:path' +import sharp from 'sharp' + +import { archivePhotoStorageFilename } from '../../src/lib/archivePhoto.ts' + +import { sha256Hex } from './cli.mjs' +import { FLICKR_USER_AGENT, isFlickrSourceGone } from './flickrApi.mjs' + +/** A photo original is MBs; the guard only catches a stalled/runaway server. */ +const PHOTO_DOWNLOAD_MAX_BYTES = 512 * 1024 * 1024 +const PHOTO_DOWNLOAD_TIMEOUT_MS = 2 * 60_000 +/** Long edge of the decode proof; the same size the face index feeds the engine. */ +const DECODE_MAX_EDGE = 1024 + +const messageOf = (error) => (error instanceof Error ? error.message : String(error)) + +/** + * Every side effect arrives injected; loose types are deliberate (the unit spec + * drives them with `vi.fn` fakes and the CLI with the real owners). + * + * @typedef {object} ArchiveIntegrityRepairDeps + * @property {{ id: number, flickrId: string | number, filename?: string | null }} row + * @property {string} tempRoot + * @property {string} staticDir + * @property {(args: { media: { filename: string | null }, staticDir: string, destinationPath: string }) => Promise<{ status: string, stage?: string | null, reason?: string | null, bytes?: number | null }>} inspect + * @property {any} payload + * @property {{ getLargestSize: (flickrId: string) => Promise<{ url?: string | null } | null> }} client + * @property {(payload: any, args: { id: number, filePath: string }) => Promise<{ status: string, filename?: string | null, error?: string | null }>} repair + * @property {(args: { url: string, destinationPath: string, headers: Record, timeoutMs: number, maxBytes: number }) => Promise} download + * @property {(entry: { id: number, flickrId: string, filename: string | null, reason: string }) => Promise} withdraw + */ + +/** + * @param {ArchiveIntegrityRepairDeps} deps + * @returns {Promise} + */ +export const repairArchivePhotoFromSource = async ({ + row, + tempRoot, + staticDir, + inspect, + payload, + client, + repair, + download, + withdraw, +}) => { + const flickrId = String(row.flickrId) + const base = { id: row.id, flickrId, filename: row.filename ?? null } + const dir = join(tempRoot, `repair-${row.id}`) + await mkdir(dir, { recursive: true }) + + try { + let source + try { + source = await client.getLargestSize(flickrId) + } catch (error) { + if (isFlickrSourceGone(error)) { + return await withdraw({ + ...base, + reason: `o Flickr recusou a fonte (${messageOf(error)})`, + }) + } + return { ...base, action: 'failed', stage: 'source', reason: messageOf(error) } + } + if (!source?.url) { + return await withdraw({ + ...base, + reason: 'o Flickr não devolveu nenhum tamanho utilizável para a foto', + }) + } + + const filename = row.filename ?? archivePhotoStorageFilename(flickrId, source.url) + const filePath = join(dir, filename) + try { + await download({ + url: source.url, + destinationPath: filePath, + headers: { 'User-Agent': FLICKR_USER_AGENT }, + timeoutMs: PHOTO_DOWNLOAD_TIMEOUT_MS, + maxBytes: PHOTO_DOWNLOAD_MAX_BYTES, + }) + } catch (error) { + return { ...base, action: 'failed', stage: 'download', reason: messageOf(error) } + } + + let bytes = 0 + try { + bytes = (await stat(filePath)).size + await sharp(filePath, { failOn: 'error' }) + .rotate() + .resize({ width: DECODE_MAX_EDGE, withoutEnlargement: true }) + .toBuffer() + } catch (error) { + return await withdraw({ + ...base, + reason: `a fonte do Flickr não decodifica (${messageOf(error)})`, + }) + } + + const repaired = await repair(payload, { id: row.id, filePath }) + if (repaired.status === 'failed') { + return { ...base, action: 'failed', stage: 'repair', reason: repaired.error } + } + + const repairedFilename = repaired.filename ?? filename + const verification = await inspect({ + media: { filename: repairedFilename }, + staticDir, + destinationPath: join(dir, `verify-${repairedFilename}`), + }) + if (verification.status !== 'ok') { + const reason = + verification.status === 'missing' + ? 'o objeto reparado não foi encontrado depois da escrita' + : `${verification.stage}: ${verification.reason}` + return { ...base, filename: repairedFilename, action: 'failed', stage: 'verify', reason } + } + + return { + ...base, + filename: repairedFilename, + action: 'recovered', + sourceUrl: source.url, + sha256: sha256Hex(await readFile(filePath)), + bytes: verification.bytes || bytes, + } + } finally { + await rm(dir, { recursive: true, force: true }).catch(() => undefined) + } +} diff --git a/scripts/lib/flickrApi.mjs b/scripts/lib/flickrApi.mjs index 5c92b164..14a1f498 100644 --- a/scripts/lib/flickrApi.mjs +++ b/scripts/lib/flickrApi.mjs @@ -24,7 +24,7 @@ const FLICKR_TIMEOUT_MS = 30_000 const FLICKR_RETRY_ATTEMPTS = 4 const FLICKR_RETRY_BASE_DELAY_MS = 1_000 -class FlickrApiError extends Error { +export class FlickrApiError extends Error { /** * @param {string} message * @param {{ code?: string | number | null, status?: number | null }} [options] @@ -45,6 +45,21 @@ const isRetryable = (error) => { return true } +/** + * C246 — a terminal refusal that says the SOURCE is gone: the Flickr + * "photo not found" `stat=fail` codes (1/2) or an HTTP 404. Auth/service + * failures (`stat=fail` 100/105, HTTP 401/403) and transient errors (429/5xx, + * network) are NOT a source verdict — the integrity repair keeps them as + * retryable instead of withdrawing photos because of a bad API key or an + * outage. + */ +const FLICKR_SOURCE_GONE_CODES = new Set([1, 2]) + +export const isFlickrSourceGone = (error) => + error instanceof FlickrApiError && + (error.status === 404 || + (error.status === null && FLICKR_SOURCE_GONE_CODES.has(Number(error.code)))) + /** * @param {{ * apiKey?: string, diff --git a/scripts/lib/test-affected-core.mjs b/scripts/lib/test-affected-core.mjs index b9f1fa14..519ac13b 100644 --- a/scripts/lib/test-affected-core.mjs +++ b/scripts/lib/test-affected-core.mjs @@ -44,6 +44,8 @@ export const SCRIPTS_SPEC_PINNED = [ 'scripts/lib/agent-session.mjs', 'scripts/lib/ansi.mjs', 'scripts/lib/archiveCatalogPlan.mjs', + 'scripts/lib/archiveIntegrityPlan.mjs', + 'scripts/lib/archiveIntegrityRepair.mjs', 'scripts/lib/archivePublishPlan.mjs', 'scripts/lib/archiveVisionApi.mjs', 'scripts/lib/auto-unblock.mjs', diff --git a/src/lib/privateMedia.ts b/src/lib/privateMedia.ts index d128b4c2..4547c7c9 100644 --- a/src/lib/privateMedia.ts +++ b/src/lib/privateMedia.ts @@ -1,7 +1,8 @@ /** * C193/C199 — pure response rules of a private media artifact: the safe content - * type, the content disposition and the range/private headers. No I/O and no - * `server-only`: the streaming utility and the unit tests share this module. + * type, the content disposition, the range/private headers and the classification + * of an absent object. No I/O and no `server-only`: the streaming utility and the + * unit tests share this module. * * The owner serves every private upload collection (`reelMedia`, `recordingMedia`) * through the authenticated `/campanha` routes; the public `media` contract @@ -45,6 +46,31 @@ export type PrivateMediaRange = { headers: Record } +/** + * True when the read error means the OBJECT is absent in whichever store served + * it: S3 `NoSuchKey`/`NotFound` (or a 404 in the SDK metadata) or the local disk + * `ENOENT`. Everything else — a checksum mismatch included — is a real read + * failure of an object that exists (C246 classifies it as corrupt, never as + * missing). Pure so the response paths, the integrity sweep and the unit spec + * share one classification. + */ +export const isPrivateMediaMissingError = (error: unknown): boolean => { + if ((error as { code?: unknown } | null)?.code === 'ENOENT') return true + if (!error || typeof error !== 'object') return false + const name = 'name' in error ? String(error.name) : '' + const errorCode = 'Code' in error ? String(error.Code) : '' + const metadata = + '$metadata' in error && error.$metadata && typeof error.$metadata === 'object' + ? (error.$metadata as { httpStatusCode?: number }) + : null + return ( + name === 'NoSuchKey' || + name === 'NotFound' || + errorCode === 'NoSuchKey' || + metadata?.httpStatusCode === 404 + ) +} + const isInlineSafe = (mimeType: unknown): mimeType is string => typeof mimeType === 'string' && (PRIVATE_MEDIA_INLINE_MIME_TYPES as readonly string[]).includes(mimeType) diff --git a/src/utilities/flickr/archivePhotoIngest.ts b/src/utilities/flickr/archivePhotoIngest.ts index 6895651f..603550d3 100644 --- a/src/utilities/flickr/archivePhotoIngest.ts +++ b/src/utilities/flickr/archivePhotoIngest.ts @@ -16,7 +16,13 @@ import { withPayloadTransaction } from '@/utilities/payloadTransaction' * `flickrId`, the transactional upload of the original and the row, and the * honest result the CLI turns into the receipt. The network side (listing, * metadata, download) belongs to `pnpm flickr:import`; nothing here touches - * the Flickr API, publishes or curates. + * the Flickr API or curates. + * + * C246 adds the maintenance writes of the integrity sweep: the object replace + * of one existing row (`repairArchivePhotoObject` — same key, no field edits) + * and the unrecoverable exit from the public (`withdrawArchivePhotoFromPublic` + * — `approved → draft`; `removed` stays untouched). Both are trusted-actor + * writes with no session, like the ingestion. */ export type ArchivePhotoExisting = { @@ -182,3 +188,92 @@ export const updateArchivePhotoExif = async ( } } } + +export type ArchivePhotoRepairResult = + | { status: 'repaired'; id: number; filename: string | null; filesize: number | null } + | { status: 'failed'; id: number; error: string } + +/** + * C246 — replaces the STORED OBJECT of one existing row with the file at + * `filePath`, through the collection's own upload write path (`payload.update` + + * `filePath` + `overwriteExistingFiles`). The update carries NO data fields: the + * curation, the metadata and the `publicationStatus` are never touched; with the + * caller naming the temp file after `row.filename`, the stored key does not + * change and an `approved` photo returns to serving on the next request. Every + * failure is returned, never thrown — the caller owns the receipt. + * Same documented CLI bypass as the ingestion (trusted actor, no session). + */ +export const repairArchivePhotoObject = async ( + payload: Payload, + { id, filePath }: { id: number; filePath: string }, +): Promise => { + try { + const doc = await payload.update({ + collection: ARCHIVE_PHOTO_SLUG, + id, + data: {}, + filePath, + overwriteExistingFiles: true, + // Intentional bypass: the integrity CLI is a trusted actor with no session. + overrideAccess: true, + }) + return { + status: 'repaired', + id, + filename: doc.filename ?? null, + filesize: doc.filesize ?? null, + } + } catch (error) { + return { status: 'failed', id, error: error instanceof Error ? error.message : String(error) } + } +} + +export type ArchivePhotoWithdrawalResult = + | { status: 'withdrawn'; id: number; flickrId: string; previousStatus: string } + | { status: 'skipped'; id: number; flickrId: string; reason: 'removed' | 'already-draft' } + | { status: 'failed'; id: number; flickrId: string; error: string } + +/** + * C246 — takes one unrecoverable photo out of the public: `approved → draft` + * (the intent recommendation; `removed` is a takedown request from a person and + * stays untouched). The removal-channel guard only covers the transition INTO + * `approved`, so the downgrade never needs the channel; the collection hooks + * purge the face descriptors and revalidate the public listing on the way out. + * Every failure is returned, never thrown — the caller owns the receipt. + */ +export const withdrawArchivePhotoFromPublic = async ( + payload: Payload, + { id, flickrId }: { id: number; flickrId: string }, +): Promise => { + try { + const row = await payload.findByID({ + collection: ARCHIVE_PHOTO_SLUG, + id, + depth: 0, + // Intentional bypass: the integrity CLI is a trusted actor with no session. + overrideAccess: true, + }) + const previousStatus = row.publicationStatus + if (previousStatus === 'removed') { + return { status: 'skipped', id, flickrId, reason: 'removed' } + } + if (previousStatus === 'draft') { + return { status: 'skipped', id, flickrId, reason: 'already-draft' } + } + await payload.update({ + collection: ARCHIVE_PHOTO_SLUG, + id, + data: { publicationStatus: 'draft' }, + // Intentional bypass: same trusted actor. + overrideAccess: true, + }) + return { status: 'withdrawn', id, flickrId, previousStatus } + } catch (error) { + return { + status: 'failed', + id, + flickrId, + error: error instanceof Error ? error.message : String(error), + } + } +} diff --git a/src/utilities/privateMedia/privateMediaResponse.ts b/src/utilities/privateMedia/privateMediaResponse.ts index def17330..4bb45d60 100644 --- a/src/utilities/privateMedia/privateMediaResponse.ts +++ b/src/utilities/privateMedia/privateMediaResponse.ts @@ -13,10 +13,12 @@ import { Readable } from 'stream' import { pipeline } from 'stream/promises' import { + isPrivateMediaMissingError, PRIVATE_MEDIA_CACHE_CONTROL, privateMediaHeaders, type PrivateMediaRange, } from '@/lib/privateMedia' +import { messageOf } from '@/utilities/media/ffmpeg' import { resolveS3StorageEnv } from '@/utilities/mediaStorage' /** @@ -77,21 +79,8 @@ const privateMediaStorage = (): PrivateMediaStorage | null => { return cachedStorage } -const isMissingObject = (error: unknown): boolean => { - if (!error || typeof error !== 'object') return false - const name = 'name' in error ? String(error.name) : '' - const code = 'Code' in error ? String(error.Code) : '' - const metadata = - '$metadata' in error && error.$metadata && typeof error.$metadata === 'object' - ? (error.$metadata as { httpStatusCode?: number }) - : null - return ( - name === 'NoSuchKey' || - name === 'NotFound' || - code === 'NoSuchKey' || - metadata?.httpStatusCode === 404 - ) -} +/** Long edge of the decode probe — the same size the face index feeds the engine. */ +const INSPECTION_DECODE_MAX_EDGE = 1024 /** Resolves the on-disk path of an artifact, refusing any traversal attempt. */ const resolveLocalPath = (staticDir: string, filename: string): string => { @@ -223,7 +212,7 @@ export const buildPrivateMediaResponse = async ({ }), }) } catch (error) { - if (isMissingObject(error) || (error as NodeJS.ErrnoException)?.code === 'ENOENT') { + if (isPrivateMediaMissingError(error)) { return notFound() } throw error @@ -299,7 +288,7 @@ export const buildPrivateMediaImageResponse = async ({ }), }) } catch (error) { - if (isMissingObject(error) || (error as NodeJS.ErrnoException)?.code === 'ENOENT') { + if (isPrivateMediaMissingError(error)) { return notFound() } throw error @@ -342,3 +331,68 @@ export const downloadPrivateMediaToFile = async ({ createWriteStream(destinationPath), ) } + +export type PrivateMediaObjectInspection = + | { status: 'ok'; bytes: number } + | { status: 'missing' } + | { status: 'corrupt'; stage: 'download' | 'decode'; reason: string; bytes: number } + +/** + * C246 — read-only integrity probe of one stored object: it downloads the + * artifact through the SAME path the serving routes and the face index use + * (checksum validation of the S3 SDK included) and then forces a real decode of + * the image with sharp (the same `failOn: 'error'` decode the public grid runs). + * A missing object is classified as `missing`; every other read failure (a + * checksum mismatch, for one) is `corrupt` at the `download` stage; a file that + * downloads but does not decode is `corrupt` at the `decode` stage. Never + * throws: the caller owns the receipt. + * + * `destinationPath` is a caller-owned temp path (the sweep removes it per + * photo) — nothing here writes to the archive. + */ +export const inspectPrivateMediaObject = async ({ + media, + staticDir, + destinationPath, +}: { + media: PrivateMediaFile + staticDir: string + destinationPath: string +}): Promise => { + if (!media.filename) return { status: 'missing' } + + try { + await downloadPrivateMediaToFile({ media, staticDir, destinationPath }) + } catch (error) { + if (isPrivateMediaMissingError(error)) return { status: 'missing' } + return { + status: 'corrupt', + stage: 'download', + reason: messageOf(error, 'erro desconhecido'), + bytes: 0, + } + } + + let bytes = 0 + try { + bytes = (await stat(destinationPath)).size + } catch { + // The decode below still classifies the failure; bytes stay 0. + } + + try { + await sharp(destinationPath, { failOn: 'error' }) + .rotate() + .resize({ width: INSPECTION_DECODE_MAX_EDGE, withoutEnlargement: true }) + .toBuffer() + } catch (error) { + return { + status: 'corrupt', + stage: 'decode', + reason: messageOf(error, 'erro desconhecido'), + bytes, + } + } + + return { status: 'ok', bytes } +} diff --git a/tests/int/archiveIntegrity.int.spec.ts b/tests/int/archiveIntegrity.int.spec.ts new file mode 100644 index 00000000..b0795e46 --- /dev/null +++ b/tests/int/archiveIntegrity.int.spec.ts @@ -0,0 +1,337 @@ +// @vitest-environment node + +import { randomUUID } from 'node:crypto' +import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import type { Payload } from 'payload' +import { getPayload } from 'payload' +import sharp from 'sharp' +import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest' + +vi.mock('next/cache', () => ({ + revalidatePath: () => undefined, + revalidateTag: vi.fn(), + unstable_cache: (fn: unknown) => fn, +})) + +import { ARCHIVE_PHOTO_SLUG, type ArchivePhotoImport } from '@/lib/archivePhoto' +import config from '@/payload.config' +import { + ingestArchivePhoto, + repairArchivePhotoObject, + withdrawArchivePhotoFromPublic, +} from '@/utilities/flickr/archivePhotoIngest' +import { + inspectPrivateMediaObject, + resolvePrivateMediaStaticDir, +} from '@/utilities/privateMedia/privateMediaResponse' + +import { ARCHIVE_PHOTO_JPEG_BYTES } from '../helpers/archivePhotoFixture' +import { PHOTO_ALBUM_LEASE_KEY, withExclusiveTestDatabaseLease } from '../helpers/testDatabaseLease' + +// C246 — the integrity boundary over the real Payload `teqo_test`: the probe +// classification (ok/missing/corrupt through the serving path), the object +// repair on the same key (curation/status untouched), and the unrecoverable +// exit from the public (approved → draft, descriptors purged, removed intact). + +let payload: Payload +const createdFlickrIds = new Set() +const tempDirs: string[] = [] + +const CHANNEL = 'https://jorgesolla.example.org/remocao' + +const record = ( + flickrId: string, + overrides: Partial = {}, +): ArchivePhotoImport => ({ + flickrId, + sourceUrl: `https://www.flickr.com/photos/depjorgesolla/${flickrId}/`, + owner: '12345678@N00', + license: '0', + title: `Foto ${flickrId}`, + description: 'Registro do mandato', + tags: [], + takenAt: '2025-03-14 10:20:30', + postedAt: '2025-03-15T10:00:00.000Z', + albums: [], + geo: null, + exif: [], + originalUrl: `https://live.staticflickr.com/65535/${flickrId}_abcdef_o.jpg`, + originalKind: 'original', + ...overrides, +}) + +const tempDir = async (prefix: string): Promise => { + const dir = await mkdtemp(join(tmpdir(), prefix)) + tempDirs.push(dir) + return dir +} + +const createPhoto = async (): Promise<{ id: number; flickrId: string; filename: string }> => { + const flickrId = `c246-${randomUUID().slice(0, 8)}` + createdFlickrIds.add(flickrId) + const dir = await tempDir('archive-integrity-') + const filePath = join(dir, `flickr-${flickrId}.jpg`) + await writeFile(filePath, ARCHIVE_PHOTO_JPEG_BYTES) + const created = await ingestArchivePhoto(payload, record(flickrId), { filePath }) + if (created.status !== 'created' || !created.filename) { + throw new Error(`fixture not created: ${created.status}`) + } + return { id: created.id, flickrId, filename: created.filename } +} + +const staticDir = (): string => resolvePrivateMediaStaticDir(payload, ARCHIVE_PHOTO_SLUG) +const storedPath = (filename: string): string => join(staticDir(), filename) + +const probe = async (filename: string | null) => { + const dir = await tempDir('archive-integrity-probe-') + return inspectPrivateMediaObject({ + media: { filename }, + staticDir: staticDir(), + destinationPath: join(dir, 'probe.bin'), + }) +} + +const statusOf = async (id: number): Promise => { + const doc = await payload.findByID({ + collection: ARCHIVE_PHOTO_SLUG, + id, + depth: 0, + overrideAccess: true, + }) + return doc.publicationStatus +} + +const approve = async (id: number): Promise => { + await payload.update({ + collection: ARCHIVE_PHOTO_SLUG, + id, + data: { publicationStatus: 'approved' }, + overrideAccess: true, + }) +} + +const setChannel = async (removalChannelUrl: string | null): Promise => { + await withExclusiveTestDatabaseLease(payload, PHOTO_ALBUM_LEASE_KEY, () => + payload.updateGlobal({ + slug: 'photoAlbum', + data: { published: removalChannelUrl !== null, removalChannelUrl }, + overrideAccess: true, + }), + ) +} + +describe('archive photo integrity (C246)', () => { + beforeAll(async () => { + payload = await getPayload({ config: await config }) + await setChannel(CHANNEL) + }) + + afterAll(async () => { + for (const flickrId of createdFlickrIds) { + await payload.delete({ + collection: ARCHIVE_PHOTO_SLUG, + where: { flickrId: { equals: flickrId } }, + overrideAccess: true, + }) + } + await setChannel(null) + await Promise.all(tempDirs.map((dir) => rm(dir, { recursive: true, force: true }))) + }) + + it('reads a stored original as ok through the serving path', async () => { + const photo = await createPhoto() + + const inspection = await probe(photo.filename) + + expect(inspection).toEqual({ status: 'ok', bytes: ARCHIVE_PHOTO_JPEG_BYTES.length }) + }) + + it('classifies an undecodable object at the decode stage', async () => { + const photo = await createPhoto() + await writeFile(storedPath(photo.filename), Buffer.from('isto não é uma imagem')) + + const inspection = await probe(photo.filename) + + expect(inspection.status).toBe('corrupt') + if (inspection.status !== 'corrupt') return + expect(inspection.stage).toBe('decode') + expect(inspection.reason.length).toBeGreaterThan(0) + }) + + it('classifies a deleted object as missing', async () => { + const photo = await createPhoto() + await rm(storedPath(photo.filename), { force: true }) + + expect(await probe(photo.filename)).toEqual({ status: 'missing' }) + }) + + it('classifies a row without a stored filename as missing', async () => { + expect(await probe(null)).toEqual({ status: 'missing' }) + }) + + it('repairs the object on the same key without touching curation or status', async () => { + const photo = await createPhoto() + await approve(photo.id) + await payload.update({ + collection: ARCHIVE_PHOTO_SLUG, + id: photo.id, + data: { title: 'Título curado', curatedFields: ['alt'] }, + overrideAccess: true, + }) + await writeFile(storedPath(photo.filename), Buffer.from('quebrado')) + const replacement = await sharp({ + create: { width: 3, height: 3, channels: 3, background: { r: 255, g: 0, b: 0 } }, + }) + .jpeg() + .toBuffer() + + const dir = await tempDir('archive-integrity-repair-') + const filePath = join(dir, photo.filename) + await writeFile(filePath, replacement) + + const repaired = await repairArchivePhotoObject(payload, { id: photo.id, filePath }) + + expect(repaired).toEqual({ + status: 'repaired', + id: photo.id, + filename: photo.filename, + filesize: replacement.length, + }) + expect(await readFile(storedPath(photo.filename))).toEqual(replacement) + + const row = await payload.findByID({ + collection: ARCHIVE_PHOTO_SLUG, + id: photo.id, + depth: 0, + overrideAccess: true, + }) + expect(row.publicationStatus).toBe('approved') + expect(row.title).toBe('Título curado') + expect(row.curatedFields).toEqual(['alt']) + expect(row.filesize).toBe(replacement.length) + + // Convergence: the repaired object now inspects clean. + expect(await probe(photo.filename)).toEqual({ status: 'ok', bytes: replacement.length }) + }) + + it('reports a failed repair without touching the row', async () => { + const photo = await createPhoto() + const before = await payload.findByID({ + collection: ARCHIVE_PHOTO_SLUG, + id: photo.id, + depth: 0, + overrideAccess: true, + }) + + const failed = await repairArchivePhotoObject(payload, { + id: photo.id, + filePath: join(tmpdir(), 'nao-existe', photo.filename), + }) + + expect(failed.status).toBe('failed') + if (failed.status !== 'failed') return + expect(failed.error.length).toBeGreaterThan(0) + const after = await payload.findByID({ + collection: ARCHIVE_PHOTO_SLUG, + id: photo.id, + depth: 0, + overrideAccess: true, + }) + expect(after.publicationStatus).toBe(before.publicationStatus) + expect(after.title).toBe(before.title) + }) + + it('withdraws an unrecoverable approved photo to draft and purges descriptors', async () => { + const photo = await createPhoto() + await approve(photo.id) + await payload.create({ + collection: 'archivePhotoFace', + data: { + photo: photo.id, + model: 'modelo-de-teste', + detectedAt: new Date().toISOString(), + vector: [0.1, 0.2, 0.3], + }, + overrideAccess: true, + }) + + const withdrawn = await withdrawArchivePhotoFromPublic(payload, { + id: photo.id, + flickrId: photo.flickrId, + }) + + expect(withdrawn).toEqual({ + status: 'withdrawn', + id: photo.id, + flickrId: photo.flickrId, + previousStatus: 'approved', + }) + expect(await statusOf(photo.id)).toBe('draft') + const faces = await payload.find({ + collection: 'archivePhotoFace', + where: { photo: { equals: photo.id } }, + depth: 0, + overrideAccess: true, + }) + expect(faces.docs).toHaveLength(0) + }) + + it('never touches a removed photo', async () => { + const photo = await createPhoto() + await payload.update({ + collection: ARCHIVE_PHOTO_SLUG, + id: photo.id, + data: { publicationStatus: 'removed' }, + overrideAccess: true, + }) + + const result = await withdrawArchivePhotoFromPublic(payload, { + id: photo.id, + flickrId: photo.flickrId, + }) + + expect(result).toEqual({ + status: 'skipped', + id: photo.id, + flickrId: photo.flickrId, + reason: 'removed', + }) + expect(await statusOf(photo.id)).toBe('removed') + }) + + it('keeps an already-draft photo out of the public without a write', async () => { + const photo = await createPhoto() + + const result = await withdrawArchivePhotoFromPublic(payload, { + id: photo.id, + flickrId: photo.flickrId, + }) + + expect(result).toEqual({ + status: 'skipped', + id: photo.id, + flickrId: photo.flickrId, + reason: 'already-draft', + }) + expect(await statusOf(photo.id)).toBe('draft') + }) + + it('does not block the downgrade when the removal channel is absent', async () => { + const photo = await createPhoto() + await approve(photo.id) + await setChannel(null) + try { + const withdrawn = await withdrawArchivePhotoFromPublic(payload, { + id: photo.id, + flickrId: photo.flickrId, + }) + + expect(withdrawn.status).toBe('withdrawn') + expect(await statusOf(photo.id)).toBe('draft') + } finally { + await setChannel(CHANNEL) + } + }) +}) diff --git a/tests/unit/archiveIntegrityCli.unit.spec.ts b/tests/unit/archiveIntegrityCli.unit.spec.ts new file mode 100644 index 00000000..abb86bd3 --- /dev/null +++ b/tests/unit/archiveIntegrityCli.unit.spec.ts @@ -0,0 +1,106 @@ +import { spawnSync } from 'node:child_process' +import { join } from 'node:path' + +import { describe, expect, it } from 'vitest' + +// C246 — the integrity CLI's fail-closed guards, exercised as a subprocess: +// each refusal must happen before any payload boot, bucket read or Flickr call. + +const repoRoot = process.cwd() +const scriptPath = join(repoRoot, 'scripts', 'check-archive-integrity.mjs') + +const run = (args: string[], env: Record = {}) => + spawnSync(process.execPath, [scriptPath, ...args], { + cwd: repoRoot, + encoding: 'utf8', + timeout: 30_000, + env: { + ...process.env, + NODE_OPTIONS: '--no-deprecation --import=tsx/esm --import=./scripts/seed-loader.mjs', + NODE_ENV: 'production', + VITEST: '', + DATABASE_URL: 'postgresql://teqo:teqo@127.0.0.1:5432/teqo_1313', + PAYLOAD_SECRET: 'test-secret', + ARCHIVE_INTEGRITY_CONFIRM: '', + TEQO_ENV: '', + ALLOW_REMOTE_DB: '', + FLICKR_API_KEY: '', + S3_BUCKET: '', + S3_ENDPOINT: '', + S3_ACCESS_KEY_ID: '', + S3_SECRET_ACCESS_KEY: '', + ...env, + }, + }) + +const output = (result: ReturnType) => `${result.stdout}${result.stderr}` +const withS3 = { + S3_BUCKET: 'bucket', + S3_ENDPOINT: 'http://127.0.0.1:3900', + S3_ACCESS_KEY_ID: 'key', + S3_SECRET_ACCESS_KEY: 'secret', +} + +describe('archive:integrity write guards (C246)', () => { + it('prints the help even without a database', () => { + const result = run(['--help'], { DATABASE_URL: '' }) + + expect(result.status).toBe(0) + expect(result.stdout).toContain('ARCHIVE_INTEGRITY_CONFIRM') + expect(result.stdout).toContain('FLICKR_API_KEY') + expect(result.stdout).toContain('--only') + }) + + it('refuses --apply on a production target without the intent flag', () => { + const result = run(['--apply']) + + expect(result.status).toBe(1) + expect(output(result)).toContain('ARCHIVE_INTEGRITY_CONFIRM=1') + }) + + it('requires the declared TEQO_ENV once the intent flag is set', () => { + const result = run(['--apply'], { ARCHIVE_INTEGRITY_CONFIRM: '1' }) + + expect(result.status).toBe(1) + expect(output(result)).toContain('TEQO_ENV') + }) + + it('refuses a target database that does not match the declared environment', () => { + const result = run(['--apply'], { ARCHIVE_INTEGRITY_CONFIRM: '1', TEQO_ENV: 'staging' }) + + expect(result.status).toBe(1) + expect(output(result)).toContain('≠ "teqo_staging"') + }) + + it('refuses --apply without the complete S3_* set on a declared production target', () => { + const result = run(['--apply'], { ARCHIVE_INTEGRITY_CONFIRM: '1', TEQO_ENV: 'production' }) + + expect(result.status).toBe(1) + expect(output(result)).toContain('S3_BUCKET') + }) + + it('requires FLICKR_API_KEY for a repair with S3 configured', () => { + const result = run(['--apply'], { + ARCHIVE_INTEGRITY_CONFIRM: '1', + TEQO_ENV: 'production', + ...withS3, + }) + + expect(result.status).toBe(1) + expect(output(result)).toContain('FLICKR_API_KEY') + }) + + it('refuses even the read-only sweep on a non-local target without S3', () => { + const result = run([]) + + expect(result.status).toBe(1) + expect(output(result)).toContain('S3_BUCKET') + }) + + it('refuses an unknown argument before touching anything', () => { + const result = run(['--force']) + + expect(result.status).toBe(1) + expect(output(result)).toContain('argumento desconhecido') + }) +}) diff --git a/tests/unit/archiveIntegrityPlan.unit.spec.ts b/tests/unit/archiveIntegrityPlan.unit.spec.ts new file mode 100644 index 00000000..d8af9b84 --- /dev/null +++ b/tests/unit/archiveIntegrityPlan.unit.spec.ts @@ -0,0 +1,322 @@ +// @vitest-environment node + +import { describe, expect, it } from 'vitest' + +import { + ARCHIVE_INTEGRITY_DEFAULT_CONCURRENCY, + ARCHIVE_INTEGRITY_DEFAULT_OUT_DIR, + archiveIntegrityReportStamp, + formatArchiveIntegrityRepairReport, + formatArchiveIntegrityScanReport, + mapWithConcurrency, + parseArchiveIntegrityCliArgs, + parseArchiveIntegrityOnly, + summarizeArchiveIntegrityRepair, + summarizeArchiveIntegrityScan, +} from '../../scripts/lib/archiveIntegrityPlan.mjs' + +// C246 — the pure parser/summary of `pnpm archive:integrity`: the read-only +// default, the bounded-concurrency map and the honest accounting of the sweep +// and the repair (removed rows never enter the public gap). + +describe('parseArchiveIntegrityCliArgs', () => { + it('defaults to the read-only sweep and parses the options', () => { + expect(parseArchiveIntegrityCliArgs([])).toEqual({ + apply: false, + only: null, + limit: null, + concurrency: ARCHIVE_INTEGRITY_DEFAULT_CONCURRENCY, + out: ARCHIVE_INTEGRITY_DEFAULT_OUT_DIR, + help: false, + }) + + const apply = parseArchiveIntegrityCliArgs([ + '--apply', + '--only', + '80,83,80', + '--limit', + '5', + '--concurrency', + '2', + '--out', + 'x', + ]) + expect(apply).toEqual({ + apply: true, + only: [80, 83], + limit: 5, + concurrency: 2, + out: 'x', + help: false, + }) + + expect(parseArchiveIntegrityCliArgs(['--help']).help).toBe(true) + expect(parseArchiveIntegrityCliArgs(['-h']).help).toBe(true) + }) + + it('fails closed on bad values and unknown args', () => { + expect(() => parseArchiveIntegrityCliArgs(['--only'])).toThrow(/faltou valor/) + expect(() => parseArchiveIntegrityCliArgs(['--only', ''])).toThrow(/--only/) + expect(() => parseArchiveIntegrityCliArgs(['--only', '80,abc'])).toThrow(/--only/) + expect(() => parseArchiveIntegrityCliArgs(['--only', '80,,83'])).toThrow(/--only/) + expect(() => parseArchiveIntegrityCliArgs(['--only', '0'])).toThrow(/--only/) + expect(() => parseArchiveIntegrityCliArgs(['--limit', '0'])).toThrow(/--limit/) + expect(() => parseArchiveIntegrityCliArgs(['--limit', 'x'])).toThrow(/--limit/) + expect(() => parseArchiveIntegrityCliArgs(['--concurrency', '0'])).toThrow(/--concurrency/) + expect(() => parseArchiveIntegrityCliArgs(['--concurrency', 'x'])).toThrow(/--concurrency/) + expect(() => parseArchiveIntegrityCliArgs(['--out', '../etc'])).toThrow(/--out/) + expect(() => parseArchiveIntegrityCliArgs(['--apply=1'])).toThrow(/argumento desconhecido/) + }) + + it('parses --only without duplicates and refuses an empty selection', () => { + expect(parseArchiveIntegrityOnly('80, 83,80')).toEqual([80, 83]) + expect(() => parseArchiveIntegrityOnly(' ')).toThrow(/--only/) + }) +}) + +describe('mapWithConcurrency', () => { + it('preserves order and bounds the live workers', async () => { + let live = 0 + let peak = 0 + const results = await mapWithConcurrency([1, 2, 3, 4, 5], 2, async (item) => { + live += 1 + peak = Math.max(peak, live) + await new Promise((resolve) => setTimeout(resolve, 5)) + live -= 1 + return item * 10 + }) + + expect(results).toEqual([10, 20, 30, 40, 50]) + expect(peak).toBeLessThanOrEqual(2) + }) + + it('handles an empty list and a concurrency above the list size', async () => { + expect(await mapWithConcurrency([], 3, async () => 1)).toEqual([]) + expect(await mapWithConcurrency(['a'], 8, async (item) => item.toUpperCase())).toEqual(['A']) + }) +}) + +describe('summarizeArchiveIntegrityScan', () => { + it('keeps pending to the public gap and separates drafts and removed rows', () => { + const summary = summarizeArchiveIntegrityScan([ + { + id: 1, + flickrId: '1', + filename: 'a.jpg', + publicationStatus: 'approved', + status: 'ok', + bytes: 100, + }, + { + id: 2, + flickrId: '2', + filename: 'b.jpg', + publicationStatus: 'draft', + status: 'ok', + bytes: 50, + }, + { + id: 80, + flickrId: '80', + filename: 'flickr-80.jpg', + publicationStatus: 'approved', + status: 'corrupt', + stage: 'download', + reason: 'checksum', + bytes: 0, + }, + { + id: 83, + flickrId: '83', + filename: null, + publicationStatus: 'approved', + status: 'missing', + bytes: 0, + }, + { + id: 84, + flickrId: '84', + filename: 'd.jpg', + publicationStatus: 'draft', + status: 'corrupt', + stage: 'decode', + reason: 'lixo', + bytes: 0, + }, + { + id: 99, + flickrId: '99', + filename: 'r.jpg', + publicationStatus: 'removed', + status: 'ok', + bytes: 10, + }, + { + id: 98, + flickrId: '98', + filename: 'r2.jpg', + publicationStatus: 'removed', + status: 'corrupt', + stage: 'decode', + reason: 'lixo', + bytes: 5, + }, + ]) + + expect(summary.scanned).toBe(7) + expect(summary.ok).toBe(2) + expect(summary.pending).toBe(2) + expect(summary.draftBroken).toEqual([ + { + id: 84, + flickrId: '84', + filename: 'd.jpg', + publicationStatus: 'draft', + stage: 'decode', + reason: 'lixo', + status: 'corrupt', + }, + ]) + expect(summary.missing).toEqual([ + { + id: 83, + flickrId: '83', + filename: null, + publicationStatus: 'approved', + stage: null, + reason: null, + }, + ]) + expect(summary.corrupted).toEqual([ + { + id: 80, + flickrId: '80', + filename: 'flickr-80.jpg', + publicationStatus: 'approved', + stage: 'download', + reason: 'checksum', + }, + { + id: 84, + flickrId: '84', + filename: 'd.jpg', + publicationStatus: 'draft', + stage: 'decode', + reason: 'lixo', + }, + ]) + expect(summary.removedScanned).toBe(2) + expect(summary.removedBroken).toEqual([ + { + id: 98, + flickrId: '98', + filename: 'r2.jpg', + publicationStatus: 'removed', + stage: 'decode', + reason: 'lixo', + status: 'corrupt', + }, + ]) + expect(summary.bytes).toBe(165) + }) +}) + +describe('summarizeArchiveIntegrityRepair', () => { + it('buckets recovered, unrecoverable and failed', () => { + const summary = summarizeArchiveIntegrityRepair([ + { id: 1, flickrId: '1', filename: 'a.jpg', action: 'recovered' }, + { id: 2, flickrId: '2', filename: 'b.jpg', action: 'unrecoverable' }, + { id: 3, flickrId: '3', filename: 'c.jpg', action: 'failed' }, + { id: 4, flickrId: '4', filename: 'd.jpg', action: 'failed' }, + ]) + + expect(summary.recovered.map((item) => item.id)).toEqual([1]) + expect(summary.unrecoverable.map((item) => item.id)).toEqual([2]) + expect(summary.failed.map((item) => item.id)).toEqual([3, 4]) + }) +}) + +describe('formatting', () => { + it('stamps the receipt name', () => { + expect(archiveIntegrityReportStamp('2026-10-01T04:09:04.123Z')).toBe('2026-10-01T04-09-04-123Z') + }) + + it('prints the sweep counts and names every broken photo with its state', () => { + const report = { + scan: { + scanned: 10, + ok: 8, + missing: [ + { + id: 83, + flickrId: '83', + filename: null, + publicationStatus: 'approved', + stage: null, + reason: null, + }, + ], + corrupted: [ + { + id: 80, + flickrId: '80', + filename: 'flickr-80.jpg', + publicationStatus: 'approved', + stage: 'download', + reason: 'checksum mismatch', + }, + ], + draftBroken: [], + removedScanned: 1, + removedBroken: [], + pending: 2, + bytes: 1024 * 1024, + }, + durationMs: 2_000, + } + const text = formatArchiveIntegrityScanReport(report).join('\n') + + expect(text).toContain('varredura: 10 foto(s)') + expect(text).toContain('ausentes: 1') + expect(text).toContain('corrompidas: 1') + expect(text).toContain('pendência pública (aprovadas quebradas): 2') + expect(text).toContain('ausente 83 (sem arquivo, aprovada)') + expect(text).toContain('corrompida 80 (flickr-80.jpg, aprovada) [download]: checksum mismatch') + expect(text).toContain('1.0 MiB') + }) + + it('prints the repair buckets with provenance and the exit record', () => { + const report = { + repair: { + recovered: [ + { + id: 80, + filename: 'flickr-80.jpg', + bytes: 2048, + sha256: 'abcdef0123456789', + sourceUrl: 'https://live.staticflickr.com/x_o.jpg', + }, + ], + unrecoverable: [ + { + id: 83, + filename: null, + previousStatus: 'approved', + newStatus: 'draft', + reason: 'fonte sumiu', + }, + ], + failed: [{ id: 84, filename: 'flickr-84.jpg', stage: 'download', reason: 'timeout' }], + }, + } + const text = formatArchiveIntegrityRepairReport(report).join('\n') + + expect(text).toContain('recuperadas: 1') + expect(text).toContain('irrecuperáveis (fora do público): 1') + expect(text).toContain('falhas: 1') + expect(text).toContain('recuperada 80 (flickr-80.jpg)') + expect(text).toContain('sha256:abcdef012345…') + expect(text).toContain('irrecuperável 83 (sem arquivo) approved → draft: fonte sumiu') + expect(text).toContain('falha 84 (flickr-84.jpg) [download]: timeout') + }) +}) diff --git a/tests/unit/archiveIntegrityRepair.unit.spec.ts b/tests/unit/archiveIntegrityRepair.unit.spec.ts new file mode 100644 index 00000000..03ec4692 --- /dev/null +++ b/tests/unit/archiveIntegrityRepair.unit.spec.ts @@ -0,0 +1,231 @@ +// @vitest-environment node + +import { mkdtemp, rm, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { basename, join } from 'node:path' +import { afterAll, describe, expect, it, vi } from 'vitest' + +import { repairArchivePhotoFromSource } from '../../scripts/lib/archiveIntegrityRepair.mjs' +import { FlickrApiError } from '../../scripts/lib/flickrApi.mjs' + +import { ARCHIVE_PHOTO_JPEG_BYTES } from '../helpers/archivePhotoFixture' + +// C246 — the per-photo repair decision with every side effect faked: a gone +// source or an undecodable download leaves the public (withdraw); a transient +// source/download failure is retryable; a write/verification failure is never +// reported as recovered. + +const tempDirs: string[] = [] + +type Deps = { + row: { id: number; flickrId: string; filename: string | null; publicationStatus: string } + tempRoot: string + staticDir: string + payload: unknown + client: { getLargestSize: (flickrId: string) => Promise<{ url: string } | null> } + download: (args: { + url: string + destinationPath: string + headers: Record + timeoutMs: number + maxBytes: number + }) => Promise + repair: ( + payload: unknown, + args: { id: number; filePath: string }, + ) => Promise<{ status: string; filename?: string | null; error?: string }> + inspect: (args: { + media: { filename: string | null } + staticDir: string + destinationPath: string + }) => Promise<{ status: string; stage?: string; reason?: string; bytes?: number }> + withdraw: (entry: { + id: number + flickrId: string + filename: string | null + reason: string + }) => Promise> +} + +const makeTempRoot = async (): Promise => { + const dir = await mkdtemp(join(tmpdir(), 'c246-repair-')) + tempDirs.push(dir) + return dir +} + +const makeDeps = async ( + overrides: Partial = {}, +): Promise<{ deps: Deps; callOrder: string[] }> => { + const callOrder: string[] = [] + const deps: Deps = { + row: { id: 80, flickrId: '123', filename: 'flickr-123.jpg', publicationStatus: 'approved' }, + tempRoot: await makeTempRoot(), + staticDir: '/nao-usado', + payload: {}, + client: { + getLargestSize: vi.fn(async () => { + callOrder.push('source') + return { url: 'https://live.staticflickr.com/65535/123_o.jpg' } + }), + }, + download: vi.fn(async ({ destinationPath }: { destinationPath: string }) => { + callOrder.push('download') + await writeFile(destinationPath, ARCHIVE_PHOTO_JPEG_BYTES) + }), + repair: vi.fn(async () => { + callOrder.push('repair') + return { status: 'repaired', id: 80, filename: 'flickr-123.jpg', filesize: 268 } + }), + inspect: vi.fn(async () => { + callOrder.push('inspect') + return { status: 'ok', bytes: ARCHIVE_PHOTO_JPEG_BYTES.length } + }), + withdraw: vi.fn(async (entry: Record) => { + callOrder.push('withdraw') + return { ...entry, action: 'unrecoverable', previousStatus: 'approved', newStatus: 'draft' } + }), + ...overrides, + } + return { deps, callOrder } +} + +const repair = (deps: Deps) => repairArchivePhotoFromSource(deps) + +afterAll(async () => { + await Promise.all(tempDirs.map((dir) => rm(dir, { recursive: true, force: true }))) +}) + +describe('repairArchivePhotoFromSource (C246)', () => { + it('recovers: source → download → decode → repair → verify, with provenance', async () => { + const { deps, callOrder } = await makeDeps() + + const result = await repair(deps) + + expect(result).toMatchObject({ + id: 80, + flickrId: '123', + filename: 'flickr-123.jpg', + action: 'recovered', + sourceUrl: 'https://live.staticflickr.com/65535/123_o.jpg', + bytes: ARCHIVE_PHOTO_JPEG_BYTES.length, + }) + expect(result.sha256).toMatch(/^[0-9a-f]{64}$/) + expect(callOrder).toEqual(['source', 'download', 'repair', 'inspect']) + expect(basename(vi.mocked(deps.repair).mock.calls[0][1].filePath)).toBe('flickr-123.jpg') + }) + + it('withdraws when Flickr refuses the source (photo gone)', async () => { + const client = { + getLargestSize: vi.fn(async () => { + throw new FlickrApiError('Flickr 1: Photo not found', { code: 1 }) + }), + } + const { deps } = await makeDeps({ client }) + + const result = await repair(deps) + + expect(result).toMatchObject({ action: 'unrecoverable', newStatus: 'draft' }) + expect(result.reason).toContain('recusou a fonte') + expect(deps.repair).not.toHaveBeenCalled() + expect(deps.withdraw).toHaveBeenCalledTimes(1) + }) + + it('withdraws when the source has no usable size', async () => { + const client = { getLargestSize: vi.fn(async () => null) } + const { deps } = await makeDeps({ client }) + + const result = await repair(deps) + + expect(result).toMatchObject({ action: 'unrecoverable' }) + expect(result.reason).toContain('nenhum tamanho utilizável') + }) + + it('keeps a transient source failure as a retryable failure', async () => { + const client = { + getLargestSize: vi.fn(async () => { + throw new Error('getaddrinfo ENOTFOUND') + }), + } + const { deps } = await makeDeps({ client }) + + const result = await repair(deps) + + expect(result).toMatchObject({ action: 'failed', stage: 'source' }) + expect(deps.withdraw).not.toHaveBeenCalled() + }) + + it('keeps a download failure as retryable and never touches the object', async () => { + const download = vi.fn(async () => { + throw new Error('Download falhou (HTTP 500).') + }) + const { deps } = await makeDeps({ download }) + + const result = await repair(deps) + + expect(result).toMatchObject({ action: 'failed', stage: 'download' }) + expect(deps.repair).not.toHaveBeenCalled() + expect(deps.withdraw).not.toHaveBeenCalled() + }) + + it('withdraws when the downloaded source does not decode', async () => { + const download = vi.fn(async ({ destinationPath }: { destinationPath: string }) => { + await writeFile(destinationPath, Buffer.from('nao e imagem')) + }) + const { deps } = await makeDeps({ download }) + + const result = await repair(deps) + + expect(result).toMatchObject({ action: 'unrecoverable' }) + expect(result.reason).toContain('não decodifica') + expect(deps.repair).not.toHaveBeenCalled() + }) + + it('reports a failed object write without claiming recovery', async () => { + const repairMock = vi.fn(async () => ({ status: 'failed', id: 80, error: 'boom' })) + const { deps } = await makeDeps({ repair: repairMock }) + + const result = await repair(deps) + + expect(result).toMatchObject({ action: 'failed', stage: 'repair', reason: 'boom' }) + expect(deps.inspect).not.toHaveBeenCalled() + }) + + it('reports a failed verification of the repaired object', async () => { + const inspect = vi.fn(async () => ({ + status: 'corrupt', + stage: 'decode', + reason: 'ainda quebrado', + bytes: 1, + })) + const { deps } = await makeDeps({ inspect }) + + const result = await repair(deps) + + expect(result).toMatchObject({ + action: 'failed', + stage: 'verify', + reason: 'decode: ainda quebrado', + }) + }) + + it('derives the deterministic name when the row has no stored filename', async () => { + const repairMock = vi.fn(async (_payload: unknown, { filePath }: { filePath: string }) => ({ + status: 'repaired', + id: 81, + filename: basename(filePath), + filesize: 0, + })) + const { deps } = await makeDeps({ + row: { id: 81, flickrId: '456', filename: null, publicationStatus: 'draft' }, + repair: repairMock, + }) + + const result = await repair(deps) + + expect(result).toMatchObject({ + action: 'recovered', + filename: 'flickr-456.jpg', + }) + expect(basename(vi.mocked(deps.repair).mock.calls[0][1].filePath)).toBe('flickr-456.jpg') + }) +}) diff --git a/tests/unit/flickrApi.unit.spec.ts b/tests/unit/flickrApi.unit.spec.ts index 38692d7c..c6bdc20e 100644 --- a/tests/unit/flickrApi.unit.spec.ts +++ b/tests/unit/flickrApi.unit.spec.ts @@ -2,7 +2,12 @@ import { describe, expect, it, vi } from 'vitest' -import { FLICKR_PHOTO_EXTRAS, createFlickrClient } from '../../scripts/lib/flickrApi.mjs' +import { + FLICKR_PHOTO_EXTRAS, + FlickrApiError, + createFlickrClient, + isFlickrSourceGone, +} from '../../scripts/lib/flickrApi.mjs' // C231 — the Flickr client contract: request shape, pacing, transient retry, // named `stat=fail` refusals, the exif tolerance and the largest-size @@ -172,3 +177,31 @@ describe('createFlickrClient (C231)', () => { expect(await clientWith(onlyUndimensioned).getLargestSize('1')).toBeNull() }) }) + +describe('isFlickrSourceGone (C246)', () => { + it('treats a photo-not-found refusal and an HTTP 404 as a gone source', () => { + expect(isFlickrSourceGone(new FlickrApiError('Flickr 1: Photo not found', { code: 1 }))).toBe( + true, + ) + expect(isFlickrSourceGone(new FlickrApiError('Flickr 2: Photo not found', { code: 2 }))).toBe( + true, + ) + expect(isFlickrSourceGone(new FlickrApiError('HTTP 404', { status: 404 }))).toBe(true) + }) + + it('never withdraws a photo for auth, outage, transient or unrelated errors', () => { + expect( + isFlickrSourceGone(new FlickrApiError('Flickr 100: Invalid API Key', { code: 100 })), + ).toBe(false) + expect( + isFlickrSourceGone( + new FlickrApiError('Flickr 105: Service currently unavailable', { code: 105 }), + ), + ).toBe(false) + expect(isFlickrSourceGone(new FlickrApiError('HTTP 403', { status: 403 }))).toBe(false) + expect(isFlickrSourceGone(new FlickrApiError('HTTP 429', { status: 429 }))).toBe(false) + expect(isFlickrSourceGone(new FlickrApiError('HTTP 500', { status: 500 }))).toBe(false) + expect(isFlickrSourceGone(new Error('getaddrinfo ENOTFOUND'))).toBe(false) + expect(isFlickrSourceGone(null)).toBe(false) + }) +}) diff --git a/tests/unit/privateMedia.unit.spec.ts b/tests/unit/privateMedia.unit.spec.ts index e6110e8a..86274bd8 100644 --- a/tests/unit/privateMedia.unit.spec.ts +++ b/tests/unit/privateMedia.unit.spec.ts @@ -3,6 +3,7 @@ import { describe, expect, it } from 'vitest' import { + isPrivateMediaMissingError, PRIVATE_MEDIA_FALLBACK_MIME_TYPE, privateMediaContentDisposition, privateMediaContentType, @@ -118,3 +119,27 @@ describe('private media response rules (C193/C199)', () => { expect(headers.get('Content-Type')).toBe('video/mp4') }) }) + +describe('private media missing-object classification (C246)', () => { + it('recognizes the S3 and local-disk absence shapes', () => { + expect(isPrivateMediaMissingError({ name: 'NoSuchKey' })).toBe(true) + expect(isPrivateMediaMissingError({ name: 'NotFound' })).toBe(true) + expect(isPrivateMediaMissingError({ Code: 'NoSuchKey' })).toBe(true) + expect(isPrivateMediaMissingError({ $metadata: { httpStatusCode: 404 } })).toBe(true) + expect(isPrivateMediaMissingError(Object.assign(new Error('ENOENT'), { code: 'ENOENT' }))).toBe( + true, + ) + }) + + it('never classifies a read failure of an existing object as missing', () => { + expect(isPrivateMediaMissingError(new Error('checksum mismatch'))).toBe(false) + expect(isPrivateMediaMissingError({ name: 'BadDigest' })).toBe(false) + expect( + isPrivateMediaMissingError( + Object.assign(new Error('socket hang up'), { $metadata: { httpStatusCode: 500 } }), + ), + ).toBe(false) + expect(isPrivateMediaMissingError(null)).toBe(false) + expect(isPrivateMediaMissingError('erro')).toBe(false) + }) +})