mirror of
https://github.com/fsolla/teqo.git
synced 2026-11-14 08:53:11 +00:00
feat(campaign): C149 — botão Conectar com o Google (OAuth) substitui a configuração manual da service account (Closes #934)
This commit is contained in:
parent
0ea5c8131e
commit
ffea55d734
34 changed files with 16705 additions and 125 deletions
10
.env.example
10
.env.example
|
|
@ -65,3 +65,13 @@ DEEPINFRA_API_KEY=YOUR_DEEPINFRA_API_KEY_HERE
|
|||
# collection. Absent/malformed → the agenda shows "não configurado" and the
|
||||
# mirror stays off — the Teqo agenda never depends on this.
|
||||
GOOGLE_CALENDAR_SERVICE_ACCOUNT_KEY=
|
||||
|
||||
# C149 — Google OAuth client pair for the "Conectar com o Google" button. Create
|
||||
# a "Web application" OAuth client in the same GCP project, register the
|
||||
# redirect URIs (`<public site>/campanha/agenda/google-oauth/callback`, plus the
|
||||
# local dev origin) and publish the app to production (unverified is fine for
|
||||
# internal use) so the refresh token does not expire in ~7 days. Absent → the
|
||||
# dialog falls back to the service-account runbook and the mirror keeps working
|
||||
# via `GOOGLE_CALENDAR_SERVICE_ACCOUNT_KEY`.
|
||||
GOOGLE_CALENDAR_OAUTH_CLIENT_ID=
|
||||
GOOGLE_CALENDAR_OAUTH_CLIENT_SECRET=
|
||||
|
|
|
|||
1
docs/changelog/2026-09-12-c149.md
Normal file
1
docs/changelog/2026-09-12-c149.md
Normal file
|
|
@ -0,0 +1 @@
|
|||
**Botão "Conectar com o Google" na agenda substitui a configuração manual da service account (2026-09-12):** ativar o espelho Google Calendar (C114/C115) exigia cerimônia de engenharia — criar service account no GCP, baixar JSON, base64 numa env e compartilhar o calendário; agora candidato ou coordenação clica **Conectar com o Google** no diálogo do espelho e autoriza uma única concessão (Authorization Code + `access_type=offline` + `prompt=consent`) para o calendário da campanha. O refresh token é gravado na própria collection `googleCalendarSync` (5 campos `oauth*`, `oauthRefreshToken` com `read: false` — inalcançável por qualquer caminho de usuário, como o segredo do webhook); a migration `20260912_052226_add_google_calendar_oauth_connection` é aditiva/nullable e o schema continua com `push: false`. O motor resolve a credencial por `readGoogleCalendarAuth` (OAuth vence quando há token + client env; service account segue fallback intacto) e o client ganhou a união `GoogleCalendarAuth` (refresh_token grant) mantendo os 6 métodos e o retry de 401. A conexão tem exatamente três estados derivados (`conectado | erro | não configurado`): `invalid_grant` vira `error` com reconexão em um clique (`GoogleCalendarAuthError` → `oauthErrorPatchFor`, pinado em unit), e o sucesso limpa o erro — falha do Google nunca corrompe o Teqo. O handshake usa escopos mínimos (`calendar.events` + `calendar.calendarlist.readonly`, validados na concessão para o consent granular não conectar sem permissão de escrita), `state` HMAC-SHA256 single-use de 10min amarrado ao usuário (cookie `campaign-google-oauth-state`, padrão WebAuthn), callback GET fora do `(app)` com redirect sempre na URL pública (`getCampaignInviteBaseURL()` — nada de localhost atrás do túnel) e re-checagem do papel fresco. Desconectar limpa a conexão no Teqo e orienta revogar o app no Google. UI: card de conexão no `GoogleCalendarSyncDialog` (Conectar/Reconectar/Desconectar; advisor vê sem botões; leader lockdown intocado) com o runbook antigo só quando o client OAuth não está configurado no servidor. Duas envs novas (`GOOGLE_CALENDAR_OAUTH_CLIENT_ID`/`_SECRET`) documentadas no `.env.example` e no runbook `docs/ops/teqo-1313-deploy.md` (criar o OAuth client Web application, registrar os redirect URIs e publicar o app unverified para o token não expirar em 7 dias) — **pré-requisito de deploy: setá-las em `~/stack/teqo-1313.env`**. Verificação: unit full 2716 passed, int full 722 passed (os specs da família Google agora serializam por advisory lock — a collection é singleton e a corrida entre arquivos paralelos sumiu), e2e `campaignAgendaGoogleSync` 9/9 local incluindo o botão de consent, typecheck/lint/format/knip/cycles verdes. Débitos deferidos com gatilho no impl plan (`docs/plans/c149-conectar-google-oauth-impl.md`): extrair o cookie assinado compartilhado no 4º site, cifrar o refresh token em repouso se compliance exigir, e a constraint única do singleton para o C150.
|
||||
|
|
@ -175,6 +175,28 @@ automaticamente — **não serve push, não recebe PRs, é somente leitura**.
|
|||
- Segredos nunca ecoados: sem `set -x`, senhas via `--password-stdin` /
|
||||
build-secrets; envs só no homeserver.
|
||||
|
||||
## C149 — Google OAuth da agenda (envs novas + GCP)
|
||||
|
||||
O botão **"Conectar com o Google"** da agenda usa um OAuth client do GCP. Sem
|
||||
as envs, o diálogo cai no runbook antigo da service account — que segue como
|
||||
fallback e não foi removida por esta entrega.
|
||||
|
||||
1. No GCP (mesmo projeto da service account), crie um OAuth client **Web
|
||||
application** com os redirect URIs:
|
||||
- `https://jorgesolla1313.com.br/campanha/agenda/google-oauth/callback`
|
||||
- `http://localhost:<porta>/campanha/agenda/google-oauth/callback` (dev)
|
||||
2. Publique o app em produção — **unverified** é aceito (uso interno). Em
|
||||
"Testing" o refresh token expira em ~7 dias e o estado vira `erro` na UI.
|
||||
3. Adicione ao `~/stack/teqo-1313.env` (homeserver, chmod 600):
|
||||
`GOOGLE_CALENDAR_OAUTH_CLIENT_ID` e `GOOGLE_CALENDAR_OAUTH_CLIENT_SECRET`.
|
||||
4. Deploy normal (`workflow_dispatch` do `deploy.yml`). O callback monta o
|
||||
redirect a partir de `NEXT_PUBLIC_SITE_URL` — precisa bater exatamente com
|
||||
o URI registrado no passo 1.
|
||||
|
||||
Desconectar no Teqo apaga o refresh token daquele lado; para revogar o acesso
|
||||
de fato, revogue o app em `myaccount.google.com/permissions` (a UI orienta o
|
||||
passo).
|
||||
|
||||
## OPS79 — última migração da plataforma antiga → nova (vertical campanha)
|
||||
|
||||
Operação de dados executada em 2026-08-23. Ver assistência lógica completa:
|
||||
|
|
|
|||
175
docs/plans/c149-conectar-google-oauth-impl.md
Normal file
175
docs/plans/c149-conectar-google-oauth-impl.md
Normal file
|
|
@ -0,0 +1,175 @@
|
|||
# Impl: C149 — Google Calendar: botão Conectar Google (OAuth) substitui a configuração manual de service account
|
||||
|
||||
Status: em execução
|
||||
Atualizado em: 2026-09-12
|
||||
Issue: #934
|
||||
Intenção: docs/plans/c149-conectar-google-oauth.md
|
||||
Appetite restante: ~2 dias eng (herdado)
|
||||
|
||||
## Leitura da intenção
|
||||
|
||||
- **Outcome:** candidato/coordenador conecta a conta Google do calendário da campanha com um clique (consent), sem criar service account nem colar JSON; o estado da conexão é honesto (`conectado | erro | não configurado`), erro de token reconecta em um clique e o Teqo segue SoT.
|
||||
- **Não negociar:** escopos mínimos literais (`calendar.events` + `calendar.calendarlist.readonly`); exatamente três estados, derivados, nunca armazenados; `GOOGLE_CALENDAR_SERVICE_ACCOUNT_KEY` preservada como fallback (não renomear/remover); token sobrevive sem re-consentimento semanal; não é OAuth por pessoa (anti-goal C114); motor de reconciliação intocado; callback sempre na URL pública (`getCampaignInviteBaseURL()`); token nunca em log; armazenamento fail-closed (sem conexão → sem sync, sem crash); leader lockdown intocado.
|
||||
- **Reavaliar:** app unverified exige publicar em produção no GCP para o refresh token não expirar em ~7 dias (decisão de ops, D14); armazenamento do refresh token em texto puro é débito consciente com gatilho de revisão (D1); advisor vê o card sem botões (assumido com produto); a escrita de `calendarId` continua admin-only e será aberta no C150.
|
||||
|
||||
## Abordagem recomendada
|
||||
|
||||
```mermaid
|
||||
flowchart LR
|
||||
subgraph UI["Agenda /campanha/agenda"]
|
||||
CARD["GoogleCalendarSyncDialog<br/>card de conexão (3 estados)"]
|
||||
end
|
||||
CARD -->|startGoogleCalendarOAuth| ACT[server action]
|
||||
ACT -->|state assinado HMAC| COOKIE[(cookie campaign<br/>TTL 10min, single-use)]
|
||||
ACT -->|authorizeUrl| GOOG[accounts.google.com<br/>consent]
|
||||
GOOG -->|code + state| CB[callback GET<br/>fora do app]
|
||||
COOKIE -->|verifica state| CB
|
||||
CB -->|troca code| TOK[oauth2 token endpoint]
|
||||
TOK -->|refresh_token| DB[(googleCalendarSync<br/>oauthRefreshToken)]
|
||||
CB -->|302 /campanha/agenda| CARD
|
||||
SYNC[runCampaignCalendarSync] -->|readGoogleCalendarAuth| DB
|
||||
SYNC -->|Bearer access token| GCAL[Google Calendar API]
|
||||
SA[GOOGLE_CALENDAR_SERVICE_ACCOUNT_KEY] -.fallback quando sem OAuth.-> SYNC
|
||||
CARD -->|disconnectGoogleCalendarOAuth| ACT2[server action]
|
||||
ACT2 -->|limpa conexão| DB
|
||||
```
|
||||
|
||||
**Opções consideradas:** A) Authorization Code + rota de callback própria com state assinado em cookie | B) fluxo out-of-band (device/código colado) | C) manter service account e melhorar só o runbook.
|
||||
|
||||
**Recomendação:** A — reusa o precedente de cerimônia fora do `(app)` (WebAuthn) e o resolvedor de URL pública (`getCampaignInviteBaseURL`), entrega o consent de um clique e isola o callback para teste; B troca "um clique" por "copiar e colar código" e não tem precedente; C não entrega o outcome pedido pelo dono.
|
||||
|
||||
**Rejeitadas:** B porque adiciona atrito e uma superfície de erro nova; C porque é exatamente a cerimônia que o pedido manda remover.
|
||||
|
||||
### Componentes / mudanças
|
||||
|
||||
- **`src/collections/GoogleCalendarSync.ts`** — cinco campos novos (D1): `oauthRefreshToken` (text, `read: canReadGoogleCalendarSyncIdentityField`, `create/update: canSetGoogleCalendarSyncSystemField`), `oauthScope` (text), `oauthConnectedAt` (date), `oauthErrorAt` (date), `oauthError` (textarea); labels/admin `readOnly` em pt-BR; `admin.defaultColumns` inalterado.
|
||||
- **`src/utilities/access/googleCalendarSync.ts`** — `canManageGoogleCalendarConnection(actor)` (delega a `isCampaignUnrestricted`); novos campos usam os helpers existentes (`canSetGoogleCalendarSyncSystemField`, `canReadGoogleCalendarSyncIdentityField`).
|
||||
- **`src/migrations/<timestamp>_add_google_calendar_oauth_connection.{ts,json}` + `index.ts`** — migração aditiva (colunas nullable), gerada com `pnpm migrate:create add_google_calendar_oauth_connection`; `src/payload-types.ts` regenerado (`pnpm generate:types`).
|
||||
- **`src/lib/googleCalendarOAuth.ts`** (novo, puro) — `GOOGLE_CALENDAR_OAUTH_SCOPES`, `GOOGLE_CALENDAR_OAUTH_CALLBACK_PATH`, `GOOGLE_CALENDAR_OAUTH_AUTHORIZE_ENDPOINT`, `buildGoogleCalendarOAuthAuthorizeUrl({ clientId, redirectUri, state })` com `access_type=offline`, `prompt=consent`, `response_type=code`.
|
||||
- **`src/utilities/googleCalendarOAuth.ts`** (novo, server-only, sem importar `googleCalendarSync`) — `readGoogleCalendarOAuthCredentials()` (fail-closed), `buildGoogleCalendarOAuthRedirectUri()` (via `getCampaignInviteBaseURL()`), `storeGoogleCalendarOAuthState(state, userId)` / `readGoogleCalendarOAuthState(state)` / `clearGoogleCalendarOAuthState()` (HMAC `PAYLOAD_SECRET`, payload state+userId+exp, TTL 10min, httpOnly/lax, path `/campanha`, single-use), `exchangeGoogleCalendarOAuthCode(code, fetchImpl?)` → `{ refreshToken, scope }` ou erro tipado com mensagem segura (sem corpo/token).
|
||||
- **`src/utilities/googleCalendarClient.ts`** — `GoogleCalendarAuth = { kind: 'service-account'; credentials } | { kind: 'oauth'; refreshToken; clientId; clientSecret }`; `createGoogleCalendarClient(auth, ...)` mantém os 6 métodos e o cache em memória; `requestAccessToken` ganha o branch `grant_type=refresh_token`; novo `GoogleCalendarAuthError` APENAS para `invalid_grant` (refresh token revogado/expirado — o caso reconectável; `invalid_client`/5xx seguem `GoogleCalendarApiError`, pois reconectar não conserta); `buildServiceAccountAssertion` preservado para testes.
|
||||
- **`src/utilities/googleCalendarSync.ts`** — `readGoogleCalendarAuth(config)` (OAuth se refresh token + env; senão SA; senão `null`); `GoogleCalendarConnectionState = 'connected' | 'error' | 'not-configured'` + `deriveGoogleCalendarConnectionState`; `GoogleCalendarSyncView` ganha `connection`, `oauthAvailable`, `oauthConnectedAt`, `oauthError`; `loadGoogleCalendarSyncConfig` passa a "row com `calendarId` vence, senão qualquer row" (D11); helpers `recordGoogleCalendarOAuthConnection`, `recordGoogleCalendarOAuthError`, `clearGoogleCalendarOAuthConnection`; `runCampaignCalendarSync` resolve a auth pelo config, limpa `oauthError*` no sucesso e grava `oauthErrorAt/oauthError` no catch de `GoogleCalendarAuthError`.
|
||||
- **`src/app/(campaign)/campanha/actions/googleCalendarSync.ts`** — `startGoogleCalendarOAuth()` (valida `reloadUnrestrictedActor`, exige env, cria o row singleton se ausente com admin bypass, grava state cookie, devolve `{ ok, authorizeUrl }`) e `disconnectGoogleCalendarOAuth()` (limpa token/scope/datas/erro; sem revoke remoto; sem pass de sync); `GoogleCalendarSyncActionResult` ganha `canManageConnection` e os campos de conexão; `failure()` atualizado.
|
||||
- **`src/app/(campaign)/campanha/agenda/google-oauth/callback/route.ts`** (novo, fora do `(app)`, `dynamic = 'force-dynamic'`) — GET: lê `code/state/error`, verifica e queima o state cookie, re-checa papel fresco, troca o code, grava a conexão, roda pass de sync se houver `calendarId` e responde `NextResponse.redirect` 302 para `/campanha/agenda`; `access_denied` não grava erro; falha de troca grava `oauthError` e redireciona (sem query param).
|
||||
- **`src/components/campaign/activity/GoogleCalendarSyncDialog.tsx`** — card de conexão por estado (badge, Conectar/Reconectar/Desconectar, nota do fallback SA, nota para não-manager, runbook admin só quando `!oauthAvailable`); props novas `onStartOAuth`, `onDisconnect`, `canManageConnection`.
|
||||
- **`src/components/campaign/activity/AgendaGoogleSyncChrome.tsx`** — callbacks `onStartOAuth`/`onDisconnect` repassados; pill existente intocada.
|
||||
- **`src/app/(campaign)/campanha/(app)/agenda/page.tsx`** — passa as server actions novas ao chrome.
|
||||
- **`scripts/lib/e2e-affected-manifest.mjs`** — entry nova mapeando `src/utilities/googleCalendarSync.ts`, `googleCalendarSyncHooks.ts`, `googleCalendarClient.ts`, `googleCalendarOAuth.ts`, `src/lib/googleCalendarOAuth.ts`, `GoogleCalendarSyncDialog.tsx`, `AgendaGoogleSyncChrome.tsx` e `src/app/(campaign)/campanha/agenda/google-oauth` ao spec `campaignAgendaGoogleSync`.
|
||||
- **`playwright.config.ts`** — `GOOGLE_CALENDAR_OAUTH_CLIENT_ID`/`_SECRET` dummy no `webServer.env` (botão visível no e2e).
|
||||
- **`tests/e2e/campaignAgendaGoogleSync.e2e.spec.ts`** — estados com OAuth: botão, URL de consent interceptada, conectado e erro.
|
||||
- **`.env.example` + `docs/ops/teqo-1313-deploy.md`** — as duas envs e o runbook GCP (D14).
|
||||
- **Testes:** `tests/unit/googleCalendarOAuth.unit.spec.ts` (novo), extensões em `googleCalendarClient.unit.spec.ts`, `googleCalendarSync.unit.spec.ts`, `googleCalendarSyncDialog.unit.spec.tsx`, `agendaGoogleSyncChrome.unit.spec.tsx`, `tests/int/googleCalendarSync.int.spec.ts`, `tests/int/googleCalendarSyncAction.int.spec.ts`; allowlist top-level de `tests/unit/codebaseConventions.unit.spec.ts` ganha `googleCalendarOAuth.ts`.
|
||||
|
||||
### Dados → forma
|
||||
|
||||
Estado da conexão é **derivado** (nunca armazenado), função de `oauthRefreshToken`, `oauthConnectedAt` e `oauthErrorAt`:
|
||||
|
||||
| refresh token | erro mais novo que a conexão | estado | UI |
|
||||
| ------------- | ----------------------------------------------------------- | ---------------- | ------------------------------------------------------------------- |
|
||||
| não | não | `not-configured` | "Não configurado" + Conectar (se `oauthAvailable`) ou runbook admin |
|
||||
| sim ou não | sim (`oauthErrorAt > oauthConnectedAt`, inclui sem conexão) | `error` | "Erro na conexão" + Reconectar + Desconectar |
|
||||
| sim | não | `connected` | "Conectado" + Desconectar |
|
||||
|
||||
`oauthAvailable` (env do client presente) é ortogonal ao estado e decide botão vs. runbook. `oauthScope`, `oauthConnectedAt` e `oauthError` são apenas informativos/derivados; o token nunca cruza a fronteira da view.
|
||||
|
||||
## Decisões de engenharia (decisão + por quê + rejeitadas; só as caras de reverter)
|
||||
|
||||
1. **Armazenamento na própria `googleCalendarSync` (D1):** campos novos na collection que já é dona do estado do espelho; sem collection twin. Texto puro com precedente (`SocialFeedSettings.instagramAccessToken`, `pushChannelSecret`), `read: false` no token e nunca logar. **Rejeitadas:** AES-GCM (sem precedente no repo, rotação de chave e o DB comprometido já é game over) — vira débito com gatilho de revisão; collection nova (twin).
|
||||
2. **Credenciais do app OAuth em env (D2):** `GOOGLE_CALENDAR_OAUTH_CLIENT_ID`/`_SECRET`; redirect URI sempre derivado de `getCampaignInviteBaseURL()` + `/campanha/agenda/google-oauth/callback`. **Rejeitadas:** client no DB (não é per-campanha); override de redirect por env (risco de localhost vazar).
|
||||
3. **Transporte por união de auth (D3):** `GoogleCalendarAuth` com SA (JWT assertion, comportamento atual) e OAuth (refresh_token grant) na mesma superfície de 6 métodos, mesmo cache e mesmo retry de 401; `GoogleCalendarAuthError` separa conexão quebrada (`invalid_grant`, reconectável) de erro de API/config (`invalid_client`, 5xx → pausa). **Rejeitadas:** segundo client OAuth paralelo; tratar todo erro de token como auth error (invalid_client viraria "reconecte" sem conserto).
|
||||
4. **Resolução e limpeza no motor (D4):** `readGoogleCalendarAuth` prefere OAuth quando há refresh token + env, senão SA, senão `null`; sucesso limpa `oauthError*`, `GoogleCalendarAuthError` grava `oauthErrorAt/oauthError` além de `lastError`/paused. **Rejeitada:** fallback silencioso para SA com token quebrado (mascararia o erro e tornaria o estado `error` inalcançável com SA configurada).
|
||||
5. **Estado derivado (D5):** `'connected' | 'error' | 'not-configured'` como função pura dos campos, nunca coluna nova. **Rejeitadas:** armazenar o estado (drift); quarto estado intermediário.
|
||||
6. **Papéis (D6):** conectar/desconectar = `isCampaignUnrestricted` via `canManageGoogleCalendarConnection`, enforcement com `reloadUnrestrictedActor` na action; advisor vê o card sem botões; leader não vê a agenda. **Rejeitadas:** advisor conectar (credencial global); liberar por `canUpdateGoogleCalendarSync` (staff inteiro).
|
||||
7. **Fluxo: action de start + callback GET com state em cookie (D7):** `startGoogleCalendarOAuth` devolve `authorizeUrl` e o client navega; callback valida state (HMAC, TTL 10min, single-use, bound ao userId), re-checa papel fresco, troca o code e responde 302. **Rejeitadas:** searchParam/hash para aviso pós-callback (a canonicalização de `resolveActivityAgendaUrl` come parâmetros desconhecidos); rota GET de start (menos testável e sem precedente); revogação remota no disconnect.
|
||||
8. **Desconectar limpa só o Teqo (D8):** remove refresh token/scope/datas/erro com admin bypass e orienta revogar no Google; sem pass de sync automático (o próximo trigger cai no fallback SA, se houver). **Rejeitada:** chamar `/revoke` no Google (fora do literal da intenção).
|
||||
9. **Módulos (D9):** puro em `src/lib/googleCalendarOAuth.ts`, server-only em `src/utilities/googleCalendarOAuth.ts` (sem importar `googleCalendarSync` para não criar ciclo), record/clear e resolução no `googleCalendarSync.ts`, client no `googleCalendarClient.ts`; registrar o novo top-level na allowlist. **Rejeitadas:** um módulo único misturando puro/server; lógica OAuth dentro do client.
|
||||
10. **UI (D10):** card de conexão dentro do `GoogleCalendarSyncDialog`; pill existente e motor intocados; runbook admin só quando `!oauthAvailable`. **Rejeitadas:** diálogo novo; mexer na pill de status do espelho.
|
||||
11. **Loader singleton (D11):** "row com `calendarId` vence; senão qualquer row" para o row da conexão não ficar escondido pelo filtro atual. **Rejeitada:** manter o filtro e criar row só no primeiro sync (a conexão ficaria invisível).
|
||||
12. **Manifest e2e (D12):** entry do domínio Google Calendar → `campaignAgendaGoogleSync`, fechando o gap pré-existente. **Rejeitada:** confiar só no fallback de smoke (o spec não rodaria no PR).
|
||||
13. **E2E determinístico (D13):** envs dummy no webServer; estados conectado/erro com `lastSuccessAt` futuro para não disparar auto-retry; consent assertado com `page.route('https://accounts.google.com/**')`. **Rejeitada:** consent real (rede/flake).
|
||||
14. **Ops documentado, sem código (D14):** OAuth client "Web application" no GCP com redirect URIs (prod + dev), escopos, app **publicado em produção (unverified)** para o refresh token não expirar em 7 dias; envs em `~/stack/teqo-1313.env`; aviso de app não verificado aceito (decisão de produto A).
|
||||
|
||||
## Fases verificáveis
|
||||
|
||||
1. **Tracer/schema + server (~55%, ≈1,1 dia)**
|
||||
- Campos + access + `pnpm migrate:create add_google_calendar_oauth_connection` + `pnpm migrate` + `pnpm generate:types`.
|
||||
- `src/lib/googleCalendarOAuth.ts` e `src/utilities/googleCalendarOAuth.ts` com unit (URL, escopos, state TTL/single-use, env fail-closed, exchange sem token em mensagem).
|
||||
- Auth union + `GoogleCalendarAuthError` no client com unit (SA inalterado, refresh grant, 401 re-mint).
|
||||
- `readGoogleCalendarAuth`, derivação do estado, loader D11, record/clear, sucesso limpa erro, catch de `GoogleCalendarAuthError`; unit/int do sync atualizados.
|
||||
- Actions start/disconnect + `canManageGoogleCalendarConnection`; int com mock de `campaignActionContext` (padrão de `googleCalendarSyncAction.int.spec.ts`).
|
||||
- Allowlist top-level de `codebaseConventions` registrada. Gate: `pnpm gate:fast`.
|
||||
2. **Callback + UI + e2e + docs (~35%, ≈0,7 dia)**
|
||||
- Callback route (state, papel fresco, exchange, grava, pass se `calendarId`, 302).
|
||||
- Dialog/chrome/page; unit de dialog/chrome; copy pt-BR dos três estados + orientação de revogar no Google.
|
||||
- e2e: envs no `playwright.config.ts`, spec com botão/consent/conectado/erro, entry no manifest.
|
||||
- `.env.example` + runbook em `docs/ops/teqo-1313-deploy.md`. Gate: `pnpm test:e2e:affected` (deve incluir `campaignAgendaGoogleSync`).
|
||||
3. **Gates finais (~10%, ≈0,2 dia)**
|
||||
- `pnpm gate:fast`; e2e afetado local (`pnpm test:e2e:affected`); `pnpm push`.
|
||||
|
||||
## Rabbit holes / Não escopo (engenharia)
|
||||
|
||||
- **Criptografar o refresh token no DB (AES-GCM/keyring):** débito registrado com gatilho (rotação de chave/requisito de compliance); não entra.
|
||||
- **Chamar `/revoke` no Google ao desconectar:** só orientação na UI (literal da intenção).
|
||||
- **Listar/escolher calendário:** C150; aqui só a conexão.
|
||||
- **Verificação completa do app Google / auditoria:** ops, fora.
|
||||
- **Aposentar/migrar a service account:** fora; segue fallback.
|
||||
- **Watchdog de expiração do refresh token / cron:** fora; o erro aparece no próximo pass e a UI oferece Reconectar.
|
||||
- **Módulo genérico de envs:** fora; `process.env` direto como no resto do repo.
|
||||
- **Rate limiting nas rotas OAuth:** fora; state + TTL bastam para este escopo.
|
||||
- **Motor de reconciliação, janela, locks, hooks C114/C122:** intocados.
|
||||
- **Aviso pós-callback via searchParam/hash:** rejeitado; redirect simples para `/campanha/agenda`.
|
||||
- **OAuth por pessoa / agenda pessoal:** anti-goal C114.
|
||||
|
||||
## Riscos e mitigação
|
||||
|
||||
- **Refresh token expira em ~7 dias (app unverified em testing):** D14 publica o app em produção (unverified) no GCP; se expirar, estado vira `error` e Reconectar resolve. Risco residual aceito (produto A).
|
||||
- **Localhost vazando no redirect atrás do túnel:** redirect URI sempre de `getCampaignInviteBaseURL()` (prod exige HTTPS DNS); unit pin do path do callback; e2e asserta `redirect_uri` público.
|
||||
- **CSRF/replay do callback:** state HMAC `PAYLOAD_SECRET` com TTL 10min, single-use (queimado antes da troca) e binding ao userId; papel re-checado fresco.
|
||||
- **Token em log:** exchange não loga corpo; mensagens usam status/código seguros; `oauthRefreshToken` com `read: false`; unit garante ausência de token/code na mensagem de erro.
|
||||
- **Falha do Google corromper dados:** Teqo SoT; `GoogleCalendarAuthError` só grava `oauthError*`/paused; int cobre `invalid_grant` sem apagar/duplicar eventos.
|
||||
- **OAuth quebrado sem fallback SA:** decisão consciente (D4); a UI expõe erro + Reconectar e o runbook explica.
|
||||
- **Upsert do row/corrida:** start cria o singleton com admin bypass; única escrita concorrente é o callback; sem lock novo.
|
||||
- **Migration em prod:** aditiva/nullable, aplicada pelo deploy (`pnpm build` roda `payload migrate`); sem backfill; baseline intocada.
|
||||
- **`unmapped-risk` no CI:** `src/utilities/access` já é mapeado; a entry nova do domínio Google entra no mesmo PR — falhar fechado é o comportamento desejado.
|
||||
- **Auto-retry em erro de conexão:** uma tentativa por load da agenda (sem hammer); falha rápida no token endpoint.
|
||||
|
||||
## Qualidade da decisão
|
||||
|
||||
**Auto-avaliação: 4/5** — as decisões já vêm fechadas e ancoradas em achados concretos (rotas de cerimônia fora do `(app)`, cookie HMAC do WebAuthn, precedente de credencial no DB, canonicalização da agenda, gap do manifest), e o caminho crítico é testável sem tocar o Google. Perde 1 ponto por dependências fora do código: o refresh token de app unverified é uma promessa de ops (publicar em produção no GCP) e o e2e não exercita consent real, então a prova de produção fica no smoke manual pós-deploy; o armazenamento em texto puro é débito aceito com gatilho, não resolvido.
|
||||
|
||||
## Aceite de engenharia
|
||||
|
||||
- [ ] Botão "Conectar com o Google" no card da agenda para candidato/coordenador; advisor vê o card sem botões; leader sem agenda (lockdown intocado).
|
||||
- [ ] Estados exatamente `conectado | erro | não configurado`, derivados de `oauthRefreshToken`/`oauthConnectedAt`/`oauthErrorAt`.
|
||||
- [ ] Erro de token (`invalid_grant`) mostra Reconectar em um clique; `access_denied` não grava erro.
|
||||
- [ ] Callback atrás do túnel usa a URL pública (`getCampaignInviteBaseURL()`); sem localhost em produção.
|
||||
- [ ] Token nunca em log; `oauthRefreshToken` com `read: false`; exchange sem corpo em erro.
|
||||
- [ ] Fail-closed: sem auth → `runCampaignCalendarSync` retorna cedo, sem crash e sem sync.
|
||||
- [ ] Desconectar limpa a conexão no Teqo e orienta revogar no Google; sem pass de sync automático.
|
||||
- [ ] Service account segue como fallback; `GOOGLE_CALENDAR_SERVICE_ACCOUNT_KEY` não renomeada/removida.
|
||||
- [ ] Escopos literais `calendar.events` + `calendar.calendarlist.readonly`; `access_type=offline` + `prompt=consent`.
|
||||
- [ ] Migration nova aplicada local + `payload-types.ts` regenerado; nenhuma migration antiga editada.
|
||||
- [ ] `pnpm gate:fast` verde; `pnpm test:e2e:affected` inclui `campaignAgendaGoogleSync` (manifest atualizado) e passa local.
|
||||
- [ ] `.env.example` e runbook de ops (GCP, envs, app publicado unverified) documentados.
|
||||
|
||||
## Execução — desvios e decisões emergentes (2026-09-12)
|
||||
|
||||
- **Classificação do `invalid_grant` virou helper puro** (`oauthErrorPatchFor` em `googleCalendarSync.ts`, pinado em unit): o catch do motor é I/O-bound e o int de motor com row configurado colidia com os specs C114 paralelos. O comportamento é o mesmo; a prova ficou no unit.
|
||||
- **Escopos concedidos são validados no exchange**: consent granular permite desmarcar `calendar.events`; sem a checagem o card diria "Conectado" e o sync falharia 403 sem caminho de reconexão.
|
||||
- **Callback só grava `oauthError` quando o EXCHANGE falha** (papel recusado/env ausente não sujam o estado de uma conexão sadia; um erro transitório se auto-cura no próximo pass bem-sucedido, que limpa o campo).
|
||||
- **Suíte int dos specs Google serializada por advisory lock** (`GOOGLE_CALENDAR_SYNC_LOCK_KEY` + `serializeSpecWithAdvisoryLock` em `tests/helpers/advisoryLock.ts`): a collection é singleton e o loader "row configurado vence" fazia specs paralelos lerem o doc um do outro. A serialização elimina a dependência de `retry` da família.
|
||||
- **Specs novos não criam rows configurados** (só connection-only) e limpam tokens em rows compartilhados sem deletá-los; o pass do callback é mockado no spec de actions (o motor tem spec próprio).
|
||||
- **`.env.example` + runbook C149 em `docs/ops/teqo-1313-deploy.md`** documentam o OAuth client, os redirect URIs e a publicação unverified.
|
||||
|
||||
## Débitos (triage do /simplify)
|
||||
|
||||
| ID | Achado | Score | Tipo | Destino |
|
||||
| --- | ------------------------------------------------------------------- | ----- | ------------- | ------------------------------------------------------------- |
|
||||
| S1 | Cookie HMAC — 3ª cópia (`googleCalendarOAuth` vs WebAuthn vs import) | 3 | defer_trigger | Defer: extrair `signedCampaignCookie` no 4º cookie assinado ou no próximo toque de um dos três |
|
||||
| S2 | Refresh token OAuth em texto puro no DB | 3 | defer_trigger | Defer: cifrar em repouso quando compliance/counsel exigir (o campo já é `read: false` e o precedente do repo é texto puro) |
|
||||
| S3 | Upsert do singleton sem constraint única | 2 | defer_trigger | Defer para **C150** (dono do `calendarId`): decidir constraint se twins virarem risco real |
|
||||
| S4 | Helper de env dos testes duplicado em 3 specs | 2 | cheap_polish | Descartado (barato, local; os specs estão verdes) |
|
||||
| S5 | `docToView` com 3 flags posicionais | 1 | cheap_polish | Descartado (cosmético) |
|
||||
|
||||
- **Já resolvido no simplify (não reabrir):** índice em `oauthConnectedAt` removido; `failure()` sem diagnóstico falso; copy do card ajustada; `Date.parse` na derivação; guarda do `parseAccessToken`; validação de escopo concedido; prefixo morto do manifest; callback grava erro só do exchange; `runAction` no dialog; classify puro `oauthErrorPatchFor`; serialização int.
|
||||
- **Explicitamente fora:** criptografia do refresh token agora; revogação remota no Google (só orientação); rate limiting nas rotas OAuth; verificação completa do app Google; aposentadoria da service account.
|
||||
|
|
@ -228,6 +228,17 @@ export default defineConfig({
|
|||
* e2e states stay deterministic (see `googleCalendarTestKey.ts`).
|
||||
*/
|
||||
[GOOGLE_CALENDAR_SERVICE_ACCOUNT_KEY_ENV_NAME]: GOOGLE_CALENDAR_TEST_KEY,
|
||||
/*
|
||||
* C149 — dummy OAuth client pair so the agenda dialog offers the
|
||||
* "Conectar com o Google" button (the view derives `oauthAvailable`).
|
||||
* No spec seeds a refresh token on the shared sync row except with a
|
||||
* far-future success, and the button test intercepts the navigation
|
||||
* to Google — no request ever leaves the runner for oauth2.
|
||||
*/
|
||||
GOOGLE_CALENDAR_OAUTH_CLIENT_ID:
|
||||
process.env.GOOGLE_CALENDAR_OAUTH_CLIENT_ID ?? 'e2e-client-id.apps.googleusercontent.com',
|
||||
GOOGLE_CALENDAR_OAUTH_CLIENT_SECRET:
|
||||
process.env.GOOGLE_CALENDAR_OAUTH_CLIENT_SECRET ?? 'e2e-client-secret',
|
||||
// Keep e2e artifacts outside `.next`: a concurrent development server
|
||||
// owns that entire directory and may clear nested production bundles.
|
||||
NEXT_DIST_DIR: process.env.NEXT_DIST_DIR ?? '.next-e2e',
|
||||
|
|
|
|||
|
|
@ -111,6 +111,23 @@ export const E2E_AFFECTED_MANIFEST = [
|
|||
// iCal feed dialog. A change there must wake all three surfaces.
|
||||
specs: ['campaignActivity', 'campaignAgendaGoogleSync', 'campaignAgendaFeed'],
|
||||
},
|
||||
{
|
||||
// C149 — the Google Calendar OAuth connection surface. The engine/client
|
||||
// utilities live top-level (not under `src/utilities/activity`), and the
|
||||
// callback route sits OUTSIDE `(app)` — without this entry a diff to them
|
||||
// would only wake the home smoke, leaving the mirror spec unrun.
|
||||
prefixes: [
|
||||
'src/utilities/googleCalendarSync.ts',
|
||||
'src/utilities/googleCalendarSyncHooks.ts',
|
||||
'src/utilities/googleCalendarClient.ts',
|
||||
'src/utilities/googleCalendarOAuth.ts',
|
||||
'src/lib/googleCalendarOAuth.ts',
|
||||
'src/components/campaign/activity/AgendaGoogleSyncChrome',
|
||||
'src/components/campaign/activity/GoogleCalendarSyncDialog',
|
||||
'src/app/(campaign)/campanha/agenda/google-oauth',
|
||||
],
|
||||
specs: ['campaignAgendaGoogleSync'],
|
||||
},
|
||||
{
|
||||
prefixes: [
|
||||
`${CAMPAIGN_APP}/liderancas`,
|
||||
|
|
|
|||
|
|
@ -8,9 +8,11 @@ import {
|
|||
revokeCalendarFeed,
|
||||
} from '@/app/(campaign)/campanha/actions/calendarFeed'
|
||||
import {
|
||||
disconnectGoogleCalendarOAuth,
|
||||
getGoogleCalendarSyncState,
|
||||
runGoogleCalendarSyncNow,
|
||||
setGoogleCalendarSyncDisabled,
|
||||
startGoogleCalendarOAuth,
|
||||
} from '@/app/(campaign)/campanha/actions/googleCalendarSync'
|
||||
import { ActivityAgenda } from '@/components/campaign/activity/ActivityAgenda'
|
||||
import { ActivityAgendaFilters } from '@/components/campaign/activity/ActivityAgendaFilters'
|
||||
|
|
@ -102,6 +104,14 @@ export default async function AgendaPage({ searchParams }: AgendaPageProps) {
|
|||
'use server'
|
||||
return setGoogleCalendarSyncDisabled(disabled)
|
||||
}}
|
||||
onStartOAuth={async () => {
|
||||
'use server'
|
||||
return startGoogleCalendarOAuth()
|
||||
}}
|
||||
onDisconnect={async () => {
|
||||
'use server'
|
||||
return disconnectGoogleCalendarOAuth()
|
||||
}}
|
||||
/>
|
||||
|
||||
<CampaignListPendingBoundary>
|
||||
|
|
|
|||
|
|
@ -1,11 +1,25 @@
|
|||
'use server'
|
||||
|
||||
import { randomBytes } from 'node:crypto'
|
||||
|
||||
import type { CampaignUser } from '@/payload-types'
|
||||
import type { Payload } from 'payload'
|
||||
|
||||
import { buildGoogleCalendarAddLink } from '@/lib/googleCalendarLink'
|
||||
import { getCampaignActionContext, reloadStaffActor } from '@/utilities/campaignActionContext'
|
||||
import { buildGoogleCalendarOAuthAuthorizeUrl } from '@/lib/googleCalendarOAuth'
|
||||
import { canManageGoogleCalendarConnection } from '@/utilities/access/googleCalendarSync'
|
||||
import {
|
||||
getCampaignActionContext,
|
||||
reloadCampaignActor,
|
||||
reloadStaffActor,
|
||||
} from '@/utilities/campaignActionContext'
|
||||
import {
|
||||
buildGoogleCalendarOAuthRedirectUri,
|
||||
readGoogleCalendarOAuthCredentials,
|
||||
storeGoogleCalendarOAuthState,
|
||||
} from '@/utilities/googleCalendarOAuth'
|
||||
import {
|
||||
clearGoogleCalendarOAuthConnection,
|
||||
loadGoogleCalendarSyncConfig,
|
||||
readGoogleCalendarSyncView,
|
||||
runCampaignCalendarSync,
|
||||
|
|
@ -16,21 +30,41 @@ const GOOGLE_CALENDAR_SYNC_STAFF_ONLY_MESSAGE =
|
|||
'Apenas a equipe da campanha pode gerenciar a sincronização com o Google.'
|
||||
const GOOGLE_CALENDAR_SYNC_FAILED_MESSAGE =
|
||||
'Não foi possível atualizar o estado da sincronização com o Google.'
|
||||
const GOOGLE_CALENDAR_OAUTH_MANAGER_ONLY_MESSAGE =
|
||||
'Apenas candidato ou coordenação pode conectar ou desconectar a conta Google da campanha.'
|
||||
const GOOGLE_CALENDAR_OAUTH_UNAVAILABLE_MESSAGE =
|
||||
'A conexão com o Google ainda não está configurada no servidor. Fale com a equipe técnica.'
|
||||
|
||||
export type GoogleCalendarSyncActionResult = {
|
||||
ok: boolean
|
||||
message?: string
|
||||
/** C149 — the actor may connect/disconnect (candidate or coordinator). */
|
||||
canManageConnection: boolean
|
||||
} & GoogleCalendarSyncView & { addLink: string | null }
|
||||
|
||||
const withLinks = (view: GoogleCalendarSyncView): Omit<GoogleCalendarSyncActionResult, 'ok'> => ({
|
||||
export type GoogleCalendarOAuthStartResult =
|
||||
| { ok: true; authorizeUrl: string }
|
||||
| { ok: false; message: string }
|
||||
|
||||
const withLinks = (
|
||||
view: GoogleCalendarSyncView,
|
||||
): Omit<GoogleCalendarSyncActionResult, 'ok' | 'canManageConnection'> => ({
|
||||
...view,
|
||||
addLink: view.calendarId ? buildGoogleCalendarAddLink(view.calendarId) : null,
|
||||
})
|
||||
|
||||
const failure = (message: string): GoogleCalendarSyncActionResult => ({
|
||||
const failure = (
|
||||
message: string,
|
||||
canManageConnection: boolean,
|
||||
): GoogleCalendarSyncActionResult => ({
|
||||
ok: false,
|
||||
message,
|
||||
canManageConnection,
|
||||
status: 'not-configured',
|
||||
connection: 'not-configured',
|
||||
oauthAvailable: false,
|
||||
oauthConnectedAt: null,
|
||||
oauthError: null,
|
||||
calendarId: null,
|
||||
lastSyncedAt: null,
|
||||
lastSuccessAt: null,
|
||||
|
|
@ -44,16 +78,33 @@ const failure = (message: string): GoogleCalendarSyncActionResult => ({
|
|||
const requireStaff = async (payload: Payload, actor: CampaignUser): Promise<CampaignUser> =>
|
||||
reloadStaffActor(payload, actor, GOOGLE_CALENDAR_SYNC_STAFF_ONLY_MESSAGE)
|
||||
|
||||
/**
|
||||
* C149 — reloads the actor and returns them only when they may manage the
|
||||
* connection (candidate/coordinator). The role check runs on the fresh
|
||||
* document; `null` means "refused", never "not loaded".
|
||||
*/
|
||||
const loadConnectionManager = async (
|
||||
payload: Payload,
|
||||
actor: CampaignUser,
|
||||
): Promise<CampaignUser | null> => {
|
||||
const currentActor = await reloadCampaignActor(payload, actor)
|
||||
return canManageGoogleCalendarConnection(currentActor) ? currentActor : null
|
||||
}
|
||||
|
||||
/** Read model for the agenda pill/dialog — status derived, never stored. */
|
||||
export const getGoogleCalendarSyncState = async (): Promise<GoogleCalendarSyncActionResult> => {
|
||||
const { payload, actor } = await getCampaignActionContext()
|
||||
|
||||
try {
|
||||
await requireStaff(payload, actor)
|
||||
return { ok: true, ...withLinks(await readGoogleCalendarSyncView(payload)) }
|
||||
const currentActor = await requireStaff(payload, actor)
|
||||
return {
|
||||
ok: true,
|
||||
canManageConnection: canManageGoogleCalendarConnection(currentActor),
|
||||
...withLinks(await readGoogleCalendarSyncView(payload)),
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('getGoogleCalendarSyncState failed', error)
|
||||
return failure(GOOGLE_CALENDAR_SYNC_FAILED_MESSAGE)
|
||||
return failure(GOOGLE_CALENDAR_SYNC_FAILED_MESSAGE, canManageGoogleCalendarConnection(actor))
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -62,12 +113,16 @@ export const runGoogleCalendarSyncNow = async (): Promise<GoogleCalendarSyncActi
|
|||
const { payload, actor } = await getCampaignActionContext()
|
||||
|
||||
try {
|
||||
await requireStaff(payload, actor)
|
||||
const currentActor = await requireStaff(payload, actor)
|
||||
await runCampaignCalendarSync(payload, { reason: 'manual' })
|
||||
return { ok: true, ...withLinks(await readGoogleCalendarSyncView(payload)) }
|
||||
return {
|
||||
ok: true,
|
||||
canManageConnection: canManageGoogleCalendarConnection(currentActor),
|
||||
...withLinks(await readGoogleCalendarSyncView(payload)),
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('runGoogleCalendarSyncNow failed', error)
|
||||
return failure(GOOGLE_CALENDAR_SYNC_FAILED_MESSAGE)
|
||||
return failure(GOOGLE_CALENDAR_SYNC_FAILED_MESSAGE, canManageGoogleCalendarConnection(actor))
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -90,9 +145,92 @@ export const setGoogleCalendarSyncDisabled = async (
|
|||
overrideAccess: false,
|
||||
})
|
||||
}
|
||||
return { ok: true, ...withLinks(await readGoogleCalendarSyncView(payload)) }
|
||||
return {
|
||||
ok: true,
|
||||
canManageConnection: canManageGoogleCalendarConnection(currentActor),
|
||||
...withLinks(await readGoogleCalendarSyncView(payload)),
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('setGoogleCalendarSyncDisabled failed', error)
|
||||
return failure(GOOGLE_CALENDAR_SYNC_FAILED_MESSAGE)
|
||||
return failure(GOOGLE_CALENDAR_SYNC_FAILED_MESSAGE, canManageGoogleCalendarConnection(actor))
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* C149 — starts the OAuth handshake: validates the manager role, ensures the
|
||||
* singleton connection row exists, stores the signed single-use `state` cookie
|
||||
* and returns the Google consent URL for the client to navigate to. The
|
||||
* authorize URL is built from the canonical public origin (never the request),
|
||||
* so a callback behind the Cloudflare tunnel cannot leak localhost.
|
||||
*/
|
||||
export const startGoogleCalendarOAuth = async (): Promise<GoogleCalendarOAuthStartResult> => {
|
||||
const { payload, actor } = await getCampaignActionContext()
|
||||
|
||||
try {
|
||||
if (!(await loadConnectionManager(payload, actor))) {
|
||||
return { ok: false, message: GOOGLE_CALENDAR_OAUTH_MANAGER_ONLY_MESSAGE }
|
||||
}
|
||||
|
||||
const credentials = readGoogleCalendarOAuthCredentials()
|
||||
if (!credentials) {
|
||||
return { ok: false, message: GOOGLE_CALENDAR_OAUTH_UNAVAILABLE_MESSAGE }
|
||||
}
|
||||
// Resolved BEFORE any write: a missing canonical origin throws here and
|
||||
// must not leave a stray connection row behind.
|
||||
const redirectUri = buildGoogleCalendarOAuthRedirectUri()
|
||||
|
||||
// The connection may exist before any calendar is chosen (C150 picks it).
|
||||
// Payload's Local API without `user` needs the explicit admin bypass; the
|
||||
// role check above is the authorization for this system write.
|
||||
const doc = await loadGoogleCalendarSyncConfig(payload)
|
||||
if (!doc) {
|
||||
await payload.create({
|
||||
collection: 'googleCalendarSync',
|
||||
data: {},
|
||||
depth: 0,
|
||||
overrideAccess: true,
|
||||
})
|
||||
}
|
||||
|
||||
const state = randomBytes(32).toString('base64url')
|
||||
await storeGoogleCalendarOAuthState(state, actor.id)
|
||||
|
||||
return {
|
||||
ok: true,
|
||||
authorizeUrl: buildGoogleCalendarOAuthAuthorizeUrl({
|
||||
clientId: credentials.clientId,
|
||||
redirectUri,
|
||||
state,
|
||||
}),
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('startGoogleCalendarOAuth failed', error)
|
||||
return { ok: false, message: GOOGLE_CALENDAR_SYNC_FAILED_MESSAGE }
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* C149 — disconnects the OAuth account from the Teqo: drops the refresh token
|
||||
* and the connection state. The UI guides revoking the app access on the
|
||||
* Google account; the service account, when configured, takes over on the next
|
||||
* trigger.
|
||||
*/
|
||||
export const disconnectGoogleCalendarOAuth = async (): Promise<GoogleCalendarSyncActionResult> => {
|
||||
const { payload, actor } = await getCampaignActionContext()
|
||||
|
||||
try {
|
||||
const currentActor = await loadConnectionManager(payload, actor)
|
||||
if (!currentActor) {
|
||||
return failure(GOOGLE_CALENDAR_OAUTH_MANAGER_ONLY_MESSAGE, false)
|
||||
}
|
||||
await clearGoogleCalendarOAuthConnection(payload)
|
||||
return {
|
||||
ok: true,
|
||||
canManageConnection: true,
|
||||
...withLinks(await readGoogleCalendarSyncView(payload)),
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('disconnectGoogleCalendarOAuth failed', error)
|
||||
return failure(GOOGLE_CALENDAR_SYNC_FAILED_MESSAGE, canManageGoogleCalendarConnection(actor))
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,127 @@
|
|||
import config from '@payload-config'
|
||||
import { NextResponse, type NextRequest } from 'next/server'
|
||||
import { getPayload } from 'payload'
|
||||
|
||||
import { canManageGoogleCalendarConnection } from '@/utilities/access/googleCalendarSync'
|
||||
import { getCampaignInviteBaseURL } from '@/utilities/campaignInviteOrigin'
|
||||
import {
|
||||
buildGoogleCalendarOAuthRedirectUri,
|
||||
clearGoogleCalendarOAuthState,
|
||||
exchangeGoogleCalendarOAuthCode,
|
||||
GoogleCalendarOAuthError,
|
||||
readGoogleCalendarOAuthCredentials,
|
||||
readGoogleCalendarOAuthState,
|
||||
} from '@/utilities/googleCalendarOAuth'
|
||||
import {
|
||||
loadGoogleCalendarSyncConfig,
|
||||
recordGoogleCalendarOAuthConnection,
|
||||
recordGoogleCalendarOAuthError,
|
||||
runCampaignCalendarSync,
|
||||
} from '@/utilities/googleCalendarSync'
|
||||
|
||||
/**
|
||||
* C149 — the OAuth callback registered in the GCP client. Google returns the
|
||||
* browser here after consent with `code + state` (or `error` when the user
|
||||
* denies). It lives OUTSIDE `(app)` on purpose: it is a ceremony route like
|
||||
* the WebAuthn ones, authenticated by the signed single-use state cookie and
|
||||
* not by a page session.
|
||||
*
|
||||
* The exchange is the only step that matters — the refresh token lands on the
|
||||
* singleton `googleCalendarSync` row and the derived connection state becomes
|
||||
* `connected`; a failure records `oauthError` so the UI offers reconnect. The
|
||||
* browser always ends on `/campanha/agenda` (no query param): the agenda page
|
||||
* canonicalizes unknown params away, so the state itself is the feedback.
|
||||
*/
|
||||
|
||||
export const dynamic = 'force-dynamic'
|
||||
|
||||
/** The code exchange plus (when a calendar is configured) a full sync pass. */
|
||||
export const maxDuration = 60
|
||||
|
||||
const AGENDA_PATH = '/campanha/agenda'
|
||||
|
||||
const agendaRedirect = (request: NextRequest): NextResponse => {
|
||||
try {
|
||||
return NextResponse.redirect(new URL(AGENDA_PATH, getCampaignInviteBaseURL()))
|
||||
} catch {
|
||||
// Only reachable when NEXT_PUBLIC_SITE_URL is missing in production —
|
||||
// still send the user back to the page instead of a dead end.
|
||||
return NextResponse.redirect(new URL(AGENDA_PATH, request.nextUrl.origin))
|
||||
}
|
||||
}
|
||||
|
||||
export const GET = async (request: NextRequest): Promise<NextResponse> => {
|
||||
const payload = await getPayload({ config })
|
||||
const searchParams = request.nextUrl.searchParams
|
||||
const providedState = searchParams.get('state') ?? ''
|
||||
const errorParam = searchParams.get('error')
|
||||
const code = searchParams.get('code')
|
||||
|
||||
try {
|
||||
// `access_denied` is the user saying no — not an error to surface, and
|
||||
// there is no code to exchange.
|
||||
if (errorParam || !code) {
|
||||
return agendaRedirect(request)
|
||||
}
|
||||
|
||||
const state = await readGoogleCalendarOAuthState(providedState)
|
||||
// Burn before the exchange: a captured callback cannot be replayed even
|
||||
// if the exchange itself fails.
|
||||
await clearGoogleCalendarOAuthState()
|
||||
|
||||
// Re-check the role against the FRESH document: the consent navigation
|
||||
// may have outlived a downgrade. The signed state binds the actor; the
|
||||
// role check is what authorizes the system write below. Intentional admin
|
||||
// bypass on the reload (the fresh-role read is the trusted check).
|
||||
const actor = await payload.findByID({
|
||||
collection: 'campaignUser',
|
||||
id: state.userId,
|
||||
depth: 0,
|
||||
overrideAccess: true,
|
||||
})
|
||||
if (!canManageGoogleCalendarConnection(actor)) {
|
||||
throw new GoogleCalendarOAuthError(
|
||||
'Apenas candidato ou coordenação pode conectar a conta Google da campanha.',
|
||||
)
|
||||
}
|
||||
|
||||
const credentials = readGoogleCalendarOAuthCredentials()
|
||||
if (!credentials) {
|
||||
throw new GoogleCalendarOAuthError('A conexão com o Google não está configurada no servidor.')
|
||||
}
|
||||
|
||||
// Only an EXCHANGE failure flips the connection state to `error` — a
|
||||
// refused role or a missing client env leaves the stored connection
|
||||
// untouched (the engine clears a transient exchange error on the next
|
||||
// successful pass with the previous token).
|
||||
const tokens = await exchangeGoogleCalendarOAuthCode({
|
||||
code,
|
||||
redirectUri: buildGoogleCalendarOAuthRedirectUri(),
|
||||
credentials,
|
||||
}).catch(async (error: unknown) => {
|
||||
if (error instanceof GoogleCalendarOAuthError) {
|
||||
await recordGoogleCalendarOAuthError(payload, error.message).catch(() => {})
|
||||
}
|
||||
throw error
|
||||
})
|
||||
|
||||
await recordGoogleCalendarOAuthConnection(payload, {
|
||||
refreshToken: tokens.refreshToken,
|
||||
scope: tokens.scope,
|
||||
})
|
||||
|
||||
const doc = await loadGoogleCalendarSyncConfig(payload)
|
||||
if (doc?.calendarId) {
|
||||
await runCampaignCalendarSync(payload, { reason: 'config-change' })
|
||||
}
|
||||
|
||||
return agendaRedirect(request)
|
||||
} catch (error) {
|
||||
const message =
|
||||
error instanceof Error ? error.message : 'Erro desconhecido ao conectar com o Google.'
|
||||
payload.logger.error(`[GoogleCalendarSync] Callback OAuth falhou: ${message}`)
|
||||
return agendaRedirect(request)
|
||||
} finally {
|
||||
await clearGoogleCalendarOAuthState().catch(() => {})
|
||||
}
|
||||
}
|
||||
|
|
@ -18,6 +18,12 @@ import { googleCalendarSyncConfigHook } from '@/utilities/googleCalendarSyncHook
|
|||
* account private key never lives here — it is `GOOGLE_CALENDAR_SERVICE_ACCOUNT_KEY`
|
||||
* in the environment. Changing `calendarId` (or re-enabling) re-runs the full
|
||||
* reconciliation automatically via `googleCalendarSyncConfigHook` (D7).
|
||||
*
|
||||
* C149 — the same row also holds the OAuth connection (refresh token + state)
|
||||
* created by the "Conectar com o Google" button. The refresh token is
|
||||
* system-only and unreadable by every user path (`read: false`), exactly like
|
||||
* the push-channel secret; the engine reads it with the admin bypass. The
|
||||
* service account remains the fallback while no OAuth connection exists.
|
||||
*/
|
||||
export const GoogleCalendarSync: CollectionConfig = {
|
||||
slug: 'googleCalendarSync',
|
||||
|
|
@ -60,6 +66,64 @@ export const GoogleCalendarSync: CollectionConfig = {
|
|||
'O calendarId do calendário "Agenda da Campanha" na conta Google da campanha (ex.: c_…@group.calendar.google.com). O calendário precisa estar público no link e a service account com permissão de edição.',
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'oauthRefreshToken',
|
||||
type: 'text',
|
||||
label: 'Token de atualização (OAuth)',
|
||||
access: {
|
||||
create: canSetGoogleCalendarSyncSystemField,
|
||||
read: canReadGoogleCalendarSyncIdentityField,
|
||||
update: canSetGoogleCalendarSyncSystemField,
|
||||
},
|
||||
admin: {
|
||||
readOnly: true,
|
||||
description:
|
||||
'Credencial da conexão OAuth com a conta Google da campanha. Preenchida pelo callback do botão "Conectar com o Google"; nunca é exibida.',
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'oauthScope',
|
||||
type: 'text',
|
||||
label: 'Escopos concedidos (OAuth)',
|
||||
access: {
|
||||
create: canSetGoogleCalendarSyncSystemField,
|
||||
update: canSetGoogleCalendarSyncSystemField,
|
||||
},
|
||||
admin: {
|
||||
readOnly: true,
|
||||
description: 'Escopos que o Google devolveu na conexão (diagnóstico).',
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'oauthConnectedAt',
|
||||
type: 'date',
|
||||
label: 'Conta Google conectada em',
|
||||
access: {
|
||||
create: canSetGoogleCalendarSyncSystemField,
|
||||
update: canSetGoogleCalendarSyncSystemField,
|
||||
},
|
||||
admin: { readOnly: true },
|
||||
},
|
||||
{
|
||||
name: 'oauthErrorAt',
|
||||
type: 'date',
|
||||
label: 'Último erro de conexão (OAuth)',
|
||||
access: {
|
||||
create: canSetGoogleCalendarSyncSystemField,
|
||||
update: canSetGoogleCalendarSyncSystemField,
|
||||
},
|
||||
admin: { readOnly: true },
|
||||
},
|
||||
{
|
||||
name: 'oauthError',
|
||||
type: 'textarea',
|
||||
label: 'Mensagem do último erro de conexão (OAuth)',
|
||||
access: {
|
||||
create: canSetGoogleCalendarSyncSystemField,
|
||||
update: canSetGoogleCalendarSyncSystemField,
|
||||
},
|
||||
admin: { readOnly: true },
|
||||
},
|
||||
{
|
||||
name: 'disabledAt',
|
||||
type: 'date',
|
||||
|
|
|
|||
|
|
@ -2,7 +2,10 @@
|
|||
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||
|
||||
import type { GoogleCalendarSyncActionResult } from '@/app/(campaign)/campanha/actions/googleCalendarSync'
|
||||
import type {
|
||||
GoogleCalendarOAuthStartResult,
|
||||
GoogleCalendarSyncActionResult,
|
||||
} from '@/app/(campaign)/campanha/actions/googleCalendarSync'
|
||||
import { GoogleCalendarSyncDialog } from '@/components/campaign/activity/GoogleCalendarSyncDialog'
|
||||
import { SetCampaignHeaderAction } from '@/components/campaign/shell/CampaignPageChromeContext'
|
||||
import { useBridgedQuickAction } from '@/components/campaign/shell/CampaignQuickActionContext'
|
||||
|
|
@ -12,6 +15,10 @@ type AgendaGoogleSyncChromeProps = {
|
|||
initialState: GoogleCalendarSyncActionResult
|
||||
onSyncNow: () => Promise<GoogleCalendarSyncActionResult>
|
||||
onSetDisabled: (disabled: boolean) => Promise<GoogleCalendarSyncActionResult>
|
||||
/** C149 — starts the OAuth handshake; the dialog navigates to the consent URL. */
|
||||
onStartOAuth: () => Promise<GoogleCalendarOAuthStartResult>
|
||||
/** C149 — drops the OAuth connection from the Teqo. */
|
||||
onDisconnect: () => Promise<GoogleCalendarSyncActionResult>
|
||||
}
|
||||
|
||||
const PILL_COPY: Record<
|
||||
|
|
@ -50,6 +57,8 @@ export const AgendaGoogleSyncChrome = ({
|
|||
initialState,
|
||||
onSyncNow,
|
||||
onSetDisabled,
|
||||
onStartOAuth,
|
||||
onDisconnect,
|
||||
}: AgendaGoogleSyncChromeProps) => {
|
||||
const [open, setOpen] = useState(false)
|
||||
const [state, setState] = useState(initialState)
|
||||
|
|
@ -83,6 +92,13 @@ export const AgendaGoogleSyncChrome = ({
|
|||
[onSetDisabled],
|
||||
)
|
||||
|
||||
// C149 — the connection card follows the disconnect without a page reload.
|
||||
const handleDisconnect = useCallback(async () => {
|
||||
const result = await onDisconnect()
|
||||
if (result.ok) setState(result)
|
||||
return result
|
||||
}, [onDisconnect])
|
||||
|
||||
const pill = useMemo(() => {
|
||||
const copy = PILL_COPY[state.status]
|
||||
return (
|
||||
|
|
@ -109,6 +125,8 @@ export const AgendaGoogleSyncChrome = ({
|
|||
state={state}
|
||||
onSyncNow={handleSyncNow}
|
||||
onSetDisabled={handleSetDisabled}
|
||||
onStartOAuth={onStartOAuth}
|
||||
onDisconnect={handleDisconnect}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
|
|
|
|||
|
|
@ -4,7 +4,10 @@ import { useState } from 'react'
|
|||
|
||||
import { CopyIcon, ExternalLinkIcon, RefreshCwIcon } from 'lucide-react'
|
||||
|
||||
import type { GoogleCalendarSyncActionResult } from '@/app/(campaign)/campanha/actions/googleCalendarSync'
|
||||
import type {
|
||||
GoogleCalendarOAuthStartResult,
|
||||
GoogleCalendarSyncActionResult,
|
||||
} from '@/app/(campaign)/campanha/actions/googleCalendarSync'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import {
|
||||
Dialog,
|
||||
|
|
@ -25,6 +28,7 @@ import {
|
|||
import { Input } from '@/components/ui/input'
|
||||
import { useIsMobile } from '@/hooks/use-mobile'
|
||||
import { formatBahiaDateTimeLabel } from '@/lib/campaignTime'
|
||||
import type { GoogleCalendarConnectionStatus } from '@/utilities/googleCalendarSync'
|
||||
|
||||
type GoogleCalendarSyncDialogProps = {
|
||||
open: boolean
|
||||
|
|
@ -32,6 +36,10 @@ type GoogleCalendarSyncDialogProps = {
|
|||
state: GoogleCalendarSyncActionResult
|
||||
onSyncNow: () => Promise<GoogleCalendarSyncActionResult>
|
||||
onSetDisabled: (disabled: boolean) => Promise<GoogleCalendarSyncActionResult>
|
||||
/** C149 — starts the OAuth handshake; the caller navigates to the returned URL. */
|
||||
onStartOAuth: () => Promise<GoogleCalendarOAuthStartResult>
|
||||
/** C149 — drops the OAuth connection from the Teqo. */
|
||||
onDisconnect: () => Promise<GoogleCalendarSyncActionResult>
|
||||
}
|
||||
|
||||
const NOT_CONFIGURED_RUNBOOK: readonly string[] = [
|
||||
|
|
@ -41,11 +49,32 @@ const NOT_CONFIGURED_RUNBOOK: readonly string[] = [
|
|||
'Um administrador configura o ID do calendário no Painel e a chave da service account nas variáveis de ambiente.',
|
||||
]
|
||||
|
||||
const CONNECTION_BADGE: Record<
|
||||
GoogleCalendarConnectionStatus,
|
||||
{ label: string; className: string }
|
||||
> = {
|
||||
connected: {
|
||||
label: 'Conectado',
|
||||
className: 'border-green-300 bg-green-50 text-green-700',
|
||||
},
|
||||
error: {
|
||||
label: 'Erro',
|
||||
className: 'border-amber-300 bg-amber-50 text-amber-700',
|
||||
},
|
||||
'not-configured': {
|
||||
label: 'Não configurado',
|
||||
className: 'border-border text-muted-foreground',
|
||||
},
|
||||
}
|
||||
|
||||
const copyIcon = <CopyIcon className="h-4 w-4" />
|
||||
|
||||
/**
|
||||
* C114 — Google Calendar mirror dialog (estados da jornada do canvas:
|
||||
* não configurado / sincronizado / pausado / desativado). Bottom sheet on
|
||||
* C114/C115/C149 — Google Calendar mirror dialog. The OAuth connection card
|
||||
* (C149) leads every state: `não configurado` offers the one-click connect
|
||||
* (or the admin runbook when the server has no OAuth client), `conectado`
|
||||
* offers reconnect/disconnect and `erro` offers the reconnect path. The
|
||||
* mirror-state blocks (C114/C115) stay below, untouched. Bottom sheet on
|
||||
* mobile, dialog on desktop — same chrome as the iCal feed dialog (C94).
|
||||
*/
|
||||
export const GoogleCalendarSyncDialog = ({
|
||||
|
|
@ -54,6 +83,8 @@ export const GoogleCalendarSyncDialog = ({
|
|||
state,
|
||||
onSyncNow,
|
||||
onSetDisabled,
|
||||
onStartOAuth,
|
||||
onDisconnect,
|
||||
}: GoogleCalendarSyncDialogProps) => {
|
||||
const isMobile = useIsMobile()
|
||||
const [isBusy, setIsBusy] = useState(false)
|
||||
|
|
@ -72,30 +103,38 @@ export const GoogleCalendarSyncDialog = ({
|
|||
setTimeout(() => setCopied(false), 2000)
|
||||
}
|
||||
|
||||
const handleSyncNow = async () => {
|
||||
if (isBusy) return
|
||||
/**
|
||||
* Shared busy/error envelope: every card action maps a failed result to the
|
||||
* same `actionError` line and never leaves the buttons stuck in busy.
|
||||
*/
|
||||
const runAction = async <T extends { ok: boolean; message?: string }>(
|
||||
run: () => Promise<T>,
|
||||
fallbackMessage: string,
|
||||
): Promise<T | null> => {
|
||||
if (isBusy) return null
|
||||
setIsBusy(true)
|
||||
setActionError(null)
|
||||
try {
|
||||
const result = await onSyncNow()
|
||||
if (!result.ok) setActionError(result.message ?? 'Não foi possível sincronizar.')
|
||||
const result = await run()
|
||||
if (!result.ok) setActionError(result.message ?? fallbackMessage)
|
||||
return result
|
||||
} finally {
|
||||
setIsBusy(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleSetDisabled = async (disabled: boolean) => {
|
||||
if (isBusy) return
|
||||
setIsBusy(true)
|
||||
setActionError(null)
|
||||
try {
|
||||
const result = await onSetDisabled(disabled)
|
||||
if (!result.ok) setActionError(result.message ?? 'Não foi possível atualizar.')
|
||||
} finally {
|
||||
setIsBusy(false)
|
||||
}
|
||||
const handleSyncNow = () => runAction(onSyncNow, 'Não foi possível sincronizar.')
|
||||
|
||||
const handleSetDisabled = (disabled: boolean) =>
|
||||
runAction(() => onSetDisabled(disabled), 'Não foi possível atualizar.')
|
||||
|
||||
const handleStartOAuth = async () => {
|
||||
const result = await runAction(onStartOAuth, 'Não foi possível iniciar a conexão.')
|
||||
if (result?.ok) window.location.assign(result.authorizeUrl)
|
||||
}
|
||||
|
||||
const handleDisconnect = () => runAction(onDisconnect, 'Não foi possível desconectar.')
|
||||
|
||||
const linkBlock = state.addLink ? (
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium" htmlFor="google-calendar-link">
|
||||
|
|
@ -183,16 +222,51 @@ export const GoogleCalendarSyncDialog = ({
|
|||
</Button>
|
||||
)
|
||||
|
||||
let content: React.ReactNode
|
||||
let actions: React.ReactNode = null
|
||||
const connectionActions = state.canManageConnection ? (
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
{state.connection === 'connected' ? (
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={() => void handleStartOAuth()}
|
||||
disabled={isBusy}
|
||||
>
|
||||
Reconectar com o Google
|
||||
</Button>
|
||||
) : (
|
||||
<Button
|
||||
type="button"
|
||||
onClick={() => void handleStartOAuth()}
|
||||
disabled={isBusy || !state.oauthAvailable}
|
||||
>
|
||||
<ExternalLinkIcon className="mr-2 h-4 w-4" />
|
||||
{state.connection === 'error' ? 'Reconectar com o Google' : 'Conectar com o Google'}
|
||||
</Button>
|
||||
)}
|
||||
{state.connection !== 'not-configured' ? (
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={() => void handleDisconnect()}
|
||||
disabled={isBusy}
|
||||
>
|
||||
Desconectar
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Somente candidato ou coordenação pode conectar ou desconectar a conta Google da campanha.
|
||||
</p>
|
||||
)
|
||||
|
||||
if (state.status === 'not-configured') {
|
||||
content = (
|
||||
const oauthUnavailableBlock =
|
||||
state.connection === 'not-configured' && !state.oauthAvailable ? (
|
||||
<div className="rounded-lg border border-amber-300 bg-amber-50 p-4 text-sm">
|
||||
<p className="mb-2 font-medium text-amber-900">Ainda não configurado</p>
|
||||
<p className="text-amber-800">
|
||||
O Teqo segue 100% funcional — a agenda do Teqo nunca depende do Google. Para ativar o
|
||||
espelho, um administrador precisa concluir a configuração:
|
||||
O Teqo segue 100% funcional — a agenda do Teqo nunca depende do Google. Um administrador
|
||||
pode concluir a configuração manual:
|
||||
</p>
|
||||
<ol className="mt-2 list-inside list-decimal space-y-1 text-amber-800">
|
||||
{NOT_CONFIGURED_RUNBOOK.map((step) => (
|
||||
|
|
@ -200,7 +274,99 @@ export const GoogleCalendarSyncDialog = ({
|
|||
))}
|
||||
</ol>
|
||||
</div>
|
||||
)
|
||||
) : null
|
||||
|
||||
const connectionBlock = (
|
||||
<div className="rounded-lg border border-border p-4">
|
||||
<div className="flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between">
|
||||
<div>
|
||||
<h3 className="text-sm font-semibold">Google Calendar</h3>
|
||||
{state.connection === 'not-configured' ? (
|
||||
<p className="mt-1 text-sm text-muted-foreground">
|
||||
Conecte a conta Google do calendário da campanha para gerenciar o espelho por ela.
|
||||
{state.status !== 'not-configured'
|
||||
? ' O espelho segue ativo pela configuração manual (service account).'
|
||||
: ''}
|
||||
</p>
|
||||
) : state.connection === 'connected' ? (
|
||||
<p className="mt-1 text-sm text-muted-foreground">
|
||||
O espelho usa a conta Google conectada para escrever os compromissos no calendário da
|
||||
campanha.
|
||||
</p>
|
||||
) : (
|
||||
<p className="mt-1 text-sm text-muted-foreground">
|
||||
A conexão com o Google precisa ser refeita.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<span
|
||||
className={`self-start rounded-full border px-3 py-1 text-xs font-medium ${CONNECTION_BADGE[state.connection].className}`}
|
||||
>
|
||||
{CONNECTION_BADGE[state.connection].label}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{state.connection === 'not-configured' ? (
|
||||
<div className="mt-4 space-y-3">
|
||||
{oauthUnavailableBlock ?? (
|
||||
<div className="rounded-md border border-dashed border-border p-4">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Você será levado à tela de consentimento do Google para autorizar o Teqo a gerenciar
|
||||
o calendário da campanha.
|
||||
</p>
|
||||
<div className="mt-3">{connectionActions}</div>
|
||||
</div>
|
||||
)}
|
||||
<p className="text-xs text-muted-foreground">
|
||||
A configuração manual por service account continua disponível como fallback técnico.
|
||||
</p>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{state.connection === 'connected' ? (
|
||||
<div className="mt-4 space-y-3">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{state.oauthConnectedAt
|
||||
? `Conta Google conectada em ${formatBahiaDateTimeLabel(state.oauthConnectedAt)}.`
|
||||
: 'Conta Google conectada.'}
|
||||
</p>
|
||||
{connectionActions}
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Desconectar revoga o acesso do Teqo; depois, revogue o app nas configurações de
|
||||
segurança da conta Google.
|
||||
</p>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{state.connection === 'error' ? (
|
||||
<div className="mt-4 space-y-3">
|
||||
<div className="rounded-md border border-amber-300 bg-amber-50 p-4 text-sm">
|
||||
<p className="font-medium text-amber-900">Erro de conexão com o Google</p>
|
||||
<p className="mt-1 text-amber-800">
|
||||
{state.oauthError ??
|
||||
'Não foi possível renovar o acesso. O Teqo continua sendo a fonte da verdade — nada foi perdido.'}
|
||||
</p>
|
||||
<p className="mt-2 text-xs text-amber-700">
|
||||
Reconecte com a mesma conta Google do calendário da campanha. Se a conta mudou,
|
||||
desconecte antes e conecte novamente.
|
||||
</p>
|
||||
</div>
|
||||
{connectionActions}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
)
|
||||
|
||||
let content: React.ReactNode
|
||||
let actions: React.ReactNode = null
|
||||
|
||||
if (state.status === 'not-configured') {
|
||||
content =
|
||||
state.connection === 'connected' ? (
|
||||
<p className="rounded-lg bg-muted p-3 text-sm text-muted-foreground">
|
||||
A conexão está ativa. O calendário da campanha é escolhido na configuração do Painel.
|
||||
</p>
|
||||
) : null
|
||||
} else if (state.status === 'disabled') {
|
||||
content = (
|
||||
<div className="rounded-lg bg-muted p-4 text-sm text-muted-foreground">
|
||||
|
|
@ -280,6 +446,14 @@ export const GoogleCalendarSyncDialog = ({
|
|||
</p>
|
||||
) : null
|
||||
|
||||
// C149 — the connection card leads every state; the mirror blocks stay below.
|
||||
const body = (
|
||||
<>
|
||||
{connectionBlock}
|
||||
{content}
|
||||
</>
|
||||
)
|
||||
|
||||
const description =
|
||||
'Compromissos do Teqo refletem no calendário Google compartilhado em minutos, e edições feitas nele voltam para o Teqo. Quem segue recebe aviso conforme as próprias configurações.'
|
||||
|
||||
|
|
@ -298,7 +472,7 @@ export const GoogleCalendarSyncDialog = ({
|
|||
</DrawerHeader>
|
||||
<div className="overflow-y-auto px-4 pb-2">
|
||||
<div className="space-y-4">
|
||||
{content}
|
||||
{body}
|
||||
{errorNotice}
|
||||
{actions}
|
||||
</div>
|
||||
|
|
@ -322,7 +496,7 @@ export const GoogleCalendarSyncDialog = ({
|
|||
data-slot="dialog-scroll-body"
|
||||
className="min-h-0 flex-1 overflow-y-auto overscroll-contain px-6 py-4"
|
||||
>
|
||||
<div className="space-y-4">{content}</div>
|
||||
<div className="space-y-4">{body}</div>
|
||||
</div>
|
||||
{errorNotice || actions ? (
|
||||
<div data-slot="dialog-footer" className="shrink-0 space-y-3 border-t px-6 py-4">
|
||||
|
|
|
|||
48
src/lib/googleCalendarOAuth.ts
Normal file
48
src/lib/googleCalendarOAuth.ts
Normal file
|
|
@ -0,0 +1,48 @@
|
|||
/**
|
||||
* C149 — pure pieces of the campaign's Google OAuth handshake. The authorize
|
||||
* URL is request-independent (client id + redirect uri + state are passed in),
|
||||
* so it lives in `lib/` and is unit-tested without a server. Nothing here is
|
||||
* secret: the client id is public in the consent URL and the state is
|
||||
* generated per attempt by the caller.
|
||||
*/
|
||||
|
||||
const GOOGLE_CALENDAR_OAUTH_AUTHORIZE_ENDPOINT = 'https://accounts.google.com/o/oauth2/v2/auth'
|
||||
|
||||
/** Where Google returns the browser after consent (also registered in the GCP client). */
|
||||
export const GOOGLE_CALENDAR_OAUTH_CALLBACK_PATH = '/campanha/agenda/google-oauth/callback'
|
||||
|
||||
/**
|
||||
* The minimum scopes (C149 literal): `calendar.events` is what the mirror
|
||||
* needs to write, `calendar.calendarlist.readonly` is what C150's picker
|
||||
* needs to list the account's calendars. Nothing beyond.
|
||||
*/
|
||||
export const GOOGLE_CALENDAR_OAUTH_SCOPES = [
|
||||
'https://www.googleapis.com/auth/calendar.events',
|
||||
'https://www.googleapis.com/auth/calendar.calendarlist.readonly',
|
||||
] as const
|
||||
|
||||
type GoogleCalendarOAuthAuthorizeUrlInput = {
|
||||
clientId: string
|
||||
redirectUri: string
|
||||
state: string
|
||||
}
|
||||
|
||||
export const buildGoogleCalendarOAuthAuthorizeUrl = ({
|
||||
clientId,
|
||||
redirectUri,
|
||||
state,
|
||||
}: GoogleCalendarOAuthAuthorizeUrlInput): string => {
|
||||
const params = new URLSearchParams({
|
||||
client_id: clientId,
|
||||
redirect_uri: redirectUri,
|
||||
response_type: 'code',
|
||||
scope: GOOGLE_CALENDAR_OAUTH_SCOPES.join(' '),
|
||||
// `offline` + `consent` are what make Google return a refresh token that
|
||||
// survives without weekly re-consent (the hard product constraint); the
|
||||
// explicit `prompt` also guarantees a fresh token on reconnect.
|
||||
access_type: 'offline',
|
||||
prompt: 'consent',
|
||||
state,
|
||||
})
|
||||
return `${GOOGLE_CALENDAR_OAUTH_AUTHORIZE_ENDPOINT}?${params.toString()}`
|
||||
}
|
||||
14105
src/migrations/20260912_052226_add_google_calendar_oauth_connection.json
Normal file
14105
src/migrations/20260912_052226_add_google_calendar_oauth_connection.json
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -0,0 +1,27 @@
|
|||
import { MigrateUpArgs, MigrateDownArgs, sql } from '@payloadcms/db-postgres'
|
||||
|
||||
/**
|
||||
* C149 — `googleCalendarSync` gains the OAuth connection fields (C149). The
|
||||
* generated migration also re-emitted the already-applied
|
||||
* `supporter_import_batch.actor_id DROP NOT NULL` statement
|
||||
* (applied by `20260824_010000_make_supporter_import_batch_actor_nullable`);
|
||||
* it was removed by hand so the apply does not fail on a duplicate change —
|
||||
* same drift handled in the C114 migration.
|
||||
*/
|
||||
export async function up({ db, payload, req }: MigrateUpArgs): Promise<void> {
|
||||
await db.execute(sql`
|
||||
ALTER TABLE "google_calendar_sync" ADD COLUMN "oauth_refresh_token" varchar;
|
||||
ALTER TABLE "google_calendar_sync" ADD COLUMN "oauth_scope" varchar;
|
||||
ALTER TABLE "google_calendar_sync" ADD COLUMN "oauth_connected_at" timestamp(3) with time zone;
|
||||
ALTER TABLE "google_calendar_sync" ADD COLUMN "oauth_error_at" timestamp(3) with time zone;
|
||||
ALTER TABLE "google_calendar_sync" ADD COLUMN "oauth_error" varchar;`)
|
||||
}
|
||||
|
||||
export async function down({ db, payload, req }: MigrateDownArgs): Promise<void> {
|
||||
await db.execute(sql`
|
||||
ALTER TABLE "google_calendar_sync" DROP COLUMN "oauth_refresh_token";
|
||||
ALTER TABLE "google_calendar_sync" DROP COLUMN "oauth_scope";
|
||||
ALTER TABLE "google_calendar_sync" DROP COLUMN "oauth_connected_at";
|
||||
ALTER TABLE "google_calendar_sync" DROP COLUMN "oauth_error_at";
|
||||
ALTER TABLE "google_calendar_sync" DROP COLUMN "oauth_error";`)
|
||||
}
|
||||
|
|
@ -64,6 +64,7 @@ import * as migration_20260820_003426_add_instagram_sync_status from './20260820
|
|||
import * as migration_20260823_161905_add_municipality_update_deliberation from './20260823_161905_add_municipality_update_deliberation';
|
||||
import * as migration_20260824_000000_reconcile_allocation_decision_rationale_nullable from './20260824_000000_reconcile_allocation_decision_rationale_nullable';
|
||||
import * as migration_20260824_010000_make_supporter_import_batch_actor_nullable from './20260824_010000_make_supporter_import_batch_actor_nullable';
|
||||
import * as migration_20260912_052226_add_google_calendar_oauth_connection from './20260912_052226_add_google_calendar_oauth_connection';
|
||||
|
||||
export const migrations = [
|
||||
{
|
||||
|
|
@ -384,16 +385,21 @@ export const migrations = [
|
|||
{
|
||||
up: migration_20260823_161905_add_municipality_update_deliberation.up,
|
||||
down: migration_20260823_161905_add_municipality_update_deliberation.down,
|
||||
name: '20260823_161905_add_municipality_update_deliberation'
|
||||
name: '20260823_161905_add_municipality_update_deliberation',
|
||||
},
|
||||
{
|
||||
up: migration_20260824_000000_reconcile_allocation_decision_rationale_nullable.up,
|
||||
down: migration_20260824_000000_reconcile_allocation_decision_rationale_nullable.down,
|
||||
name: '20260824_000000_reconcile_allocation_decision_rationale_nullable'
|
||||
name: '20260824_000000_reconcile_allocation_decision_rationale_nullable',
|
||||
},
|
||||
{
|
||||
up: migration_20260824_010000_make_supporter_import_batch_actor_nullable.up,
|
||||
down: migration_20260824_010000_make_supporter_import_batch_actor_nullable.down,
|
||||
name: '20260824_010000_make_supporter_import_batch_actor_nullable'
|
||||
name: '20260824_010000_make_supporter_import_batch_actor_nullable',
|
||||
},
|
||||
{
|
||||
up: migration_20260912_052226_add_google_calendar_oauth_connection.up,
|
||||
down: migration_20260912_052226_add_google_calendar_oauth_connection.down,
|
||||
name: '20260912_052226_add_google_calendar_oauth_connection'
|
||||
},
|
||||
];
|
||||
|
|
|
|||
|
|
@ -940,6 +940,17 @@ export interface GoogleCalendarSync {
|
|||
* O calendarId do calendário "Agenda da Campanha" na conta Google da campanha (ex.: c_…@group.calendar.google.com). O calendário precisa estar público no link e a service account com permissão de edição.
|
||||
*/
|
||||
calendarId?: string | null;
|
||||
/**
|
||||
* Credencial da conexão OAuth com a conta Google da campanha. Preenchida pelo callback do botão "Conectar com o Google"; nunca é exibida.
|
||||
*/
|
||||
oauthRefreshToken?: string | null;
|
||||
/**
|
||||
* Escopos que o Google devolveu na conexão (diagnóstico).
|
||||
*/
|
||||
oauthScope?: string | null;
|
||||
oauthConnectedAt?: string | null;
|
||||
oauthErrorAt?: string | null;
|
||||
oauthError?: string | null;
|
||||
/**
|
||||
* Enquanto preenchido, o Teqo não escreve no Google.
|
||||
*/
|
||||
|
|
@ -1959,6 +1970,11 @@ export interface CalendarFeedSelect<T extends boolean = true> {
|
|||
*/
|
||||
export interface GoogleCalendarSyncSelect<T extends boolean = true> {
|
||||
calendarId?: T;
|
||||
oauthRefreshToken?: T;
|
||||
oauthScope?: T;
|
||||
oauthConnectedAt?: T;
|
||||
oauthErrorAt?: T;
|
||||
oauthError?: T;
|
||||
disabledAt?: T;
|
||||
lastSyncedAt?: T;
|
||||
lastSuccessAt?: T;
|
||||
|
|
|
|||
|
|
@ -3,8 +3,10 @@ import type { Access, FieldAccess } from 'payload'
|
|||
import {
|
||||
getFreshCampaignUser,
|
||||
isCampaignStaff,
|
||||
isCampaignUnrestricted,
|
||||
isPayloadAdmin,
|
||||
payloadAdminOnly,
|
||||
type CampaignActor,
|
||||
} from '@/utilities/access/shared'
|
||||
|
||||
/**
|
||||
|
|
@ -53,3 +55,13 @@ export const canSetGoogleCalendarSyncSystemField: FieldAccess = () => false
|
|||
* hand out full push-forging authority to any staff member.
|
||||
*/
|
||||
export const canReadGoogleCalendarSyncIdentityField: FieldAccess = () => false
|
||||
|
||||
/**
|
||||
* C149 — connecting/disconnecting the campaign's Google account is a core
|
||||
* decision over a credential that belongs to the whole campaign: candidate or
|
||||
* coordinator only (advisor reads the state but never holds the token; leader
|
||||
* never reaches the agenda). Enforced at the action layer with the freshly
|
||||
* reloaded actor — same pattern as the other campaign actions.
|
||||
*/
|
||||
export const canManageGoogleCalendarConnection = (user: CampaignActor): boolean =>
|
||||
isCampaignUnrestricted(user)
|
||||
|
|
|
|||
|
|
@ -5,8 +5,10 @@ import { importPKCS8, SignJWT } from 'jose'
|
|||
import type { GoogleRemoteEvent } from '@/lib/googleCalendarEventMapping'
|
||||
|
||||
/**
|
||||
* C114 — thin Calendar API v3 client authenticated as the campaign's service
|
||||
* account (JWT assertion → OAuth2 token → REST). Only the four event endpoints
|
||||
* C114 — thin Calendar API v3 client authenticated either as the campaign's
|
||||
* service account (JWT assertion → OAuth2 token → REST) or — C149 — with the
|
||||
* OAuth refresh token granted by the "Conectar com o Google" consent
|
||||
* (refresh_token grant → access token → REST). Only the four event endpoints
|
||||
* the reconciliation engine needs; the token is cached per runtime instance
|
||||
* and re-minted on 401. The `fetch` implementation is injectable so tests can
|
||||
* stub the transport without network.
|
||||
|
|
@ -29,11 +31,27 @@ export const REQUEST_TIMEOUT_MS = 15_000
|
|||
|
||||
export type { GoogleRemoteEvent } from '@/lib/googleCalendarEventMapping'
|
||||
|
||||
export type GoogleCalendarCredentials = {
|
||||
export type GoogleCalendarServiceAccountCredentials = {
|
||||
clientEmail: string
|
||||
privateKey: string
|
||||
}
|
||||
|
||||
/** C149 — the connection created by the OAuth consent (refresh token + app pair). */
|
||||
type GoogleCalendarOAuthCredentials = {
|
||||
clientId: string
|
||||
clientSecret: string
|
||||
refreshToken: string
|
||||
}
|
||||
|
||||
/**
|
||||
* How the client authenticates. The engine resolves OAuth first (when the
|
||||
* campaign connected an account) and falls back to the service account —
|
||||
* `googleCalendarSync.readGoogleCalendarAuth`.
|
||||
*/
|
||||
export type GoogleCalendarAuth =
|
||||
| { kind: 'service-account'; credentials: GoogleCalendarServiceAccountCredentials }
|
||||
| { kind: 'oauth'; credentials: GoogleCalendarOAuthCredentials }
|
||||
|
||||
/** Typed transport failure — safe message, never echoes credentials or bodies. */
|
||||
export class GoogleCalendarApiError extends Error {
|
||||
readonly status: number
|
||||
|
|
@ -45,6 +63,19 @@ export class GoogleCalendarApiError extends Error {
|
|||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* C149 — the OAuth connection is dead (`invalid_grant`: the refresh token was
|
||||
* revoked or expired). Reconnectable by the user, unlike an API/config error:
|
||||
* the engine records it as the connection `error` state that offers
|
||||
* "Reconectar" in one click.
|
||||
*/
|
||||
export class GoogleCalendarAuthError extends Error {
|
||||
constructor(message: string) {
|
||||
super(message)
|
||||
this.name = 'GoogleCalendarAuthError'
|
||||
}
|
||||
}
|
||||
|
||||
export type GoogleCalendarClient = {
|
||||
listEvents: (
|
||||
calendarId: string,
|
||||
|
|
@ -76,7 +107,7 @@ export type FetchLike = typeof fetch
|
|||
* for tests — `createGoogleCalendarClient` mints and exchanges it internally.
|
||||
*/
|
||||
export const buildServiceAccountAssertion = async (
|
||||
credentials: GoogleCalendarCredentials,
|
||||
credentials: GoogleCalendarServiceAccountCredentials,
|
||||
nowSeconds: number,
|
||||
): Promise<string> => {
|
||||
const key = await importPKCS8(credentials.privateKey, 'RS256')
|
||||
|
|
@ -89,8 +120,18 @@ export const buildServiceAccountAssertion = async (
|
|||
.sign(key)
|
||||
}
|
||||
|
||||
/** Reads the token endpoint's error code without ever surfacing the body. */
|
||||
const readOAuthErrorCode = async (response: Response): Promise<string | null> => {
|
||||
try {
|
||||
const body = (await response.json()) as { error?: unknown }
|
||||
return typeof body.error === 'string' ? body.error : null
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
export const createGoogleCalendarClient = (
|
||||
credentials: GoogleCalendarCredentials,
|
||||
auth: GoogleCalendarAuth,
|
||||
fetchImpl: FetchLike = fetch,
|
||||
hookSignal?: AbortSignal,
|
||||
): GoogleCalendarClient => {
|
||||
|
|
@ -101,7 +142,29 @@ export const createGoogleCalendarClient = (
|
|||
return hookSignal ? AbortSignal.any([hookSignal, perHop]) : perHop
|
||||
}
|
||||
|
||||
const requestAccessToken = async (): Promise<string> => {
|
||||
const parseAccessToken = async (response: Response): Promise<string> => {
|
||||
const body = (await response.json().catch(() => null)) as {
|
||||
access_token?: unknown
|
||||
expires_in?: unknown
|
||||
} | null
|
||||
const accessToken = typeof body?.access_token === 'string' ? body.access_token : ''
|
||||
if (!accessToken) {
|
||||
throw new GoogleCalendarApiError('Resposta de autenticação do Google sem token.', 502)
|
||||
}
|
||||
const expiresIn =
|
||||
typeof body?.expires_in === 'number' && Number.isFinite(body.expires_in)
|
||||
? body.expires_in
|
||||
: GOOGLE_TOKEN_TTL_SECONDS
|
||||
cachedToken = {
|
||||
value: accessToken,
|
||||
expiresAtMs: Date.now() + expiresIn * 1000,
|
||||
}
|
||||
return cachedToken.value
|
||||
}
|
||||
|
||||
const requestServiceAccountAccessToken = async (
|
||||
credentials: GoogleCalendarServiceAccountCredentials,
|
||||
): Promise<string> => {
|
||||
const nowSeconds = Math.floor(Date.now() / 1000)
|
||||
const assertion = await buildServiceAccountAssertion(credentials, nowSeconds)
|
||||
|
||||
|
|
@ -120,19 +183,46 @@ export const createGoogleCalendarClient = (
|
|||
response.status,
|
||||
)
|
||||
}
|
||||
|
||||
const body = (await response.json()) as { access_token?: string; expires_in?: number }
|
||||
if (!body.access_token) {
|
||||
throw new GoogleCalendarApiError('Resposta de autenticação do Google sem token.', 502)
|
||||
}
|
||||
|
||||
cachedToken = {
|
||||
value: body.access_token,
|
||||
expiresAtMs: Date.now() + (body.expires_in ?? GOOGLE_TOKEN_TTL_SECONDS) * 1000,
|
||||
}
|
||||
return cachedToken.value
|
||||
return parseAccessToken(response)
|
||||
}
|
||||
|
||||
const requestOAuthAccessToken = async (
|
||||
credentials: GoogleCalendarOAuthCredentials,
|
||||
): Promise<string> => {
|
||||
const response = await fetchImpl(GOOGLE_TOKEN_ENDPOINT, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
||||
body: new URLSearchParams({
|
||||
grant_type: 'refresh_token',
|
||||
refresh_token: credentials.refreshToken,
|
||||
client_id: credentials.clientId,
|
||||
client_secret: credentials.clientSecret,
|
||||
}).toString(),
|
||||
signal: requestSignal(),
|
||||
})
|
||||
if (!response.ok) {
|
||||
// `invalid_grant` is the user-fixable case (token revoked/expired) and
|
||||
// becomes the reconnectable auth error. `invalid_client` (our app
|
||||
// credential is wrong) and transient failures stay API errors: telling
|
||||
// the user to reconnect would not fix them.
|
||||
if ((await readOAuthErrorCode(response)) === 'invalid_grant') {
|
||||
throw new GoogleCalendarAuthError(
|
||||
'A conexão com o Google expirou ou foi revogada. Reconecte a conta.',
|
||||
)
|
||||
}
|
||||
throw new GoogleCalendarApiError(
|
||||
`Não foi possível autenticar no Google (HTTP ${response.status}).`,
|
||||
response.status,
|
||||
)
|
||||
}
|
||||
return parseAccessToken(response)
|
||||
}
|
||||
|
||||
const requestAccessToken = (): Promise<string> =>
|
||||
auth.kind === 'oauth'
|
||||
? requestOAuthAccessToken(auth.credentials)
|
||||
: requestServiceAccountAccessToken(auth.credentials)
|
||||
|
||||
const getAccessToken = async (): Promise<string> => {
|
||||
if (cachedToken && cachedToken.expiresAtMs > Date.now() + TOKEN_REFRESH_LEAD_SECONDS * 1000) {
|
||||
return cachedToken.value
|
||||
|
|
|
|||
212
src/utilities/googleCalendarOAuth.ts
Normal file
212
src/utilities/googleCalendarOAuth.ts
Normal file
|
|
@ -0,0 +1,212 @@
|
|||
import 'server-only'
|
||||
|
||||
import { cookies } from 'next/headers'
|
||||
import { createHmac, timingSafeEqual } from 'node:crypto'
|
||||
|
||||
import {
|
||||
GOOGLE_CALENDAR_OAUTH_CALLBACK_PATH,
|
||||
GOOGLE_CALENDAR_OAUTH_SCOPES,
|
||||
} from '@/lib/googleCalendarOAuth'
|
||||
import { getCampaignInviteBaseURL } from '@/utilities/campaignInviteOrigin'
|
||||
|
||||
/**
|
||||
* C149 — the server side of the campaign's Google OAuth handshake: the client
|
||||
* credential pair (env), the public redirect URI, the signed single-use
|
||||
* `state` cookie and the authorization-code exchange.
|
||||
*
|
||||
* No token ever crosses this module's messages: the exchange reads only
|
||||
* `refresh_token`/`scope` and fails with a safe, fixed pt-BR message — the
|
||||
* response body is never echoed and never logged.
|
||||
*/
|
||||
|
||||
export const GOOGLE_CALENDAR_OAUTH_CLIENT_ID_ENV = 'GOOGLE_CALENDAR_OAUTH_CLIENT_ID'
|
||||
export const GOOGLE_CALENDAR_OAUTH_CLIENT_SECRET_ENV = 'GOOGLE_CALENDAR_OAUTH_CLIENT_SECRET'
|
||||
|
||||
const GOOGLE_TOKEN_ENDPOINT = 'https://oauth2.googleapis.com/token'
|
||||
const STATE_COOKIE_NAME = 'campaign-google-oauth-state'
|
||||
const STATE_COOKIE_PATH = '/campanha'
|
||||
const STATE_TTL_MS = 10 * 60 * 1000
|
||||
const REQUEST_TIMEOUT_MS = 15_000
|
||||
|
||||
export type GoogleCalendarOAuthClientCredentials = {
|
||||
clientId: string
|
||||
clientSecret: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Reads the OAuth client pair from the environment. Fail-closed: a partial or
|
||||
* absent pair disables the connect button (the view exposes `oauthAvailable`)
|
||||
* without breaking the service-account fallback. Never logs the values.
|
||||
*/
|
||||
export const readGoogleCalendarOAuthCredentials =
|
||||
(): GoogleCalendarOAuthClientCredentials | null => {
|
||||
const clientId = process.env[GOOGLE_CALENDAR_OAUTH_CLIENT_ID_ENV]?.trim()
|
||||
const clientSecret = process.env[GOOGLE_CALENDAR_OAUTH_CLIENT_SECRET_ENV]?.trim()
|
||||
if (!clientId || !clientSecret) return null
|
||||
return { clientId, clientSecret }
|
||||
}
|
||||
|
||||
/**
|
||||
* The redirect URI registered in the GCP OAuth client. Always the canonical
|
||||
* public origin (production requires `NEXT_PUBLIC_SITE_URL`), never the
|
||||
* request URL — so a callback behind the Cloudflare tunnel cannot leak
|
||||
* localhost into the redirect.
|
||||
*/
|
||||
export const buildGoogleCalendarOAuthRedirectUri = (): string =>
|
||||
`${getCampaignInviteBaseURL()}${GOOGLE_CALENDAR_OAUTH_CALLBACK_PATH}`
|
||||
|
||||
export class GoogleCalendarOAuthError extends Error {
|
||||
constructor(message: string) {
|
||||
super(message)
|
||||
this.name = 'GoogleCalendarOAuthError'
|
||||
}
|
||||
}
|
||||
|
||||
const GOOGLE_CALENDAR_OAUTH_STATE_EXPIRED_MESSAGE =
|
||||
'A conexão com o Google expirou. Tente conectar novamente.'
|
||||
|
||||
const getSecret = (): string => {
|
||||
const secret = process.env.PAYLOAD_SECRET
|
||||
if (!secret) throw new Error('PAYLOAD_SECRET não configurado para o state do OAuth.')
|
||||
return secret
|
||||
}
|
||||
|
||||
const signature = (state: string, userId: string, expiresAt: number): Buffer =>
|
||||
createHmac('sha256', getSecret()).update(`${state}.${userId}.${expiresAt}`).digest()
|
||||
|
||||
const cookieOptions = (maxAge: number) => ({
|
||||
httpOnly: true,
|
||||
maxAge,
|
||||
path: STATE_COOKIE_PATH,
|
||||
sameSite: 'lax' as const,
|
||||
secure: process.env.NODE_ENV === 'production',
|
||||
})
|
||||
|
||||
/**
|
||||
* Stores the signed, time-boxed `state` — the CSRF/replay guard of the
|
||||
* handshake, bound to the campaign user that started it (precedent:
|
||||
* `campaignWebAuthnChallenge`).
|
||||
*/
|
||||
export const storeGoogleCalendarOAuthState = async (
|
||||
state: string,
|
||||
userId: number,
|
||||
): Promise<void> => {
|
||||
const expiresAt = Date.now() + STATE_TTL_MS
|
||||
const sig = signature(state, String(userId), expiresAt).toString('base64url')
|
||||
|
||||
const cookieStore = await cookies()
|
||||
cookieStore.set(
|
||||
STATE_COOKIE_NAME,
|
||||
`${state}.${userId}.${expiresAt}.${sig}`,
|
||||
cookieOptions(Math.ceil(STATE_TTL_MS / 1000)),
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Verifies the cookie against the `state` Google echoed back and returns the
|
||||
* user that started the flow. Anything wrong (missing, malformed, stale,
|
||||
* forged, mismatched) is the same event from the user's side.
|
||||
*/
|
||||
export const readGoogleCalendarOAuthState = async (
|
||||
providedState: string,
|
||||
): Promise<{ userId: number }> => {
|
||||
const cookieStore = await cookies()
|
||||
const raw = cookieStore.get(STATE_COOKIE_NAME)?.value
|
||||
|
||||
const invalid = () => new GoogleCalendarOAuthError(GOOGLE_CALENDAR_OAUTH_STATE_EXPIRED_MESSAGE)
|
||||
if (!raw) throw invalid()
|
||||
|
||||
const [state, userIdText, expiresAtText, sig, ...rest] = raw.split('.')
|
||||
if (!state || !userIdText || !expiresAtText || !sig || rest.length > 0) throw invalid()
|
||||
|
||||
const expiresAt = Number(expiresAtText)
|
||||
if (!Number.isSafeInteger(expiresAt) || expiresAt <= Date.now()) throw invalid()
|
||||
|
||||
const expected = signature(state, userIdText, expiresAt)
|
||||
const provided = Buffer.from(sig, 'base64url')
|
||||
if (expected.length !== provided.length || !timingSafeEqual(expected, provided)) throw invalid()
|
||||
if (state !== providedState) throw invalid()
|
||||
|
||||
const userId = Number(userIdText)
|
||||
if (!Number.isSafeInteger(userId) || userId <= 0) throw invalid()
|
||||
|
||||
return { userId }
|
||||
}
|
||||
|
||||
/** Burns the state so a captured callback cannot be replayed. */
|
||||
export const clearGoogleCalendarOAuthState = async (): Promise<void> => {
|
||||
const cookieStore = await cookies()
|
||||
cookieStore.set(STATE_COOKIE_NAME, '', cookieOptions(0))
|
||||
}
|
||||
|
||||
export type GoogleCalendarOAuthTokens = {
|
||||
refreshToken: string
|
||||
scope: string | null
|
||||
}
|
||||
|
||||
/**
|
||||
* Exchanges the authorization code for the long-lived refresh token. The
|
||||
* response body is never echoed or logged: a non-OK answer collapses to a
|
||||
* fixed safe message, and a body without `refresh_token` tells the user to
|
||||
* remove the app access and try again (the case where Google does not reissue
|
||||
* a token for an already-granted consent).
|
||||
*/
|
||||
export const exchangeGoogleCalendarOAuthCode = async ({
|
||||
code,
|
||||
redirectUri,
|
||||
credentials,
|
||||
fetchImpl = fetch,
|
||||
}: {
|
||||
code: string
|
||||
redirectUri: string
|
||||
credentials: GoogleCalendarOAuthClientCredentials
|
||||
fetchImpl?: typeof fetch
|
||||
}): Promise<GoogleCalendarOAuthTokens> => {
|
||||
let response: Response
|
||||
try {
|
||||
response = await fetchImpl(GOOGLE_TOKEN_ENDPOINT, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
||||
body: new URLSearchParams({
|
||||
grant_type: 'authorization_code',
|
||||
code,
|
||||
client_id: credentials.clientId,
|
||||
client_secret: credentials.clientSecret,
|
||||
redirect_uri: redirectUri,
|
||||
}).toString(),
|
||||
signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),
|
||||
})
|
||||
} catch {
|
||||
throw new GoogleCalendarOAuthError('Não foi possível falar com o Google. Tente novamente.')
|
||||
}
|
||||
|
||||
if (!response.ok) {
|
||||
throw new GoogleCalendarOAuthError('O Google recusou a autorização. Tente conectar novamente.')
|
||||
}
|
||||
|
||||
const body = (await response.json().catch(() => null)) as {
|
||||
refresh_token?: unknown
|
||||
scope?: unknown
|
||||
} | null
|
||||
const refreshToken = typeof body?.refresh_token === 'string' ? body.refresh_token : ''
|
||||
if (!refreshToken) {
|
||||
throw new GoogleCalendarOAuthError(
|
||||
'O Google não devolveu um token de atualização. Remova o acesso do Teqo na sua conta Google e tente conectar de novo.',
|
||||
)
|
||||
}
|
||||
|
||||
const scope = typeof body?.scope === 'string' ? body.scope : null
|
||||
// Granular consent lets the user uncheck a scope: without `calendar.events`
|
||||
// the mirror would connect and then fail every pass with 403 — fail the
|
||||
// handshake instead, which lands on the reconnect path.
|
||||
if (scope) {
|
||||
const granted = new Set(scope.split(' ').filter(Boolean))
|
||||
if (GOOGLE_CALENDAR_OAUTH_SCOPES.some((required) => !granted.has(required))) {
|
||||
throw new GoogleCalendarOAuthError(
|
||||
'A autorização não inclui todas as permissões necessárias. Conecte novamente e aceite o acesso completo.',
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
return { refreshToken, scope }
|
||||
}
|
||||
|
|
@ -32,9 +32,15 @@ import { getCampaignInviteBaseURL } from '@/utilities/campaignInviteOrigin'
|
|||
import {
|
||||
createGoogleCalendarClient,
|
||||
GoogleCalendarApiError,
|
||||
GoogleCalendarAuthError,
|
||||
type GoogleCalendarAuth,
|
||||
type GoogleCalendarClient,
|
||||
type GoogleCalendarCredentials,
|
||||
type GoogleCalendarServiceAccountCredentials,
|
||||
} from '@/utilities/googleCalendarClient'
|
||||
import {
|
||||
readGoogleCalendarOAuthCredentials,
|
||||
type GoogleCalendarOAuthClientCredentials,
|
||||
} from '@/utilities/googleCalendarOAuth'
|
||||
|
||||
/**
|
||||
* C114+C115 — the campaign↔Google calendar reconciliation engine.
|
||||
|
|
@ -57,6 +63,12 @@ import {
|
|||
* trigger also lazily ensures the push channel (D5). The engine NEVER throws
|
||||
* into the caller — failures land in the state fields and the status derives
|
||||
* to `paused`; the channel itself is best-effort and never pauses the mirror.
|
||||
*
|
||||
* C149 — the credential is now resolved by `readGoogleCalendarAuth`: the OAuth
|
||||
* connection (refresh token stored on the config row) wins when present and
|
||||
* the service account remains the fallback. A dead OAuth connection
|
||||
* (`GoogleCalendarAuthError`) additionally records `oauthError`, which derives
|
||||
* the connection `error` state that offers reconnect in one click.
|
||||
*/
|
||||
|
||||
export const GOOGLE_CALENDAR_SERVICE_ACCOUNT_KEY_ENV = 'GOOGLE_CALENDAR_SERVICE_ACCOUNT_KEY'
|
||||
|
|
@ -111,8 +123,16 @@ const SYNC_RELEVANT_ACTIVITY_FIELDS = [
|
|||
|
||||
export type GoogleCalendarSyncStatus = 'not-configured' | 'disabled' | 'synced' | 'paused'
|
||||
|
||||
export type GoogleCalendarConnectionStatus = 'connected' | 'error' | 'not-configured'
|
||||
|
||||
export type GoogleCalendarSyncView = {
|
||||
status: GoogleCalendarSyncStatus
|
||||
/** C149 — state of the OAuth connection (derived, never stored). */
|
||||
connection: GoogleCalendarConnectionStatus
|
||||
/** C149 — the OAuth client env is present, so the connect button can work. */
|
||||
oauthAvailable: boolean
|
||||
oauthConnectedAt: string | null
|
||||
oauthError: string | null
|
||||
calendarId: string | null
|
||||
lastSyncedAt: string | null
|
||||
lastSuccessAt: string | null
|
||||
|
|
@ -142,43 +162,92 @@ export const deriveGoogleCalendarSyncStatus = (input: {
|
|||
return 'synced'
|
||||
}
|
||||
|
||||
/**
|
||||
* C149 — pure derivation of the connection state (the intent's literal
|
||||
* `conectado | erro | não configurado`). An auth error newer than the last
|
||||
* connection wins (including before any successful connection); a token
|
||||
* without a newer error is connected; anything else is not configured. The
|
||||
* state is never stored — reconnect clears the error fields and the state
|
||||
* recovers by construction.
|
||||
*/
|
||||
export const deriveGoogleCalendarConnectionStatus = (input: {
|
||||
hasRefreshToken: boolean
|
||||
oauthConnectedAt?: string | null
|
||||
oauthErrorAt?: string | null
|
||||
}): GoogleCalendarConnectionStatus => {
|
||||
// Timestamps are compared as instants, not strings: a value persisted with
|
||||
// a UTC offset (admin edit, fixture) would invert a lexical comparison.
|
||||
const errorAt = input.oauthErrorAt ? Date.parse(input.oauthErrorAt) : Number.NaN
|
||||
const connectedAt = input.oauthConnectedAt ? Date.parse(input.oauthConnectedAt) : Number.NaN
|
||||
if (!Number.isNaN(errorAt) && (Number.isNaN(connectedAt) || errorAt > connectedAt)) {
|
||||
return 'error'
|
||||
}
|
||||
return input.hasRefreshToken ? 'connected' : 'not-configured'
|
||||
}
|
||||
|
||||
/**
|
||||
* Reads the service account credential from the environment (base64 of the
|
||||
* Google Cloud JSON key). Fail-closed: malformed/absent → null, which
|
||||
* disables the sync entirely without breaking anything else.
|
||||
*/
|
||||
export const readGoogleServiceAccountCredentials = (): GoogleCalendarCredentials | null => {
|
||||
const raw = process.env[GOOGLE_CALENDAR_SERVICE_ACCOUNT_KEY_ENV]
|
||||
if (!raw) {
|
||||
// Note: this function is called without payload context, so we use console.warn
|
||||
// In production, this warning will appear in server logs
|
||||
console.warn(
|
||||
`[GoogleCalendarSync] ${GOOGLE_CALENDAR_SERVICE_ACCOUNT_KEY_ENV} não está definido no ambiente.`,
|
||||
)
|
||||
export const readGoogleServiceAccountCredentials =
|
||||
(): GoogleCalendarServiceAccountCredentials | null => {
|
||||
const raw = process.env[GOOGLE_CALENDAR_SERVICE_ACCOUNT_KEY_ENV]
|
||||
if (!raw) {
|
||||
// Note: this function is called without payload context, so we use console.warn
|
||||
// In production, this warning will appear in server logs
|
||||
console.warn(
|
||||
`[GoogleCalendarSync] ${GOOGLE_CALENDAR_SERVICE_ACCOUNT_KEY_ENV} não está definido no ambiente.`,
|
||||
)
|
||||
return null
|
||||
}
|
||||
|
||||
try {
|
||||
const parsed = JSON.parse(Buffer.from(raw, 'base64').toString('utf8')) as Record<
|
||||
string,
|
||||
unknown
|
||||
>
|
||||
if (
|
||||
typeof parsed.client_email === 'string' &&
|
||||
typeof parsed.private_key === 'string' &&
|
||||
parsed.private_key.length > 0
|
||||
) {
|
||||
return { clientEmail: parsed.client_email, privateKey: parsed.private_key }
|
||||
}
|
||||
console.warn(
|
||||
`[GoogleCalendarSync] ${GOOGLE_CALENDAR_SERVICE_ACCOUNT_KEY_ENV} está malformado: client_email ou private_key ausente/vazio.`,
|
||||
)
|
||||
} catch {
|
||||
console.warn(
|
||||
`[GoogleCalendarSync] ${GOOGLE_CALENDAR_SERVICE_ACCOUNT_KEY_ENV} não é base64 válido ou JSON inválido.`,
|
||||
)
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
try {
|
||||
const parsed = JSON.parse(Buffer.from(raw, 'base64').toString('utf8')) as Record<
|
||||
string,
|
||||
unknown
|
||||
>
|
||||
if (
|
||||
typeof parsed.client_email === 'string' &&
|
||||
typeof parsed.private_key === 'string' &&
|
||||
parsed.private_key.length > 0
|
||||
) {
|
||||
return { clientEmail: parsed.client_email, privateKey: parsed.private_key }
|
||||
/**
|
||||
* C149 — resolves the credential the engine uses. The OAuth connection (when
|
||||
* a refresh token is stored AND the client env is present) wins over the
|
||||
* service account, which stays as the technical fallback. `null` means no
|
||||
* credential at all — the fail-closed state that skips the sync.
|
||||
*/
|
||||
export const readGoogleCalendarAuth = (
|
||||
doc: GoogleCalendarSyncDoc | null,
|
||||
oauthClient: GoogleCalendarOAuthClientCredentials | null = readGoogleCalendarOAuthCredentials(),
|
||||
): GoogleCalendarAuth | null => {
|
||||
if (doc?.oauthRefreshToken && oauthClient) {
|
||||
return {
|
||||
kind: 'oauth',
|
||||
credentials: {
|
||||
clientId: oauthClient.clientId,
|
||||
clientSecret: oauthClient.clientSecret,
|
||||
refreshToken: doc.oauthRefreshToken,
|
||||
},
|
||||
}
|
||||
console.warn(
|
||||
`[GoogleCalendarSync] ${GOOGLE_CALENDAR_SERVICE_ACCOUNT_KEY_ENV} está malformado: client_email ou private_key ausente/vazio.`,
|
||||
)
|
||||
} catch {
|
||||
console.warn(
|
||||
`[GoogleCalendarSync] ${GOOGLE_CALENDAR_SERVICE_ACCOUNT_KEY_ENV} não é base64 válido ou JSON inválido.`,
|
||||
)
|
||||
}
|
||||
return null
|
||||
|
||||
const serviceAccount = readGoogleServiceAccountCredentials()
|
||||
return serviceAccount ? { kind: 'service-account', credentials: serviceAccount } : null
|
||||
}
|
||||
|
||||
export const loadGoogleCalendarSyncConfig = async (
|
||||
|
|
@ -187,26 +256,31 @@ export const loadGoogleCalendarSyncConfig = async (
|
|||
): Promise<GoogleCalendarSyncDoc | null> => {
|
||||
// Intentional admin bypass: config/state of the mirror is read by the
|
||||
// engine and the agenda pill for any staff — access is enforced at the
|
||||
// action layer, never per-read. The configured row wins: a stray empty
|
||||
// row (staff can no longer create one, but legacy rows may exist) must
|
||||
// never shadow the operational calendar (single-row invariant). The
|
||||
// webhook route and the engine rely on this bypass for the channel
|
||||
// identity fields, which no user path may read.
|
||||
// action layer, never per-read. The webhook route and the engine rely on
|
||||
// this bypass for the channel identity fields, which no user path may read.
|
||||
//
|
||||
// C149 — single-row read with a preference: the configured row (calendarId
|
||||
// present) wins, and when none is configured the connection-only row is
|
||||
// returned (fresh setup: the OAuth connection exists before C150 picks the
|
||||
// calendar). The collection holds one row operationally; legacy extra docs
|
||||
// must never shadow the operational calendar — same intentional admin bypass.
|
||||
const result = await payload.find({
|
||||
collection: 'googleCalendarSync',
|
||||
limit: 1,
|
||||
limit: 0,
|
||||
pagination: false,
|
||||
depth: 0,
|
||||
where: { calendarId: { exists: true } },
|
||||
sort: 'createdAt',
|
||||
overrideAccess: true,
|
||||
req,
|
||||
})
|
||||
return result.docs[0] ?? null
|
||||
const docs = result.docs
|
||||
return docs.find((doc) => Boolean(doc.calendarId)) ?? docs[0] ?? null
|
||||
}
|
||||
|
||||
const docToView = (
|
||||
doc: GoogleCalendarSyncDoc | null,
|
||||
hasCredential: boolean,
|
||||
oauthAvailable: boolean,
|
||||
): GoogleCalendarSyncView => ({
|
||||
status: deriveGoogleCalendarSyncStatus({
|
||||
hasCredential,
|
||||
|
|
@ -215,6 +289,14 @@ const docToView = (
|
|||
lastSuccessAt: doc?.lastSuccessAt ?? null,
|
||||
lastErrorAt: doc?.lastErrorAt ?? null,
|
||||
}),
|
||||
connection: deriveGoogleCalendarConnectionStatus({
|
||||
hasRefreshToken: Boolean(doc?.oauthRefreshToken),
|
||||
oauthConnectedAt: doc?.oauthConnectedAt ?? null,
|
||||
oauthErrorAt: doc?.oauthErrorAt ?? null,
|
||||
}),
|
||||
oauthAvailable,
|
||||
oauthConnectedAt: doc?.oauthConnectedAt ?? null,
|
||||
oauthError: doc?.oauthError ?? null,
|
||||
calendarId: doc?.calendarId ?? null,
|
||||
lastSyncedAt: doc?.lastSyncedAt ?? null,
|
||||
lastSuccessAt: doc?.lastSuccessAt ?? null,
|
||||
|
|
@ -228,11 +310,9 @@ const docToView = (
|
|||
export const readGoogleCalendarSyncView = async (
|
||||
payload: Payload,
|
||||
): Promise<GoogleCalendarSyncView> => {
|
||||
const [doc, credentials] = await Promise.all([
|
||||
loadGoogleCalendarSyncConfig(payload),
|
||||
readGoogleServiceAccountCredentials(),
|
||||
])
|
||||
return docToView(doc, credentials !== null)
|
||||
const doc = await loadGoogleCalendarSyncConfig(payload)
|
||||
const oauthClient = readGoogleCalendarOAuthCredentials()
|
||||
return docToView(doc, readGoogleCalendarAuth(doc, oauthClient) !== null, oauthClient !== null)
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -638,6 +718,8 @@ type SyncStatePatch = Partial<
|
|||
| 'lastSuccessAt'
|
||||
| 'lastErrorAt'
|
||||
| 'lastError'
|
||||
| 'oauthErrorAt'
|
||||
| 'oauthError'
|
||||
| 'pushChannelId'
|
||||
| 'pushChannelResourceId'
|
||||
| 'pushChannelExpiresAt'
|
||||
|
|
@ -720,6 +802,100 @@ export const recordGoogleCalendarSyncError = async (
|
|||
await recordSyncState(payload, undefined, { lastErrorAt: at, lastError: message.slice(0, 500) })
|
||||
}
|
||||
|
||||
/**
|
||||
* C149 — persists the OAuth connection (the callback calls this after a
|
||||
* successful code exchange). Creates the singleton connection row when the
|
||||
* start action did not (fresh setup); clears every error field so the derived
|
||||
* connection state recovers to `connected` by construction. The refresh token
|
||||
* is system state, written with the admin bypass, and never returned here.
|
||||
*/
|
||||
export const recordGoogleCalendarOAuthConnection = async (
|
||||
payload: Payload,
|
||||
data: { refreshToken: string; scope: string | null },
|
||||
req?: PayloadRequest,
|
||||
): Promise<void> => {
|
||||
const at = new Date().toISOString()
|
||||
const patch = {
|
||||
oauthRefreshToken: data.refreshToken,
|
||||
oauthScope: data.scope,
|
||||
oauthConnectedAt: at,
|
||||
oauthErrorAt: null,
|
||||
oauthError: null,
|
||||
}
|
||||
|
||||
const doc = await loadGoogleCalendarSyncConfig(payload, req)
|
||||
if (doc) {
|
||||
await payload.update({
|
||||
collection: 'googleCalendarSync',
|
||||
id: doc.id,
|
||||
data: patch,
|
||||
depth: 0,
|
||||
// Intentional admin bypass: the connection is system state, unreadable
|
||||
// by user paths; the caller checks the actor before getting here.
|
||||
overrideAccess: true,
|
||||
req,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
await payload.create({
|
||||
collection: 'googleCalendarSync',
|
||||
data: patch,
|
||||
depth: 0,
|
||||
// Intentional admin bypass: same rationale as above.
|
||||
overrideAccess: true,
|
||||
req,
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* C149 — records a failed handshake (code exchange) so the UI shows the
|
||||
* connection `error` with the reconnect path. Never throws into the callback:
|
||||
* the failure is already being surfaced by the redirect.
|
||||
*/
|
||||
export const recordGoogleCalendarOAuthError = async (
|
||||
payload: Payload,
|
||||
message: string,
|
||||
): Promise<void> => {
|
||||
const doc = await loadGoogleCalendarSyncConfig(payload)
|
||||
if (!doc) return // nothing to record onto — the start action creates the row
|
||||
|
||||
const at = new Date().toISOString()
|
||||
await payload.update({
|
||||
collection: 'googleCalendarSync',
|
||||
id: doc.id,
|
||||
data: { oauthErrorAt: at, oauthError: message.slice(0, 500) },
|
||||
depth: 0,
|
||||
// Intentional admin bypass: connection state is system-only.
|
||||
overrideAccess: true,
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* C149 — disconnect: drops the OAuth connection from the Teqo (the UI guides
|
||||
* revoking the app access on the Google account). The service account, when
|
||||
* configured, takes over on the next trigger — no sync pass is forced here.
|
||||
*/
|
||||
export const clearGoogleCalendarOAuthConnection = async (payload: Payload): Promise<void> => {
|
||||
const doc = await loadGoogleCalendarSyncConfig(payload)
|
||||
if (!doc) return
|
||||
|
||||
await payload.update({
|
||||
collection: 'googleCalendarSync',
|
||||
id: doc.id,
|
||||
data: {
|
||||
oauthRefreshToken: null,
|
||||
oauthScope: null,
|
||||
oauthConnectedAt: null,
|
||||
oauthErrorAt: null,
|
||||
oauthError: null,
|
||||
},
|
||||
depth: 0,
|
||||
// Intentional admin bypass: the disconnect action checks the actor.
|
||||
overrideAccess: true,
|
||||
})
|
||||
}
|
||||
|
||||
/** The push-channel webhook address; the URL secret IS the credential. */
|
||||
const buildPushChannelAddress = (secret: string): string | null => {
|
||||
try {
|
||||
|
|
@ -827,6 +1003,20 @@ const ensureGoogleCalendarPushChannel = async (
|
|||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* C149 — pure classification for the engine's catch: only a dead OAuth
|
||||
* connection (`GoogleCalendarAuthError`, i.e. `invalid_grant`) also flips the
|
||||
* connection to `error`; every other failure stays `paused` without touching
|
||||
* the connection. Exported for the unit pin (the engine catch is I/O-bound).
|
||||
*/
|
||||
export const oauthErrorPatchFor = (
|
||||
error: unknown,
|
||||
at: string,
|
||||
): Pick<GoogleCalendarSyncDoc, 'oauthErrorAt' | 'oauthError'> | null =>
|
||||
error instanceof GoogleCalendarAuthError
|
||||
? { oauthErrorAt: at, oauthError: error.message.slice(0, 500) }
|
||||
: null
|
||||
|
||||
export type CampaignCalendarSyncOutcome = SyncCounts & {
|
||||
status: GoogleCalendarSyncStatus
|
||||
at: string
|
||||
|
|
@ -853,20 +1043,20 @@ export const runCampaignCalendarSync = async (
|
|||
payload: Payload,
|
||||
options: CampaignCalendarSyncOptions,
|
||||
): Promise<CampaignCalendarSyncOutcome> => {
|
||||
const credentials = readGoogleServiceAccountCredentials()
|
||||
const config = await loadGoogleCalendarSyncConfig(payload, options.req)
|
||||
const auth = readGoogleCalendarAuth(config)
|
||||
const at = new Date().toISOString()
|
||||
|
||||
if (!credentials || !config?.calendarId || config.disabledAt) {
|
||||
if (!auth || !config?.calendarId || config.disabledAt) {
|
||||
const status = deriveGoogleCalendarSyncStatus({
|
||||
hasCredential: credentials !== null,
|
||||
hasCredential: auth !== null,
|
||||
calendarId: config?.calendarId ?? null,
|
||||
disabledAt: config?.disabledAt ?? null,
|
||||
lastSuccessAt: config?.lastSuccessAt ?? null,
|
||||
lastErrorAt: config?.lastErrorAt ?? null,
|
||||
})
|
||||
payload.logger.warn(
|
||||
`[GoogleCalendarSync] Sincronização ignorada: credentialsPresent=${credentials !== null}, calendarId=${config?.calendarId ?? 'null'}, disabledAt=${config?.disabledAt ?? 'null'}, status=${status}`,
|
||||
`[GoogleCalendarSync] Sincronização ignorada: credentialsPresent=${auth !== null}, calendarId=${config?.calendarId ?? 'null'}, disabledAt=${config?.disabledAt ?? 'null'}, status=${status}`,
|
||||
)
|
||||
return {
|
||||
status,
|
||||
|
|
@ -882,8 +1072,7 @@ export const runCampaignCalendarSync = async (
|
|||
// the hook `signal` is not plumbed into that instance — the stub must be
|
||||
// created with its own signal. Production hooks never inject a client, so
|
||||
// the `??` branch covers the row-lock path.
|
||||
const client =
|
||||
options.client ?? createGoogleCalendarClient(credentials, undefined, options.signal)
|
||||
const client = options.client ?? createGoogleCalendarClient(auth, undefined, options.signal)
|
||||
|
||||
try {
|
||||
if (!options.skipChannelEnsure) {
|
||||
|
|
@ -897,17 +1086,28 @@ export const runCampaignCalendarSync = async (
|
|||
options.activityWhere,
|
||||
config,
|
||||
)
|
||||
await recordSyncState(payload, options.req, { lastSyncedAt: at, lastSuccessAt: at })
|
||||
// C149 — a successful pass also clears a previous OAuth error: the
|
||||
// connection state recovers to `connected` without a manual reconnect.
|
||||
await recordSyncState(payload, options.req, {
|
||||
lastSyncedAt: at,
|
||||
lastSuccessAt: at,
|
||||
oauthErrorAt: null,
|
||||
oauthError: null,
|
||||
})
|
||||
return { status: 'synced', ...counts, at }
|
||||
} catch (error) {
|
||||
const message =
|
||||
error instanceof Error ? error.message : 'Erro desconhecido ao sincronizar com o Google.'
|
||||
payload.logger.error(`[GoogleCalendarSync] Erro durante a sincronização: ${message}`)
|
||||
await recordSyncState(payload, options.req, {
|
||||
const patch: SyncStatePatch = {
|
||||
lastSyncedAt: at,
|
||||
lastErrorAt: at,
|
||||
lastError: message.slice(0, 500),
|
||||
})
|
||||
}
|
||||
// C149 — a dead OAuth connection (`invalid_grant`) additionally flips the
|
||||
// derived connection state to `error`, which offers reconnect in one click.
|
||||
Object.assign(patch, oauthErrorPatchFor(error, at) ?? {})
|
||||
await recordSyncState(payload, options.req, patch)
|
||||
return { status: 'paused', created: 0, updated: 0, deleted: 0, reverseEdits: 0, at }
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -49,7 +49,7 @@ const seedSyncConfig = (
|
|||
})
|
||||
|
||||
test.describe('Agenda — sincronização Google (C114/C122)', () => {
|
||||
test('staff vê a pill "não configurado" e o diálogo explica o passo de operação', async ({
|
||||
test('staff vê a pill "não configurado" e o diálogo oferece conectar (C149)', async ({
|
||||
campaign,
|
||||
page,
|
||||
}) => {
|
||||
|
|
@ -67,9 +67,57 @@ test.describe('Agenda — sincronização Google (C114/C122)', () => {
|
|||
|
||||
const dialog = syncDialog(page)
|
||||
await expect(dialog).toBeVisible()
|
||||
await expect(dialog.getByText('Ainda não configurado')).toBeVisible()
|
||||
// O runbook de operação aparece (a ativação depende da conta Google da campanha).
|
||||
await expect(dialog.getByText(/service account do Teqo/)).toBeVisible()
|
||||
// C149: sem conexão, o card oferece o botão de conectar (o client OAuth
|
||||
// dummy está no webServer) e mantém a service account como fallback.
|
||||
await expect(dialog.getByText('Não configurado')).toBeVisible()
|
||||
await expect(dialog.getByRole('button', { name: 'Conectar com o Google' })).toBeVisible()
|
||||
await expect(dialog.getByText(/service account continua disponível/)).toBeVisible()
|
||||
})
|
||||
|
||||
test('o botão Conectar leva ao consent do Google com os escopos mínimos (C149)', async ({
|
||||
campaign,
|
||||
page,
|
||||
}) => {
|
||||
const { fixtures } = campaign
|
||||
const coordinator = await fixtures.createCampaignUser('coordinator')
|
||||
// Row owned by the fixture: the start action UPDATES it (the connection
|
||||
// row may exist before any calendar is chosen) instead of creating a row
|
||||
// the cleanup would not track.
|
||||
await fixtures.payload.create({
|
||||
collection: 'googleCalendarSync',
|
||||
data: {},
|
||||
depth: 0,
|
||||
overrideAccess: true,
|
||||
})
|
||||
|
||||
await campaign.login(page, coordinator.email!, coordinator.password)
|
||||
await page.goto(`${campaign.baseURL}/campanha/agenda`)
|
||||
|
||||
// The click navigates the browser to accounts.google.com — intercept so
|
||||
// the assertion observes the URL Google would receive, with no network.
|
||||
await page.route('https://accounts.google.com/**', (route) =>
|
||||
route.fulfill({ status: 200, contentType: 'text/html', body: '<title>consent</title>' }),
|
||||
)
|
||||
|
||||
const pill = page.getByRole('button', { name: 'Google: não configurado' })
|
||||
await expect(pill).toBeVisible({ timeout: 15_000 })
|
||||
await pill.click()
|
||||
|
||||
const dialog = syncDialog(page)
|
||||
await expect(dialog).toBeVisible()
|
||||
await dialog.getByRole('button', { name: 'Conectar com o Google' }).click()
|
||||
|
||||
await expect.poll(() => page.url()).toContain('accounts.google.com/o/oauth2/v2/auth')
|
||||
const url = new URL(page.url())
|
||||
expect(url.searchParams.get('access_type')).toBe('offline')
|
||||
expect(url.searchParams.get('prompt')).toBe('consent')
|
||||
expect(url.searchParams.get('response_type')).toBe('code')
|
||||
expect(url.searchParams.get('scope')).toBe(
|
||||
'https://www.googleapis.com/auth/calendar.events https://www.googleapis.com/auth/calendar.calendarlist.readonly',
|
||||
)
|
||||
expect(url.searchParams.get('state')).toBeTruthy()
|
||||
const redirectUri = new URL(url.searchParams.get('redirect_uri') as string)
|
||||
expect(redirectUri.pathname).toBe('/campanha/agenda/google-oauth/callback')
|
||||
})
|
||||
|
||||
test('estado disabled: pill, aviso de desativação e Reativar re-sincroniza (D7)', async ({
|
||||
|
|
@ -212,7 +260,8 @@ test.describe('Agenda — sincronização Google (C114/C122)', () => {
|
|||
|
||||
const sheet = syncDialog(page)
|
||||
await expect(sheet).toBeVisible({ timeout: 15_000 })
|
||||
await expect(sheet.getByText('Ainda não configurado')).toBeVisible()
|
||||
await expect(sheet.getByText('Não configurado')).toBeVisible()
|
||||
await expect(sheet.getByRole('button', { name: 'Conectar com o Google' })).toBeVisible()
|
||||
})
|
||||
})
|
||||
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
import { Client } from 'pg'
|
||||
import { afterAll, beforeAll } from 'vitest'
|
||||
|
||||
import { assertTestDatabase } from './assertTestDatabase'
|
||||
|
||||
|
|
@ -12,6 +13,16 @@ import { assertTestDatabase } from './assertTestDatabase'
|
|||
*/
|
||||
export const ADMIN_LOGIN_LOCK_KEY = 727_001
|
||||
|
||||
/**
|
||||
* C149 — `googleCalendarSync` is a SINGLETON row, and the int suite runs files
|
||||
* in parallel against one database: several google specs create/pick/delete
|
||||
* configured rows concurrently, and the loader's "configured row wins"
|
||||
* preference then resolves to ANOTHER file's row (channel/count assertions
|
||||
* read the wrong doc, snapshots land on a row being deleted). The lock
|
||||
* serializes the whole google calendar family for the duration of each file.
|
||||
*/
|
||||
export const GOOGLE_CALENDAR_SYNC_LOCK_KEY = 727_002
|
||||
|
||||
/**
|
||||
* Runs `fn` while holding a Postgres advisory lock.
|
||||
*
|
||||
|
|
@ -33,3 +44,26 @@ export const withAdvisoryLock = async <T>(key: number, fn: () => Promise<T>): Pr
|
|||
await client.end().catch(() => {})
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Holds the given advisory lock for the WHOLE spec file: `beforeAll` acquires
|
||||
* on a dedicated connection and `afterAll` releases it. Call at the top level
|
||||
* of a spec. A crashed worker drops its connection, releasing the lock.
|
||||
*/
|
||||
export const serializeSpecWithAdvisoryLock = (key: number): void => {
|
||||
let client: Client | null = null
|
||||
|
||||
beforeAll(async () => {
|
||||
assertTestDatabase(process.env.DATABASE_URL)
|
||||
client = new Client({ connectionString: process.env.DATABASE_URL })
|
||||
await client.connect()
|
||||
await client.query('SELECT pg_advisory_lock($1)', [key])
|
||||
}, 60_000)
|
||||
|
||||
afterAll(async () => {
|
||||
if (!client) return
|
||||
await client.query('SELECT pg_advisory_unlock($1)', [key]).catch(() => {})
|
||||
await client.end().catch(() => {})
|
||||
client = null
|
||||
})
|
||||
}
|
||||
|
|
|
|||
|
|
@ -7,12 +7,18 @@ import config from '@/payload.config'
|
|||
import { loadGoogleCalendarSyncConfig } from '@/utilities/googleCalendarSync'
|
||||
import { hookFilledCreateData } from '@/utilities/hookFilledData'
|
||||
|
||||
import {
|
||||
GOOGLE_CALENDAR_SYNC_LOCK_KEY,
|
||||
serializeSpecWithAdvisoryLock,
|
||||
} from '../helpers/advisoryLock'
|
||||
import { installCampaignFixtures } from '../helpers/campaignFixtures'
|
||||
import {
|
||||
createSlowGoogleFetch,
|
||||
withValidGoogleCalendarCredential,
|
||||
} from '../helpers/googleCalendarTestKey'
|
||||
|
||||
serializeSpecWithAdvisoryLock(GOOGLE_CALENDAR_SYNC_LOCK_KEY)
|
||||
|
||||
let payload: Payload
|
||||
const campaignFixtures = installCampaignFixtures({
|
||||
getPayload: () => payload,
|
||||
|
|
|
|||
169
tests/int/googleCalendarOAuth.int.spec.ts
Normal file
169
tests/int/googleCalendarOAuth.int.spec.ts
Normal file
|
|
@ -0,0 +1,169 @@
|
|||
// @vitest-environment node
|
||||
//
|
||||
// C149 — the OAuth connection stored on the singleton `googleCalendarSync`
|
||||
// row: recording, error state, disconnect, view/loader derivation. The int
|
||||
// suite runs files in parallel against ONE shared database, so this file only
|
||||
// creates CONNECTION-ONLY rows (no `calendarId`): the singleton loader prefers
|
||||
// a configured row, which means these rows can never shadow the engine specs'
|
||||
// configured row. Rows this file created are tracked and deleted; a token that
|
||||
// landed on a shared row (the loader's preference) is cleared, never deleted.
|
||||
|
||||
import { getPayload, type Payload } from 'payload'
|
||||
import { afterEach, beforeAll, describe, expect, it } from 'vitest'
|
||||
|
||||
import config from '@/payload.config'
|
||||
import {
|
||||
GOOGLE_CALENDAR_OAUTH_CLIENT_ID_ENV,
|
||||
GOOGLE_CALENDAR_OAUTH_CLIENT_SECRET_ENV,
|
||||
} from '@/utilities/googleCalendarOAuth'
|
||||
import {
|
||||
clearGoogleCalendarOAuthConnection,
|
||||
loadGoogleCalendarSyncConfig,
|
||||
readGoogleCalendarSyncView,
|
||||
recordGoogleCalendarOAuthConnection,
|
||||
recordGoogleCalendarOAuthError,
|
||||
} from '@/utilities/googleCalendarSync'
|
||||
|
||||
import {
|
||||
GOOGLE_CALENDAR_SYNC_LOCK_KEY,
|
||||
serializeSpecWithAdvisoryLock,
|
||||
} from '../helpers/advisoryLock'
|
||||
|
||||
serializeSpecWithAdvisoryLock(GOOGLE_CALENDAR_SYNC_LOCK_KEY)
|
||||
|
||||
let payload: Payload
|
||||
|
||||
/** Sets/restores the OAuth client pair around a callback — restored on failure too. */
|
||||
const withGoogleCalendarOAuthClient = async <T>(run: () => Promise<T>): Promise<T> => {
|
||||
const previous = {
|
||||
id: process.env[GOOGLE_CALENDAR_OAUTH_CLIENT_ID_ENV],
|
||||
secret: process.env[GOOGLE_CALENDAR_OAUTH_CLIENT_SECRET_ENV],
|
||||
}
|
||||
process.env[GOOGLE_CALENDAR_OAUTH_CLIENT_ID_ENV] = 'test-client-id'
|
||||
process.env[GOOGLE_CALENDAR_OAUTH_CLIENT_SECRET_ENV] = 'test-client-secret'
|
||||
try {
|
||||
return await run()
|
||||
} finally {
|
||||
if (previous.id === undefined) delete process.env[GOOGLE_CALENDAR_OAUTH_CLIENT_ID_ENV]
|
||||
else process.env[GOOGLE_CALENDAR_OAUTH_CLIENT_ID_ENV] = previous.id
|
||||
if (previous.secret === undefined) delete process.env[GOOGLE_CALENDAR_OAUTH_CLIENT_SECRET_ENV]
|
||||
else process.env[GOOGLE_CALENDAR_OAUTH_CLIENT_SECRET_ENV] = previous.secret
|
||||
}
|
||||
}
|
||||
|
||||
describe('C149 — OAuth connection on the googleCalendarSync row', () => {
|
||||
beforeAll(async () => {
|
||||
payload = await getPayload({ config: await config })
|
||||
})
|
||||
|
||||
const ownedIds = new Set<number>()
|
||||
|
||||
afterEach(async () => {
|
||||
// Clear connection fields this file wrote wherever they landed — the
|
||||
// loader may have picked a row created by a sibling spec, and that row
|
||||
// must not be deleted from under it.
|
||||
const touched = await payload.find({
|
||||
collection: 'googleCalendarSync',
|
||||
where: { oauthRefreshToken: { like: 'c149-conn%' } },
|
||||
depth: 0,
|
||||
pagination: false,
|
||||
overrideAccess: true,
|
||||
})
|
||||
for (const doc of touched.docs) {
|
||||
if (ownedIds.has(doc.id)) continue
|
||||
await payload.update({
|
||||
collection: 'googleCalendarSync',
|
||||
id: doc.id,
|
||||
data: {
|
||||
oauthRefreshToken: null,
|
||||
oauthScope: null,
|
||||
oauthConnectedAt: null,
|
||||
oauthErrorAt: null,
|
||||
oauthError: null,
|
||||
},
|
||||
depth: 0,
|
||||
overrideAccess: true,
|
||||
})
|
||||
}
|
||||
for (const id of ownedIds) {
|
||||
await payload.delete({ collection: 'googleCalendarSync', id, overrideAccess: true })
|
||||
}
|
||||
ownedIds.clear()
|
||||
})
|
||||
|
||||
const createConnectionRow = async (data: Record<string, unknown> = {}) => {
|
||||
const doc = await payload.create({
|
||||
collection: 'googleCalendarSync',
|
||||
data,
|
||||
depth: 0,
|
||||
overrideAccess: true,
|
||||
})
|
||||
ownedIds.add(doc.id)
|
||||
return doc
|
||||
}
|
||||
|
||||
it('records the connection (create when no row exists) and clears previous errors', async () => {
|
||||
await createConnectionRow({ oauthErrorAt: '2026-08-11T10:00:00.000Z', oauthError: 'antigo' })
|
||||
|
||||
await recordGoogleCalendarOAuthConnection(payload, {
|
||||
refreshToken: 'c149-conn-create',
|
||||
scope: 'scope-a scope-b',
|
||||
})
|
||||
|
||||
const doc = await loadGoogleCalendarSyncConfig(payload)
|
||||
expect(doc?.oauthRefreshToken).toBe('c149-conn-create')
|
||||
expect(doc?.oauthScope).toBe('scope-a scope-b')
|
||||
expect(doc?.oauthConnectedAt).toBeTruthy()
|
||||
expect(doc?.oauthErrorAt).toBeNull()
|
||||
expect(doc?.oauthError).toBeNull()
|
||||
})
|
||||
|
||||
it('records the auth error and clears the whole connection on disconnect', async () => {
|
||||
await createConnectionRow({ oauthRefreshToken: 'c149-conn-disconnect' })
|
||||
|
||||
await recordGoogleCalendarOAuthError(payload, 'A conexão com o Google expirou.')
|
||||
|
||||
let doc = await loadGoogleCalendarSyncConfig(payload)
|
||||
expect(doc?.oauthError).toBe('A conexão com o Google expirou.')
|
||||
expect(doc?.oauthErrorAt).toBeTruthy()
|
||||
|
||||
await clearGoogleCalendarOAuthConnection(payload)
|
||||
|
||||
doc = await loadGoogleCalendarSyncConfig(payload)
|
||||
expect(doc?.oauthRefreshToken).toBeNull()
|
||||
expect(doc?.oauthScope).toBeNull()
|
||||
expect(doc?.oauthConnectedAt).toBeNull()
|
||||
expect(doc?.oauthErrorAt).toBeNull()
|
||||
expect(doc?.oauthError).toBeNull()
|
||||
})
|
||||
|
||||
it('derives the connection view (connected → error) and oauthAvailable from the env', async () => {
|
||||
await createConnectionRow({ oauthRefreshToken: 'c149-conn-view' })
|
||||
|
||||
await withGoogleCalendarOAuthClient(async () => {
|
||||
const connected = await readGoogleCalendarSyncView(payload)
|
||||
expect(connected.connection).toBe('connected')
|
||||
expect(connected.oauthAvailable).toBe(true)
|
||||
|
||||
await recordGoogleCalendarOAuthError(payload, 'Token revogado.')
|
||||
|
||||
const broken = await readGoogleCalendarSyncView(payload)
|
||||
expect(broken.connection).toBe('error')
|
||||
expect(broken.oauthError).toBe('Token revogado.')
|
||||
})
|
||||
})
|
||||
|
||||
it('finds a connection-only row even without a calendarId (fresh setup)', async () => {
|
||||
const row = await createConnectionRow({ oauthRefreshToken: 'c149-conn-loader' })
|
||||
|
||||
const loaded = await loadGoogleCalendarSyncConfig(payload)
|
||||
// A configured sibling (parallel spec) legitimately wins the singleton
|
||||
// preference; without one, the connection-only row is the row.
|
||||
if (loaded?.calendarId) {
|
||||
expect(loaded.id).not.toBe(row.id)
|
||||
} else {
|
||||
expect(loaded?.id).toBe(row.id)
|
||||
}
|
||||
expect(row.calendarId ?? null).toBeNull()
|
||||
})
|
||||
})
|
||||
293
tests/int/googleCalendarOAuthAction.int.spec.ts
Normal file
293
tests/int/googleCalendarOAuthAction.int.spec.ts
Normal file
|
|
@ -0,0 +1,293 @@
|
|||
// @vitest-environment node
|
||||
//
|
||||
// C149 — the OAuth handshake boundary: the start action (role + state cookie
|
||||
// + authorize URL) and the callback route (state validation, role re-check,
|
||||
// code exchange, connection recording). `next/headers` is mocked with an
|
||||
// in-memory cookie jar so the real signing/verification runs; `fetch` is
|
||||
// stubbed at the token endpoint so no request leaves the process. Session
|
||||
// resolution is mocked (the pattern of googleCalendarSyncAction.int.spec.ts);
|
||||
// everything else — Payload access, the engine hooks, the DB — runs for real.
|
||||
|
||||
import { NextRequest } from 'next/server'
|
||||
import { getPayload, type Payload } from 'payload'
|
||||
import { afterAll, afterEach, beforeAll, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import config from '@/payload.config'
|
||||
|
||||
const { cookieJar } = vi.hoisted(() => ({ cookieJar: new Map<string, string>() }))
|
||||
|
||||
vi.mock('next/headers', () => ({
|
||||
cookies: async () => ({
|
||||
get: (name: string) => (cookieJar.has(name) ? { name, value: cookieJar.get(name) } : undefined),
|
||||
set: (name: string, value: string) => {
|
||||
cookieJar.set(name, value)
|
||||
},
|
||||
}),
|
||||
headers: async () => new Headers(),
|
||||
}))
|
||||
|
||||
vi.mock('@/utilities/campaignActionContext', async (importOriginal) => {
|
||||
const original = await importOriginal<typeof import('@/utilities/campaignActionContext')>()
|
||||
return { ...original, getCampaignActionContext: vi.fn() }
|
||||
})
|
||||
|
||||
// The callback's follow-up reconciliation is not this spec's subject (the
|
||||
// engine has its own file) and, against the shared singleton, a full pass
|
||||
// would write snapshots onto whichever row the loader picked — including a
|
||||
// parallel spec's. Stub only that call; every other export stays real.
|
||||
vi.mock('@/utilities/googleCalendarSync', async (importOriginal) => {
|
||||
const original = await importOriginal<typeof import('@/utilities/googleCalendarSync')>()
|
||||
return { ...original, runCampaignCalendarSync: vi.fn() }
|
||||
})
|
||||
|
||||
import {
|
||||
disconnectGoogleCalendarOAuth,
|
||||
startGoogleCalendarOAuth,
|
||||
} from '@/app/(campaign)/campanha/actions/googleCalendarSync'
|
||||
import { GET as googleOAuthCallback } from '@/app/(campaign)/campanha/agenda/google-oauth/callback/route'
|
||||
import { getCampaignActionContext } from '@/utilities/campaignActionContext'
|
||||
import {
|
||||
GOOGLE_CALENDAR_OAUTH_CLIENT_ID_ENV,
|
||||
GOOGLE_CALENDAR_OAUTH_CLIENT_SECRET_ENV,
|
||||
} from '@/utilities/googleCalendarOAuth'
|
||||
import { recordGoogleCalendarOAuthConnection } from '@/utilities/googleCalendarSync'
|
||||
|
||||
import {
|
||||
GOOGLE_CALENDAR_SYNC_LOCK_KEY,
|
||||
serializeSpecWithAdvisoryLock,
|
||||
} from '../helpers/advisoryLock'
|
||||
import { installCampaignFixtures } from '../helpers/campaignFixtures'
|
||||
|
||||
serializeSpecWithAdvisoryLock(GOOGLE_CALENDAR_SYNC_LOCK_KEY)
|
||||
|
||||
let payload: Payload
|
||||
const campaignFixtures = installCampaignFixtures({
|
||||
getPayload: () => payload,
|
||||
setPayload: (nextPayload) => {
|
||||
payload = nextPayload
|
||||
},
|
||||
})
|
||||
|
||||
const mockedGetContext = vi.mocked(getCampaignActionContext)
|
||||
|
||||
const OAUTH_ENV_KEYS = [
|
||||
GOOGLE_CALENDAR_OAUTH_CLIENT_ID_ENV,
|
||||
GOOGLE_CALENDAR_OAUTH_CLIENT_SECRET_ENV,
|
||||
] as const
|
||||
const originalEnv = new Map(OAUTH_ENV_KEYS.map((key) => [key, process.env[key]]))
|
||||
|
||||
const setOAuthClientEnv = (): void => {
|
||||
process.env[GOOGLE_CALENDAR_OAUTH_CLIENT_ID_ENV] = 'test-client-id'
|
||||
process.env[GOOGLE_CALENDAR_OAUTH_CLIENT_SECRET_ENV] = 'test-client-secret'
|
||||
}
|
||||
|
||||
const restoreEnv = (): void => {
|
||||
for (const key of OAUTH_ENV_KEYS) {
|
||||
const value = originalEnv.get(key)
|
||||
if (value === undefined) delete process.env[key]
|
||||
else process.env[key] = value
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Token endpoint + an empty calendar API, so the callback's follow-up pass
|
||||
* completes without network regardless of which row the loader picked.
|
||||
*/
|
||||
const googleFetchStub = (refreshToken: string): typeof fetch =>
|
||||
(async (input: RequestInfo | URL) => {
|
||||
const url = String(input)
|
||||
if (url === 'https://oauth2.googleapis.com/token') {
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
access_token: 'access-token',
|
||||
refresh_token: refreshToken,
|
||||
scope:
|
||||
'https://www.googleapis.com/auth/calendar.events https://www.googleapis.com/auth/calendar.calendarlist.readonly',
|
||||
}),
|
||||
{ status: 200, headers: { 'Content-Type': 'application/json' } },
|
||||
)
|
||||
}
|
||||
if (url.includes('/events/watch')) {
|
||||
return new Response(
|
||||
JSON.stringify({ id: 'watch-1', resourceId: 'resource-1', expiration: null }),
|
||||
{ status: 200, headers: { 'Content-Type': 'application/json' } },
|
||||
)
|
||||
}
|
||||
if (url.includes('/channels/stop')) return new Response(null, { status: 204 })
|
||||
if (url.includes('/calendar/v3/')) {
|
||||
return new Response(JSON.stringify({ items: [] }), {
|
||||
status: 200,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
})
|
||||
}
|
||||
throw new Error(`Unexpected fetch to ${url}`)
|
||||
}) as typeof fetch
|
||||
|
||||
const callbackRequest = (params: Record<string, string>): NextRequest =>
|
||||
new NextRequest(
|
||||
`http://localhost:3000/campanha/agenda/google-oauth/callback?${new URLSearchParams(params).toString()}`,
|
||||
)
|
||||
|
||||
const rowsWithToken = (token: string) =>
|
||||
payload.find({
|
||||
collection: 'googleCalendarSync',
|
||||
where: { oauthRefreshToken: { equals: token } },
|
||||
depth: 0,
|
||||
overrideAccess: true,
|
||||
})
|
||||
|
||||
describe('C149 — OAuth actions and callback', () => {
|
||||
beforeAll(async () => {
|
||||
payload = await getPayload({ config: await config })
|
||||
})
|
||||
|
||||
afterEach(async () => {
|
||||
restoreEnv()
|
||||
cookieJar.clear()
|
||||
vi.unstubAllGlobals()
|
||||
vi.clearAllMocks()
|
||||
|
||||
// The connection may have landed on a parallel spec's row (the loader
|
||||
// prefers a configured row): clear the fields, never delete that row.
|
||||
const touched = await payload.find({
|
||||
collection: 'googleCalendarSync',
|
||||
where: { oauthRefreshToken: { like: 'c149-refresh%' } },
|
||||
depth: 0,
|
||||
pagination: false,
|
||||
overrideAccess: true,
|
||||
})
|
||||
for (const doc of touched.docs) {
|
||||
await payload.update({
|
||||
collection: 'googleCalendarSync',
|
||||
id: doc.id,
|
||||
data: {
|
||||
oauthRefreshToken: null,
|
||||
oauthScope: null,
|
||||
oauthConnectedAt: null,
|
||||
oauthErrorAt: null,
|
||||
oauthError: null,
|
||||
},
|
||||
depth: 0,
|
||||
overrideAccess: true,
|
||||
})
|
||||
}
|
||||
// Empty rows (no calendar, no token) are only produced by this file's
|
||||
// start attempts — safe to delete.
|
||||
const emptyRows = await payload.find({
|
||||
collection: 'googleCalendarSync',
|
||||
where: {
|
||||
and: [{ calendarId: { exists: false } }, { oauthRefreshToken: { exists: false } }],
|
||||
},
|
||||
depth: 0,
|
||||
pagination: false,
|
||||
overrideAccess: true,
|
||||
})
|
||||
for (const doc of emptyRows.docs) {
|
||||
await payload.delete({ collection: 'googleCalendarSync', id: doc.id, overrideAccess: true })
|
||||
}
|
||||
})
|
||||
|
||||
afterAll(() => {
|
||||
restoreEnv()
|
||||
})
|
||||
|
||||
it('start (coordinator) returns the consent URL and the callback records the connection', async () => {
|
||||
const coordinator = await campaignFixtures().createCampaignUser('coordinator')
|
||||
mockedGetContext.mockResolvedValue({ payload, actor: coordinator })
|
||||
setOAuthClientEnv()
|
||||
|
||||
const start = await startGoogleCalendarOAuth()
|
||||
expect(start.ok).toBe(true)
|
||||
if (!start.ok) throw new Error('start must succeed')
|
||||
|
||||
const authorizeUrl = new URL(start.authorizeUrl)
|
||||
expect(`${authorizeUrl.origin}${authorizeUrl.pathname}`).toBe(
|
||||
'https://accounts.google.com/o/oauth2/v2/auth',
|
||||
)
|
||||
expect(authorizeUrl.searchParams.get('access_type')).toBe('offline')
|
||||
expect(authorizeUrl.searchParams.get('prompt')).toBe('consent')
|
||||
expect(authorizeUrl.searchParams.get('redirect_uri')).toMatch(
|
||||
/\/campanha\/agenda\/google-oauth\/callback$/,
|
||||
)
|
||||
const state = authorizeUrl.searchParams.get('state')
|
||||
expect(state).toBeTruthy()
|
||||
expect(cookieJar.has('campaign-google-oauth-state')).toBe(true)
|
||||
|
||||
const refreshToken = 'c149-refresh-callback-success'
|
||||
vi.stubGlobal('fetch', googleFetchStub(refreshToken))
|
||||
|
||||
const response = await googleOAuthCallback(
|
||||
callbackRequest({ code: 'code-1', state: state as string }),
|
||||
)
|
||||
expect(response.status).toBe(307)
|
||||
expect(response.headers.get('location')).toContain('/campanha/agenda')
|
||||
|
||||
const rows = await rowsWithToken(refreshToken)
|
||||
expect(rows.docs).toHaveLength(1)
|
||||
expect(rows.docs[0]?.oauthConnectedAt).toBeTruthy()
|
||||
// The state cookie is single-use.
|
||||
expect(cookieJar.get('campaign-google-oauth-state')).toBe('')
|
||||
})
|
||||
|
||||
it('start refuses an advisor without writing anything', async () => {
|
||||
const advisor = await campaignFixtures().createCampaignUser('advisor')
|
||||
mockedGetContext.mockResolvedValue({ payload, actor: advisor })
|
||||
setOAuthClientEnv()
|
||||
|
||||
const start = await startGoogleCalendarOAuth()
|
||||
|
||||
expect(start.ok).toBe(false)
|
||||
if (start.ok) throw new Error('start must be refused')
|
||||
expect(start.message).toContain('candidato ou coordenação')
|
||||
expect(cookieJar.has('campaign-google-oauth-state')).toBe(false)
|
||||
})
|
||||
|
||||
it('start fails closed when the OAuth client env is missing', async () => {
|
||||
const coordinator = await campaignFixtures().createCampaignUser('coordinator')
|
||||
mockedGetContext.mockResolvedValue({ payload, actor: coordinator })
|
||||
delete process.env[GOOGLE_CALENDAR_OAUTH_CLIENT_ID_ENV]
|
||||
delete process.env[GOOGLE_CALENDAR_OAUTH_CLIENT_SECRET_ENV]
|
||||
|
||||
const start = await startGoogleCalendarOAuth()
|
||||
|
||||
expect(start.ok).toBe(false)
|
||||
if (start.ok) throw new Error('start must fail')
|
||||
expect(start.message).toContain('não está configurada no servidor')
|
||||
})
|
||||
|
||||
it('callback refuses an invalid state without recording anything', async () => {
|
||||
const coordinator = await campaignFixtures().createCampaignUser('coordinator')
|
||||
mockedGetContext.mockResolvedValue({ payload, actor: coordinator })
|
||||
setOAuthClientEnv()
|
||||
vi.stubGlobal('fetch', googleFetchStub('c149-refresh-should-not-exist'))
|
||||
|
||||
const response = await googleOAuthCallback(
|
||||
callbackRequest({ code: 'code-1', state: 'forged-state' }),
|
||||
)
|
||||
|
||||
expect(response.status).toBe(307)
|
||||
expect((await rowsWithToken('c149-refresh-should-not-exist')).docs).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('callback treats access_denied as a silent redirect', async () => {
|
||||
const response = await googleOAuthCallback(callbackRequest({ error: 'access_denied' }))
|
||||
|
||||
expect(response.status).toBe(307)
|
||||
expect(response.headers.get('location')).toContain('/campanha/agenda')
|
||||
})
|
||||
|
||||
it('disconnect clears the stored connection', async () => {
|
||||
const coordinator = await campaignFixtures().createCampaignUser('coordinator')
|
||||
mockedGetContext.mockResolvedValue({ payload, actor: coordinator })
|
||||
setOAuthClientEnv()
|
||||
|
||||
const refreshToken = 'c149-refresh-disconnect'
|
||||
await recordGoogleCalendarOAuthConnection(payload, { refreshToken, scope: null })
|
||||
expect((await rowsWithToken(refreshToken)).docs).toHaveLength(1)
|
||||
|
||||
const result = await disconnectGoogleCalendarOAuth()
|
||||
|
||||
expect(result.ok).toBe(true)
|
||||
expect((await rowsWithToken(refreshToken)).docs).toHaveLength(0)
|
||||
})
|
||||
})
|
||||
|
|
@ -37,9 +37,15 @@ import {
|
|||
} from '@/utilities/googleCalendarSync'
|
||||
import { hookFilledCreateData } from '@/utilities/hookFilledData'
|
||||
|
||||
import {
|
||||
GOOGLE_CALENDAR_SYNC_LOCK_KEY,
|
||||
serializeSpecWithAdvisoryLock,
|
||||
} from '../helpers/advisoryLock'
|
||||
import { installCampaignFixtures } from '../helpers/campaignFixtures'
|
||||
import { withGoogleCalendarTestCredential } from '../helpers/googleCalendarTestKey'
|
||||
|
||||
serializeSpecWithAdvisoryLock(GOOGLE_CALENDAR_SYNC_LOCK_KEY)
|
||||
|
||||
let payload: Payload
|
||||
const campaignFixtures = installCampaignFixtures({
|
||||
getPayload: () => payload,
|
||||
|
|
|
|||
|
|
@ -10,6 +10,10 @@ import {
|
|||
import config from '@/payload.config'
|
||||
import { loadGoogleCalendarSyncConfig } from '@/utilities/googleCalendarSync'
|
||||
|
||||
import {
|
||||
GOOGLE_CALENDAR_SYNC_LOCK_KEY,
|
||||
serializeSpecWithAdvisoryLock,
|
||||
} from '../helpers/advisoryLock'
|
||||
import { installCampaignFixtures } from '../helpers/campaignFixtures'
|
||||
import { withGoogleCalendarTestCredential } from '../helpers/googleCalendarTestKey'
|
||||
|
||||
|
|
@ -30,6 +34,8 @@ vi.mock('@/utilities/campaignActionContext', async (importOriginal) => {
|
|||
|
||||
import { getCampaignActionContext, reloadStaffActor } from '@/utilities/campaignActionContext'
|
||||
|
||||
serializeSpecWithAdvisoryLock(GOOGLE_CALENDAR_SYNC_LOCK_KEY)
|
||||
|
||||
let payload: Payload
|
||||
const campaignFixtures = installCampaignFixtures({
|
||||
getPayload: () => payload,
|
||||
|
|
|
|||
|
|
@ -1,7 +1,10 @@
|
|||
import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react'
|
||||
import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import type { GoogleCalendarSyncActionResult } from '@/app/(campaign)/campanha/actions/googleCalendarSync'
|
||||
import type {
|
||||
GoogleCalendarOAuthStartResult,
|
||||
GoogleCalendarSyncActionResult,
|
||||
} from '@/app/(campaign)/campanha/actions/googleCalendarSync'
|
||||
import { AgendaGoogleSyncChrome } from '@/components/campaign/activity/AgendaGoogleSyncChrome'
|
||||
import {
|
||||
CampaignPageChromeProvider,
|
||||
|
|
@ -23,6 +26,11 @@ import { stubMatchMedia } from '../helpers/matchMedia'
|
|||
|
||||
const stateFor = (status: GoogleCalendarSyncStatus): GoogleCalendarSyncActionResult => ({
|
||||
ok: true,
|
||||
canManageConnection: true,
|
||||
connection: status === 'not-configured' ? 'not-configured' : 'connected',
|
||||
oauthAvailable: true,
|
||||
oauthConnectedAt: status === 'not-configured' ? null : '2026-08-01T10:00:00.000Z',
|
||||
oauthError: null,
|
||||
status,
|
||||
calendarId: status === 'not-configured' ? null : 'c_campanha@group.calendar.google.com',
|
||||
lastSyncedAt: status === 'synced' || status === 'paused' ? '2026-08-11T10:00:00.000Z' : null,
|
||||
|
|
@ -62,6 +70,13 @@ const renderChrome = ({
|
|||
initialState={stateFor(status)}
|
||||
onSyncNow={onSyncNow}
|
||||
onSetDisabled={vi.fn(async () => stateFor('disabled'))}
|
||||
onStartOAuth={vi.fn(
|
||||
async (): Promise<GoogleCalendarOAuthStartResult> => ({
|
||||
ok: true,
|
||||
authorizeUrl: 'https://accounts.google.com/',
|
||||
}),
|
||||
)}
|
||||
onDisconnect={vi.fn(async () => stateFor('not-configured'))}
|
||||
/>
|
||||
<HeaderActionsProbe />
|
||||
</CampaignQuickActionContextProvider>
|
||||
|
|
|
|||
|
|
@ -417,6 +417,9 @@ describe('src/utilities top-level is pinned', () => {
|
|||
// C114 — Google Calendar mirror: activity/config hooks (separate module so
|
||||
// the engine is mockable at the import boundary in tests).
|
||||
'googleCalendarSyncHooks.ts',
|
||||
// C149 — Google Calendar OAuth: env client pair, signed single-use state
|
||||
// cookie and the authorization-code exchange (server-only).
|
||||
'googleCalendarOAuth.ts',
|
||||
'campaignAuth.ts',
|
||||
'campaignBiometricsPrompt.ts',
|
||||
'campaignColumnVisibilityCookie.ts',
|
||||
|
|
|
|||
|
|
@ -8,12 +8,14 @@ import {
|
|||
createGoogleCalendarClient,
|
||||
GOOGLE_CALENDAR_SCOPE,
|
||||
GoogleCalendarApiError,
|
||||
GoogleCalendarAuthError,
|
||||
type FetchLike,
|
||||
type GoogleCalendarCredentials,
|
||||
type GoogleCalendarAuth,
|
||||
type GoogleCalendarServiceAccountCredentials,
|
||||
} from '@/utilities/googleCalendarClient'
|
||||
|
||||
describe('buildServiceAccountAssertion', () => {
|
||||
let credentials: GoogleCalendarCredentials
|
||||
let credentials: GoogleCalendarServiceAccountCredentials
|
||||
let publicKeyPem: string
|
||||
|
||||
beforeAll(async () => {
|
||||
|
|
@ -38,7 +40,8 @@ describe('buildServiceAccountAssertion', () => {
|
|||
})
|
||||
|
||||
describe('createGoogleCalendarClient', () => {
|
||||
let credentials: GoogleCalendarCredentials
|
||||
let credentials: GoogleCalendarServiceAccountCredentials
|
||||
let serviceAccountAuth: GoogleCalendarAuth
|
||||
|
||||
beforeAll(async () => {
|
||||
// The token endpoint is stubbed, but the client still SIGNS the JWT
|
||||
|
|
@ -48,8 +51,18 @@ describe('createGoogleCalendarClient', () => {
|
|||
clientEmail: 'teqo-sa@projeto.iam.gserviceaccount.com',
|
||||
privateKey: await exportPKCS8(privateKey),
|
||||
}
|
||||
serviceAccountAuth = { kind: 'service-account', credentials }
|
||||
})
|
||||
|
||||
const oauthAuth: GoogleCalendarAuth = {
|
||||
kind: 'oauth',
|
||||
credentials: {
|
||||
clientId: 'client-id.apps.googleusercontent.com',
|
||||
clientSecret: 'client-secret',
|
||||
refreshToken: 'refresh-token',
|
||||
},
|
||||
}
|
||||
|
||||
const calendarId = 'c_abc@group.calendar.google.com'
|
||||
|
||||
/** Minimal fake transport: token endpoint + calendar REST in memory. */
|
||||
|
|
@ -112,7 +125,7 @@ describe('createGoogleCalendarClient', () => {
|
|||
|
||||
it('inserts with the deterministic id, bearer auth and JSON body', async () => {
|
||||
const { fetchImpl, calls, events } = stubTransport()
|
||||
const client = createGoogleCalendarClient(credentials, fetchImpl)
|
||||
const client = createGoogleCalendarClient(serviceAccountAuth, fetchImpl)
|
||||
|
||||
await client.insertEvent(calendarId, { id: 'teqo1a', summary: 'S', description: 'D' })
|
||||
|
||||
|
|
@ -125,7 +138,7 @@ describe('createGoogleCalendarClient', () => {
|
|||
|
||||
it('lists with the window params and pagination', async () => {
|
||||
const { fetchImpl, calls } = stubTransport()
|
||||
const client = createGoogleCalendarClient(credentials, fetchImpl)
|
||||
const client = createGoogleCalendarClient(serviceAccountAuth, fetchImpl)
|
||||
|
||||
await client.listEvents(calendarId, {
|
||||
timeMin: '2026-01-01T00:00:00Z',
|
||||
|
|
@ -158,7 +171,7 @@ describe('createGoogleCalendarClient', () => {
|
|||
})
|
||||
}) as FetchLike
|
||||
|
||||
const client = createGoogleCalendarClient(credentials, fetchImpl)
|
||||
const client = createGoogleCalendarClient(serviceAccountAuth, fetchImpl)
|
||||
const events = await client.listEvents(calendarId, {
|
||||
timeMin: '2026-01-01T00:00:00Z',
|
||||
timeMax: '2026-12-31T00:00:00Z',
|
||||
|
|
@ -177,9 +190,79 @@ describe('createGoogleCalendarClient', () => {
|
|||
return new Response('ok', { status: 200 })
|
||||
}) as FetchLike
|
||||
|
||||
const client = createGoogleCalendarClient(credentials, fetchImpl)
|
||||
const client = createGoogleCalendarClient(serviceAccountAuth, fetchImpl)
|
||||
await expect(
|
||||
client.listEvents(calendarId, { timeMin: 'a', timeMax: 'b' }),
|
||||
).rejects.toBeInstanceOf(GoogleCalendarApiError)
|
||||
})
|
||||
|
||||
it('mints an access token with the OAuth refresh grant (C149)', async () => {
|
||||
const { fetchImpl, calls } = stubTransport()
|
||||
const client = createGoogleCalendarClient(oauthAuth, fetchImpl)
|
||||
|
||||
await client.listEvents(calendarId, { timeMin: 'a', timeMax: 'b' })
|
||||
|
||||
const tokenCall = calls.find((call) => call.url === 'https://oauth2.googleapis.com/token')
|
||||
const params = new URLSearchParams(tokenCall?.body)
|
||||
expect(params.get('grant_type')).toBe('refresh_token')
|
||||
expect(params.get('refresh_token')).toBe('refresh-token')
|
||||
expect(params.get('client_id')).toBe('client-id.apps.googleusercontent.com')
|
||||
expect(params.get('client_secret')).toBe('client-secret')
|
||||
const listCall = calls.find((call) => call.method === 'GET')
|
||||
expect(listCall?.auth).toBe('Bearer token-1')
|
||||
})
|
||||
|
||||
it('maps invalid_grant to GoogleCalendarAuthError (reconnectable)', async () => {
|
||||
const fetchImpl: FetchLike = (async () =>
|
||||
new Response(JSON.stringify({ error: 'invalid_grant' }), {
|
||||
status: 400,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
})) as FetchLike
|
||||
|
||||
const client = createGoogleCalendarClient(oauthAuth, fetchImpl)
|
||||
await expect(
|
||||
client.listEvents(calendarId, { timeMin: 'a', timeMax: 'b' }),
|
||||
).rejects.toBeInstanceOf(GoogleCalendarAuthError)
|
||||
})
|
||||
|
||||
it('keeps invalid_client as an API error (reconnect would not fix it)', async () => {
|
||||
const fetchImpl: FetchLike = (async () =>
|
||||
new Response(JSON.stringify({ error: 'invalid_client' }), {
|
||||
status: 401,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
})) as FetchLike
|
||||
|
||||
const client = createGoogleCalendarClient(oauthAuth, fetchImpl)
|
||||
await expect(
|
||||
client.listEvents(calendarId, { timeMin: 'a', timeMax: 'b' }),
|
||||
).rejects.toBeInstanceOf(GoogleCalendarApiError)
|
||||
})
|
||||
|
||||
it('re-mints the OAuth token once on a 401 and retries the call', async () => {
|
||||
let calendarCalls = 0
|
||||
let tokenRequests = 0
|
||||
const fetchImpl: FetchLike = (async (input: RequestInfo | URL) => {
|
||||
const url = String(input)
|
||||
if (url === 'https://oauth2.googleapis.com/token') {
|
||||
tokenRequests += 1
|
||||
return new Response(
|
||||
JSON.stringify({ access_token: `oauth-token-${tokenRequests}`, expires_in: 3600 }),
|
||||
{ status: 200, headers: { 'Content-Type': 'application/json' } },
|
||||
)
|
||||
}
|
||||
calendarCalls += 1
|
||||
if (calendarCalls === 1) return new Response('unauthorized', { status: 401 })
|
||||
return new Response(JSON.stringify({ items: [] }), {
|
||||
status: 200,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
})
|
||||
}) as FetchLike
|
||||
|
||||
const client = createGoogleCalendarClient(oauthAuth, fetchImpl)
|
||||
const events = await client.listEvents(calendarId, { timeMin: 'a', timeMax: 'b' })
|
||||
|
||||
expect(events).toEqual([])
|
||||
expect(calendarCalls).toBe(2)
|
||||
expect(tokenRequests).toBe(2)
|
||||
})
|
||||
})
|
||||
|
|
|
|||
195
tests/unit/googleCalendarOAuth.unit.spec.ts
Normal file
195
tests/unit/googleCalendarOAuth.unit.spec.ts
Normal file
|
|
@ -0,0 +1,195 @@
|
|||
// @vitest-environment node
|
||||
|
||||
import { afterEach, describe, expect, it } from 'vitest'
|
||||
|
||||
import {
|
||||
buildGoogleCalendarOAuthAuthorizeUrl,
|
||||
GOOGLE_CALENDAR_OAUTH_CALLBACK_PATH,
|
||||
GOOGLE_CALENDAR_OAUTH_SCOPES,
|
||||
} from '@/lib/googleCalendarOAuth'
|
||||
import {
|
||||
exchangeGoogleCalendarOAuthCode,
|
||||
GOOGLE_CALENDAR_OAUTH_CLIENT_ID_ENV,
|
||||
GOOGLE_CALENDAR_OAUTH_CLIENT_SECRET_ENV,
|
||||
GoogleCalendarOAuthError,
|
||||
readGoogleCalendarOAuthCredentials,
|
||||
} from '@/utilities/googleCalendarOAuth'
|
||||
|
||||
const CREDENTIALS = {
|
||||
clientId: 'client-id.apps.googleusercontent.com',
|
||||
clientSecret: 'client-secret',
|
||||
}
|
||||
const REDIRECT_URI = `https://jorgesolla1313.com.br${GOOGLE_CALENDAR_OAUTH_CALLBACK_PATH}`
|
||||
|
||||
describe('buildGoogleCalendarOAuthAuthorizeUrl', () => {
|
||||
it('pins the two literal minimum scopes (C149)', () => {
|
||||
expect([...GOOGLE_CALENDAR_OAUTH_SCOPES]).toEqual([
|
||||
'https://www.googleapis.com/auth/calendar.events',
|
||||
'https://www.googleapis.com/auth/calendar.calendarlist.readonly',
|
||||
])
|
||||
})
|
||||
|
||||
it('builds the consent URL with offline access, forced consent and the state', () => {
|
||||
const url = new URL(
|
||||
buildGoogleCalendarOAuthAuthorizeUrl({
|
||||
clientId: CREDENTIALS.clientId,
|
||||
redirectUri: REDIRECT_URI,
|
||||
state: 'state-123',
|
||||
}),
|
||||
)
|
||||
|
||||
expect(`${url.origin}${url.pathname}`).toBe('https://accounts.google.com/o/oauth2/v2/auth')
|
||||
expect(url.searchParams.get('client_id')).toBe(CREDENTIALS.clientId)
|
||||
expect(url.searchParams.get('redirect_uri')).toBe(REDIRECT_URI)
|
||||
expect(url.searchParams.get('response_type')).toBe('code')
|
||||
expect(url.searchParams.get('scope')).toBe(GOOGLE_CALENDAR_OAUTH_SCOPES.join(' '))
|
||||
expect(url.searchParams.get('access_type')).toBe('offline')
|
||||
expect(url.searchParams.get('prompt')).toBe('consent')
|
||||
expect(url.searchParams.get('state')).toBe('state-123')
|
||||
expect(url.search).not.toContain('client_secret')
|
||||
})
|
||||
})
|
||||
|
||||
describe('readGoogleCalendarOAuthCredentials', () => {
|
||||
const original = {
|
||||
id: process.env[GOOGLE_CALENDAR_OAUTH_CLIENT_ID_ENV],
|
||||
secret: process.env[GOOGLE_CALENDAR_OAUTH_CLIENT_SECRET_ENV],
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
if (original.id === undefined) delete process.env[GOOGLE_CALENDAR_OAUTH_CLIENT_ID_ENV]
|
||||
else process.env[GOOGLE_CALENDAR_OAUTH_CLIENT_ID_ENV] = original.id
|
||||
if (original.secret === undefined) delete process.env[GOOGLE_CALENDAR_OAUTH_CLIENT_SECRET_ENV]
|
||||
else process.env[GOOGLE_CALENDAR_OAUTH_CLIENT_SECRET_ENV] = original.secret
|
||||
})
|
||||
|
||||
it('fails closed (null) when either side is missing', () => {
|
||||
delete process.env[GOOGLE_CALENDAR_OAUTH_CLIENT_ID_ENV]
|
||||
delete process.env[GOOGLE_CALENDAR_OAUTH_CLIENT_SECRET_ENV]
|
||||
expect(readGoogleCalendarOAuthCredentials()).toBeNull()
|
||||
|
||||
process.env[GOOGLE_CALENDAR_OAUTH_CLIENT_ID_ENV] = 'client-id'
|
||||
expect(readGoogleCalendarOAuthCredentials()).toBeNull()
|
||||
|
||||
delete process.env[GOOGLE_CALENDAR_OAUTH_CLIENT_ID_ENV]
|
||||
process.env[GOOGLE_CALENDAR_OAUTH_CLIENT_SECRET_ENV] = 'client-secret'
|
||||
expect(readGoogleCalendarOAuthCredentials()).toBeNull()
|
||||
})
|
||||
|
||||
it('returns the pair when both sides are set', () => {
|
||||
process.env[GOOGLE_CALENDAR_OAUTH_CLIENT_ID_ENV] = 'client-id'
|
||||
process.env[GOOGLE_CALENDAR_OAUTH_CLIENT_SECRET_ENV] = 'client-secret'
|
||||
expect(readGoogleCalendarOAuthCredentials()).toEqual({
|
||||
clientId: 'client-id',
|
||||
clientSecret: 'client-secret',
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('exchangeGoogleCalendarOAuthCode', () => {
|
||||
it('posts the authorization code and returns the refresh token + scope', async () => {
|
||||
const calls: Array<{ url: string; body?: string }> = []
|
||||
const fetchImpl = (async (input: RequestInfo | URL, init?: RequestInit) => {
|
||||
calls.push({ url: String(input), body: typeof init?.body === 'string' ? init.body : '' })
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
access_token: 'access-token',
|
||||
refresh_token: 'refresh-token',
|
||||
scope: GOOGLE_CALENDAR_OAUTH_SCOPES.join(' '),
|
||||
}),
|
||||
{ status: 200, headers: { 'Content-Type': 'application/json' } },
|
||||
)
|
||||
}) as typeof fetch
|
||||
|
||||
const tokens = await exchangeGoogleCalendarOAuthCode({
|
||||
code: 'code-123',
|
||||
redirectUri: REDIRECT_URI,
|
||||
credentials: CREDENTIALS,
|
||||
fetchImpl,
|
||||
})
|
||||
|
||||
expect(tokens).toEqual({
|
||||
refreshToken: 'refresh-token',
|
||||
scope: GOOGLE_CALENDAR_OAUTH_SCOPES.join(' '),
|
||||
})
|
||||
const params = new URLSearchParams(calls[0]?.body)
|
||||
expect(calls[0]?.url).toBe('https://oauth2.googleapis.com/token')
|
||||
expect(params.get('grant_type')).toBe('authorization_code')
|
||||
expect(params.get('code')).toBe('code-123')
|
||||
expect(params.get('redirect_uri')).toBe(REDIRECT_URI)
|
||||
expect(params.get('client_id')).toBe(CREDENTIALS.clientId)
|
||||
expect(params.get('client_secret')).toBe(CREDENTIALS.clientSecret)
|
||||
})
|
||||
|
||||
it('maps a non-OK response to a safe error without echoing the body', async () => {
|
||||
const fetchImpl = (async () =>
|
||||
new Response(JSON.stringify({ error: 'invalid_grant', secret: 'leak-me' }), {
|
||||
status: 400,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
})) as typeof fetch
|
||||
|
||||
const error = await exchangeGoogleCalendarOAuthCode({
|
||||
code: 'code-123',
|
||||
redirectUri: REDIRECT_URI,
|
||||
credentials: CREDENTIALS,
|
||||
fetchImpl,
|
||||
}).catch((caught: unknown) => caught)
|
||||
|
||||
expect(error).toBeInstanceOf(GoogleCalendarOAuthError)
|
||||
expect((error as Error).message).not.toContain('leak-me')
|
||||
expect((error as Error).message).not.toContain('invalid_grant')
|
||||
})
|
||||
|
||||
it('rejects a partial grant that misses a required scope', async () => {
|
||||
const fetchImpl = (async () =>
|
||||
new Response(
|
||||
JSON.stringify({
|
||||
access_token: 'access-token',
|
||||
refresh_token: 'refresh-token',
|
||||
scope: 'https://www.googleapis.com/auth/calendar.events',
|
||||
}),
|
||||
{ status: 200, headers: { 'Content-Type': 'application/json' } },
|
||||
)) as typeof fetch
|
||||
|
||||
await expect(
|
||||
exchangeGoogleCalendarOAuthCode({
|
||||
code: 'code-123',
|
||||
redirectUri: REDIRECT_URI,
|
||||
credentials: CREDENTIALS,
|
||||
fetchImpl,
|
||||
}),
|
||||
).rejects.toBeInstanceOf(GoogleCalendarOAuthError)
|
||||
})
|
||||
|
||||
it('fails safely when Google does not return a refresh token', async () => {
|
||||
const fetchImpl = (async () =>
|
||||
new Response(JSON.stringify({ access_token: 'access-token' }), {
|
||||
status: 200,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
})) as typeof fetch
|
||||
|
||||
await expect(
|
||||
exchangeGoogleCalendarOAuthCode({
|
||||
code: 'code-123',
|
||||
redirectUri: REDIRECT_URI,
|
||||
credentials: CREDENTIALS,
|
||||
fetchImpl,
|
||||
}),
|
||||
).rejects.toBeInstanceOf(GoogleCalendarOAuthError)
|
||||
})
|
||||
|
||||
it('maps a network failure to a safe error', async () => {
|
||||
const fetchImpl = (async () => {
|
||||
throw new Error('socket hang up')
|
||||
}) as typeof fetch
|
||||
|
||||
await expect(
|
||||
exchangeGoogleCalendarOAuthCode({
|
||||
code: 'code-123',
|
||||
redirectUri: REDIRECT_URI,
|
||||
credentials: CREDENTIALS,
|
||||
fetchImpl,
|
||||
}),
|
||||
).rejects.toBeInstanceOf(GoogleCalendarOAuthError)
|
||||
})
|
||||
})
|
||||
|
|
@ -2,11 +2,22 @@ import { afterEach, describe, expect, it } from 'vitest'
|
|||
|
||||
import type { Activity, GoogleCalendarSync as GoogleCalendarSyncDoc } from '@/payload-types'
|
||||
|
||||
import { REQUEST_TIMEOUT_MS } from '@/utilities/googleCalendarClient'
|
||||
import {
|
||||
GoogleCalendarApiError,
|
||||
GoogleCalendarAuthError,
|
||||
REQUEST_TIMEOUT_MS,
|
||||
} from '@/utilities/googleCalendarClient'
|
||||
import {
|
||||
GOOGLE_CALENDAR_OAUTH_CLIENT_ID_ENV,
|
||||
GOOGLE_CALENDAR_OAUTH_CLIENT_SECRET_ENV,
|
||||
} from '@/utilities/googleCalendarOAuth'
|
||||
import {
|
||||
deriveGoogleCalendarConnectionStatus,
|
||||
deriveGoogleCalendarSyncStatus,
|
||||
GOOGLE_CALENDAR_SERVICE_ACCOUNT_KEY_ENV,
|
||||
GOOGLE_CALENDAR_SYNC_HOOK_TIMEOUT_MS,
|
||||
oauthErrorPatchFor,
|
||||
readGoogleCalendarAuth,
|
||||
readGoogleServiceAccountCredentials,
|
||||
shouldSyncActivityOperation,
|
||||
shouldSyncConfigChange,
|
||||
|
|
@ -61,6 +72,71 @@ describe('deriveGoogleCalendarSyncStatus', () => {
|
|||
})
|
||||
})
|
||||
|
||||
describe('deriveGoogleCalendarConnectionStatus (C149)', () => {
|
||||
it('is not-configured without a refresh token and without an error', () => {
|
||||
expect(deriveGoogleCalendarConnectionStatus({ hasRefreshToken: false })).toBe('not-configured')
|
||||
})
|
||||
|
||||
it('is connected with a refresh token and no newer error', () => {
|
||||
expect(
|
||||
deriveGoogleCalendarConnectionStatus({
|
||||
hasRefreshToken: true,
|
||||
oauthConnectedAt: '2026-08-11T10:00:00.000Z',
|
||||
}),
|
||||
).toBe('connected')
|
||||
// An error recorded BEFORE the last connection is stale — connected wins.
|
||||
expect(
|
||||
deriveGoogleCalendarConnectionStatus({
|
||||
hasRefreshToken: true,
|
||||
oauthConnectedAt: '2026-08-11T12:00:00.000Z',
|
||||
oauthErrorAt: '2026-08-11T10:00:00.000Z',
|
||||
}),
|
||||
).toBe('connected')
|
||||
})
|
||||
|
||||
it('is error when the auth error is newer than the connection (or there is none)', () => {
|
||||
expect(
|
||||
deriveGoogleCalendarConnectionStatus({
|
||||
hasRefreshToken: true,
|
||||
oauthConnectedAt: '2026-08-11T10:00:00.000Z',
|
||||
oauthErrorAt: '2026-08-11T12:00:00.000Z',
|
||||
}),
|
||||
).toBe('error')
|
||||
// A failed handshake before the first connection has no connectedAt.
|
||||
expect(
|
||||
deriveGoogleCalendarConnectionStatus({
|
||||
hasRefreshToken: false,
|
||||
oauthErrorAt: '2026-08-11T12:00:00.000Z',
|
||||
}),
|
||||
).toBe('error')
|
||||
})
|
||||
})
|
||||
|
||||
describe('oauthErrorPatchFor (C149)', () => {
|
||||
it('maps a dead OAuth connection to the connection error fields', () => {
|
||||
expect(
|
||||
oauthErrorPatchFor(
|
||||
new GoogleCalendarAuthError('A conexão com o Google expirou. Reconecte a conta.'),
|
||||
'2026-09-12T10:00:00.000Z',
|
||||
),
|
||||
).toEqual({
|
||||
oauthErrorAt: '2026-09-12T10:00:00.000Z',
|
||||
oauthError: 'A conexão com o Google expirou. Reconecte a conta.',
|
||||
})
|
||||
})
|
||||
|
||||
it('leaves every other failure out of the connection state', () => {
|
||||
expect(oauthErrorPatchFor(new Error('HTTP 500'), 'at')).toBeNull()
|
||||
expect(oauthErrorPatchFor(new GoogleCalendarApiError('HTTP 403', 403), 'at')).toBeNull()
|
||||
expect(oauthErrorPatchFor('not-an-error', 'at')).toBeNull()
|
||||
})
|
||||
|
||||
it('truncates the message to the stored 500-char limit', () => {
|
||||
const patch = oauthErrorPatchFor(new GoogleCalendarAuthError('x'.repeat(900)), 'at')
|
||||
expect(patch?.oauthError).toHaveLength(500)
|
||||
})
|
||||
})
|
||||
|
||||
describe('readGoogleServiceAccountCredentials', () => {
|
||||
const originalKey = process.env[GOOGLE_CALENDAR_SERVICE_ACCOUNT_KEY_ENV]
|
||||
|
||||
|
|
@ -103,6 +179,86 @@ describe('readGoogleServiceAccountCredentials', () => {
|
|||
})
|
||||
})
|
||||
|
||||
describe('readGoogleCalendarAuth (C149)', () => {
|
||||
const original = {
|
||||
sa: process.env[GOOGLE_CALENDAR_SERVICE_ACCOUNT_KEY_ENV],
|
||||
id: process.env[GOOGLE_CALENDAR_OAUTH_CLIENT_ID_ENV],
|
||||
secret: process.env[GOOGLE_CALENDAR_OAUTH_CLIENT_SECRET_ENV],
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
const restore = (key: string, value: string | undefined) => {
|
||||
if (value === undefined) delete process.env[key]
|
||||
else process.env[key] = value
|
||||
}
|
||||
restore(GOOGLE_CALENDAR_SERVICE_ACCOUNT_KEY_ENV, original.sa)
|
||||
restore(GOOGLE_CALENDAR_OAUTH_CLIENT_ID_ENV, original.id)
|
||||
restore(GOOGLE_CALENDAR_OAUTH_CLIENT_SECRET_ENV, original.secret)
|
||||
})
|
||||
|
||||
const setServiceAccountEnv = () => {
|
||||
process.env[GOOGLE_CALENDAR_SERVICE_ACCOUNT_KEY_ENV] = Buffer.from(
|
||||
JSON.stringify({
|
||||
client_email: 'teqo-sa@projeto.iam.gserviceaccount.com',
|
||||
private_key: '-----BEGIN PRIVATE KEY-----\nMOCK\n-----END PRIVATE KEY-----\n',
|
||||
}),
|
||||
'utf8',
|
||||
).toString('base64')
|
||||
}
|
||||
|
||||
const setOAuthClientEnv = () => {
|
||||
process.env[GOOGLE_CALENDAR_OAUTH_CLIENT_ID_ENV] = 'client-id'
|
||||
process.env[GOOGLE_CALENDAR_OAUTH_CLIENT_SECRET_ENV] = 'client-secret'
|
||||
}
|
||||
|
||||
const doc = (overrides: Record<string, unknown> = {}) =>
|
||||
({ id: 1, ...overrides }) as unknown as GoogleCalendarSyncDoc
|
||||
|
||||
it('prefers the OAuth connection over the service account', () => {
|
||||
setServiceAccountEnv()
|
||||
setOAuthClientEnv()
|
||||
|
||||
const auth = readGoogleCalendarAuth(doc({ oauthRefreshToken: 'refresh-token' }))
|
||||
|
||||
expect(auth).toEqual({
|
||||
kind: 'oauth',
|
||||
credentials: {
|
||||
clientId: 'client-id',
|
||||
clientSecret: 'client-secret',
|
||||
refreshToken: 'refresh-token',
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
it('falls back to the service account without a refresh token', () => {
|
||||
setServiceAccountEnv()
|
||||
setOAuthClientEnv()
|
||||
|
||||
const auth = readGoogleCalendarAuth(doc())
|
||||
|
||||
expect(auth?.kind).toBe('service-account')
|
||||
})
|
||||
|
||||
it('falls back to the service account when the OAuth client env is missing', () => {
|
||||
setServiceAccountEnv()
|
||||
delete process.env[GOOGLE_CALENDAR_OAUTH_CLIENT_ID_ENV]
|
||||
delete process.env[GOOGLE_CALENDAR_OAUTH_CLIENT_SECRET_ENV]
|
||||
|
||||
expect(readGoogleCalendarAuth(doc({ oauthRefreshToken: 'refresh-token' }))?.kind).toBe(
|
||||
'service-account',
|
||||
)
|
||||
})
|
||||
|
||||
it('is null (fail-closed) without any credential', () => {
|
||||
delete process.env[GOOGLE_CALENDAR_SERVICE_ACCOUNT_KEY_ENV]
|
||||
delete process.env[GOOGLE_CALENDAR_OAUTH_CLIENT_ID_ENV]
|
||||
delete process.env[GOOGLE_CALENDAR_OAUTH_CLIENT_SECRET_ENV]
|
||||
|
||||
expect(readGoogleCalendarAuth(doc({ oauthRefreshToken: 'refresh-token' }))).toBeNull()
|
||||
expect(readGoogleCalendarAuth(null)).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('shouldSyncActivityOperation', () => {
|
||||
const activity = (overrides: Record<string, unknown> = {}) =>
|
||||
({ id: 1, title: 'Caminhada', status: 'confirmado', ...overrides }) as unknown as Activity
|
||||
|
|
@ -239,6 +395,20 @@ describe('shouldSyncConfigChange', () => {
|
|||
}),
|
||||
).toBe(true)
|
||||
})
|
||||
|
||||
it('skips OAuth-connection writes — the callback runs its own pass (C149)', () => {
|
||||
expect(
|
||||
shouldSyncConfigChange({
|
||||
operation: 'update',
|
||||
doc: config({
|
||||
oauthRefreshToken: 'refresh-token',
|
||||
oauthConnectedAt: 'x',
|
||||
oauthError: null,
|
||||
}),
|
||||
previousDoc: config({ oauthRefreshToken: null }),
|
||||
}),
|
||||
).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('sync deadlines (C114-LOCK)', () => {
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import { cleanup, render, screen, within } from '@testing-library/react'
|
||||
import { cleanup, fireEvent, render, screen, waitFor, within } from '@testing-library/react'
|
||||
import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import type { GoogleCalendarSyncActionResult } from '@/app/(campaign)/campanha/actions/googleCalendarSync'
|
||||
|
|
@ -9,6 +9,11 @@ const syncedState = (
|
|||
overrides: Partial<GoogleCalendarSyncActionResult> = {},
|
||||
): GoogleCalendarSyncActionResult => ({
|
||||
ok: true,
|
||||
canManageConnection: true,
|
||||
connection: 'connected',
|
||||
oauthAvailable: true,
|
||||
oauthConnectedAt: '2026-08-01T12:00:00.000Z',
|
||||
oauthError: null,
|
||||
status: 'synced',
|
||||
calendarId: 'c_campanha@group.calendar.google.com',
|
||||
lastSyncedAt: '2026-08-11T12:00:00.000Z',
|
||||
|
|
@ -21,8 +26,15 @@ const syncedState = (
|
|||
...overrides,
|
||||
})
|
||||
|
||||
const notConfiguredState = (): GoogleCalendarSyncActionResult => ({
|
||||
const notConfiguredState = (
|
||||
overrides: Partial<GoogleCalendarSyncActionResult> = {},
|
||||
): GoogleCalendarSyncActionResult => ({
|
||||
ok: true,
|
||||
canManageConnection: true,
|
||||
connection: 'not-configured',
|
||||
oauthAvailable: true,
|
||||
oauthConnectedAt: null,
|
||||
oauthError: null,
|
||||
status: 'not-configured',
|
||||
calendarId: null,
|
||||
lastSyncedAt: null,
|
||||
|
|
@ -32,9 +44,18 @@ const notConfiguredState = (): GoogleCalendarSyncActionResult => ({
|
|||
pushChannelExpiresAt: null,
|
||||
pushChannelError: null,
|
||||
addLink: null,
|
||||
...overrides,
|
||||
})
|
||||
|
||||
const renderDialog = (state: GoogleCalendarSyncActionResult) =>
|
||||
const renderDialog = (
|
||||
state: GoogleCalendarSyncActionResult,
|
||||
overrides: {
|
||||
onStartOAuth?: () => Promise<
|
||||
{ ok: true; authorizeUrl: string } | { ok: false; message: string }
|
||||
>
|
||||
onDisconnect?: () => Promise<GoogleCalendarSyncActionResult>
|
||||
} = {},
|
||||
) =>
|
||||
render(
|
||||
<GoogleCalendarSyncDialog
|
||||
open
|
||||
|
|
@ -42,6 +63,8 @@ const renderDialog = (state: GoogleCalendarSyncActionResult) =>
|
|||
state={state}
|
||||
onSyncNow={vi.fn()}
|
||||
onSetDisabled={vi.fn()}
|
||||
onStartOAuth={overrides.onStartOAuth ?? vi.fn()}
|
||||
onDisconnect={overrides.onDisconnect ?? vi.fn()}
|
||||
/>,
|
||||
)
|
||||
|
||||
|
|
@ -129,6 +152,51 @@ describe('GoogleCalendarSyncDialog — encaixe desktop (C148)', () => {
|
|||
})
|
||||
})
|
||||
|
||||
describe('GoogleCalendarSyncDialog — card de conexão OAuth (C149)', () => {
|
||||
it('offers the connect button when the OAuth client is configured', () => {
|
||||
renderDialog(notConfiguredState())
|
||||
const dialog = screen.getByRole('dialog', { name: /Agenda da Campanha no Google/ })
|
||||
expect(within(dialog).getByText('Não configurado')).toBeTruthy()
|
||||
expect(within(dialog).getByRole('button', { name: /Conectar com o Google/ })).toBeTruthy()
|
||||
// The service account remains documented as the technical fallback.
|
||||
expect(within(dialog).getByText(/service account continua disponível/)).toBeTruthy()
|
||||
})
|
||||
|
||||
it('shows the handshake error with the one-click reconnect path', () => {
|
||||
renderDialog(
|
||||
syncedState({
|
||||
connection: 'error',
|
||||
oauthError: 'A conexão com o Google expirou ou foi revogada.',
|
||||
}),
|
||||
)
|
||||
const dialog = screen.getByRole('dialog', { name: /Agenda da Campanha no Google/ })
|
||||
expect(within(dialog).getByText('Erro')).toBeTruthy()
|
||||
expect(within(dialog).getByText(/expirou ou foi revogada/)).toBeTruthy()
|
||||
expect(within(dialog).getByRole('button', { name: /Reconectar com o Google/ })).toBeTruthy()
|
||||
})
|
||||
|
||||
it('disconnects from the card when connected', async () => {
|
||||
const onDisconnect = vi.fn(async () => notConfiguredState())
|
||||
renderDialog(syncedState(), { onDisconnect })
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Desconectar' }))
|
||||
|
||||
await waitFor(() => expect(onDisconnect).toHaveBeenCalledTimes(1))
|
||||
})
|
||||
|
||||
it('hides the connect/disconnect actions from non-managers (advisor)', () => {
|
||||
renderDialog(notConfiguredState({ canManageConnection: false }))
|
||||
expect(screen.queryByRole('button', { name: /Conectar com o Google/ })).toBeNull()
|
||||
expect(screen.getByText(/Somente candidato ou coordenação/)).toBeTruthy()
|
||||
})
|
||||
|
||||
it('falls back to the admin runbook when the server has no OAuth client', () => {
|
||||
renderDialog(notConfiguredState({ oauthAvailable: false }))
|
||||
expect(screen.getByText(/service account do Teqo/)).toBeTruthy()
|
||||
expect(screen.queryByRole('button', { name: /Conectar com o Google/ })).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('ActivityUpdateFeed — atribuição do registro reverso (C115)', () => {
|
||||
const renderFeed = (updates: Parameters<typeof ActivityUpdateFeed>[0]['updates']) =>
|
||||
render(<ActivityUpdateFeed updates={updates} />)
|
||||
|
|
|
|||
Loading…
Reference in a new issue