From 10b58afe4c118e587e3c0da05bda84e55a040526 Mon Sep 17 00:00:00 2001 From: Francisco Solla Date: Thu, 17 Sep 2026 01:54:42 -0300 Subject: [PATCH] chore(OPS125): conta de teste dos agentes no staging (Closes #1126) --- .agents/skills/work-issue/SKILL.md | 6 +- .../skills/work-issue/execution-pipeline.md | 8 + docs/changelog/2026-09-17-ops125.md | 1 + docs/ops/teqo-1313-deploy.md | 29 ++- ...125-conta-de-teste-agentes-staging-impl.md | 139 ++++++++++++++ package.json | 1 + scripts/bootstrap-staging-test-account.mjs | 62 +++++++ scripts/db-reset.mjs | 20 +- scripts/lib/cli.mjs | 19 ++ scripts/lib/staging-test-account.mjs | 171 ++++++++++++++++++ scripts/lib/test-affected-core.mjs | 1 + tests/helpers/assertTestDatabase.ts | 9 +- .../campaignStagingTestAccount.int.spec.ts | 93 ++++++++++ tests/unit/cliEnvFlags.unit.spec.ts | 13 ++ tests/unit/stagingTestAccount.unit.spec.ts | 147 +++++++++++++++ 15 files changed, 705 insertions(+), 14 deletions(-) create mode 100644 docs/changelog/2026-09-17-ops125.md create mode 100644 docs/plans/ops125-conta-de-teste-agentes-staging-impl.md create mode 100644 scripts/bootstrap-staging-test-account.mjs create mode 100644 scripts/lib/staging-test-account.mjs create mode 100644 tests/int/campaignStagingTestAccount.int.spec.ts create mode 100644 tests/unit/stagingTestAccount.unit.spec.ts diff --git a/.agents/skills/work-issue/SKILL.md b/.agents/skills/work-issue/SKILL.md index 9d896c1f..d940ccfb 100644 --- a/.agents/skills/work-issue/SKILL.md +++ b/.agents/skills/work-issue/SKILL.md @@ -190,7 +190,11 @@ Você (humano, com browser) é o ator que **executa** a §Verificação pós-dep fica só o delta: espera o run de `deploy.yml`/job `deploy-staging`, abre o staging e exerce a funcionalidade recém-entregue; defeito no escopo vira Issue claimável, observação vira `file-miss`, a Issue original (`done`) só recebe comentário de -link. Em timeout, reporta e para. Produção segue 100% humana. +link. Em timeout, reporta e para. Produção segue 100% humana. A credencial de +login é a conta de teste do OPS125 (`agente-teste@teqo.invalid`, papel +`coordinator`; senha em `~/stack/teqo-staging.env` → +`STAGING_TEST_ACCOUNT_PASSWORD`), fornecida pelo humano no run — nunca commitada +nem colada no PR/Issue (só o desfecho). ## Resumo final diff --git a/.agents/skills/work-issue/execution-pipeline.md b/.agents/skills/work-issue/execution-pipeline.md index 9a15f292..c944ee7f 100644 --- a/.agents/skills/work-issue/execution-pipeline.md +++ b/.agents/skills/work-issue/execution-pipeline.md @@ -188,6 +188,14 @@ primitivos) em prosa; promover a helper testado só se virar necessidade reutili da Issue + do `*-impl.md`. Não é regressão da aplicação inteira. - **Ambiente:** `https://staging.jorgesolla1313.com.br` (noindex; identidades em `docs/ops/teqo-1313-deploy.md:132-138`). +- **Credencial de login (OPS125):** a conta de teste é `agente-teste@teqo.invalid` + (papel `coordinator`); a senha vive em `~/stack/teqo-staging.env` na var + `STAGING_TEST_ACCOUNT_PASSWORD` (chmod 600, nunca commitada) e é **fornecida + pelo humano que opera o homeserver no momento do teste**. O agente só faz login + na aplicação: nunca lê o env file, nunca recebe `DATABASE_URL`/`ALLOW_REMOTE_DB` + e nunca cola a credencial no PR/Issue (só o desfecho). Criação/re-uso (após + recriar o banco de staging): `STAGING_TEST_ACCOUNT_CONFIRM=1 pnpm + campaign:staging:test-account` no homeserver — runbook §Staging. - **Viewports 390 e 1280**; **console sem erros** (warnings investigados) e **network** (status/erros) observados; screenshots viram evidência do desfecho. - **Ferramenta:** o browser automation da sessão (Playwright no ambiente do diff --git a/docs/changelog/2026-09-17-ops125.md b/docs/changelog/2026-09-17-ops125.md new file mode 100644 index 00000000..fe459594 --- /dev/null +++ b/docs/changelog/2026-09-17-ops125.md @@ -0,0 +1 @@ +**OPS125 — conta de teste dos agentes no staging (2026-09-17):** destrava o passo de verificação pós-deploy do `work-issue` (OPS121), que morria na porta de `/campanha/login` porque as credenciais do seed mínimo nunca existem em staging (o `teqo_staging` é cópia da produção, não um alvo de `db:seed:minimal`). Novo script de bootstrap `scripts/bootstrap-staging-test-account.mjs` (`pnpm campaign:staging:test-account`, idempotente por email) cria/atualiza uma conta `campaignUser` `coordinator` sintética (`Agente de Teste (staging)` / `agente-teste@teqo.invalid`) restrita a staging; o conhecimento vive em `scripts/lib/staging-test-account.mjs`, com guard fail-closed `assertStagingTestAccountTarget`: exige nome de banco exatamente `teqo_staging` (o discriminador honesto — o proxy socat reescreve o host para `127.0.0.1` e o env file põe `NODE_ENV=production`, então o host sozinho não distingue staging de produção), recusa `ALLOW_REMOTE_DB`, recusa `TEQO_ENV` ≠ `staging` e exige `STAGING_TEST_ACCOUNT_CONFIRM=1` sob `requiresWriteConfirm`. A senha nunca é commitada: vem de `STAGING_TEST_ACCOUNT_PASSWORD` no `~/stack/teqo-staging.env` (chmod 600) e é fornecida ao agente pelo humano **no momento do teste** — o agente só faz login na aplicação, nunca recebe `DATABASE_URL` de staging/prod nem cola credencial no PR/Issue. O helper `databaseName(url)` vai para `scripts/lib/cli.mjs` (single-sourcing do spelling de decode que `db-reset.mjs` e `assertTestDatabase.ts` re-escreviam). Docs: §Staging do `docs/ops/teqo-1313-deploy.md` (var no env file + comando de bootstrap), §Verificação pós-deploy do `execution-pipeline.md` e Passo 8 do `work-issue/SKILL.md` (fonte da credencial). Testes: unit do guard/identidade + matriz de recusa e do `databaseName`; int do upsert idempotente + `payload.login` por email (papel `coordinator`, `contact` vinculado). Sem migration, sem UI, `deploy-homeserver.sh` intocado. Issue #1126. diff --git a/docs/ops/teqo-1313-deploy.md b/docs/ops/teqo-1313-deploy.md index a44a2cea..0294e043 100644 --- a/docs/ops/teqo-1313-deploy.md +++ b/docs/ops/teqo-1313-deploy.md @@ -146,7 +146,9 @@ importa é a de `production`). 2. **Env file:** `~/stack/teqo-staging.env` (chmod 600) com `DATABASE_URL` apontando para `teqo_staging`, `PAYLOAD_SECRET` próprio, `NEXT_PUBLIC_SITE_URL=https://staging.jorgesolla1313.com.br`, - `REVALIDATE_SECRET` próprio e `S3_*` do bucket `teqo-media-staging`. + `REVALIDATE_SECRET` próprio, `S3_*` do bucket `teqo-media-staging` e + `STAGING_TEST_ACCOUNT_PASSWORD` (senha sintética da conta de teste dos + agentes — OPS125; nunca commitada). 3. **Compose:** adicionar `teqo-staging` e `teqo-staging-migrate` ao `~/stack/docker-compose.yml`, espelhando os serviços de produção (`pull_policy: never`, label `org.opencontainers.image.revision` **por @@ -190,6 +192,31 @@ noindex` no ingress — o noindex versionado (robots.ts + metadata, OPS103) Nunca copiar dados de produção (PII/LGPD): o seed mínimo é sintético. +8. **Conta de teste dos agentes (OPS125):** criar/atualizar a conta + `coordinator` sintética que a verificação pós-deploy do `work-issue` (OPS121) + usa para logar. Com o env de staging exportado e o `DATABASE_URL` reescrito + para o proxy do build (`127.0.0.1:5434`): + + ```bash + ssh homeserver + cd ~/teqo-deploy && pnpm install + set -a; source ~/stack/teqo-staging.env; set +a + export DATABASE_URL="${DATABASE_URL/@postgres:5432/@127.0.0.1:5434}" + STAGING_TEST_ACCOUNT_CONFIRM=1 pnpm campaign:staging:test-account + ``` + + Idempotente (upsert por email) — re-rodar após recriar o banco de staging. + O guard recusa qualquer alvo que não seja `teqo_staging`: `ALLOW_REMOTE_DB`, + `TEQO_ENV` ≠ `staging` e host fora do allowlist local falham fechado (o nome + do banco é o discriminador honesto — o host é reescrito para `127.0.0.1` pelo + proxy). A credencial (`agente-teste@teqo.invalid` + a senha em + `STAGING_TEST_ACCOUNT_PASSWORD`) é fornecida ao agente **no momento do teste + de verificação**, pelo humano; nunca entra no PR/Issue nem no env do agente, + e o agente nunca recebe `DATABASE_URL` de staging/produção. + Nota: o `teqo_staging` deste homeserver é **cópia da produção** (com PII + real) — a linha "o seed mínimo é sintético" do passo 7 acima está + desatualizada; a correção dos docs é débito do OPS103, dono do ambiente. + ### Limites do staging (o que ele NÃO valida) - e2e automatizado (o full continua no `verify`, que valida o commit); diff --git a/docs/plans/ops125-conta-de-teste-agentes-staging-impl.md b/docs/plans/ops125-conta-de-teste-agentes-staging-impl.md new file mode 100644 index 00000000..e8323a50 --- /dev/null +++ b/docs/plans/ops125-conta-de-teste-agentes-staging-impl.md @@ -0,0 +1,139 @@ +# Impl: Conta de teste dos agentes no ambiente de staging + +Status: aprovado +Atualizado em: 2026-09-17 +Issue: #1126 +Intenção: docs/plans/ops125-conta-de-teste-agentes-staging.md +Appetite restante: herdado (herdado: ~0,5 dia) + +## Leitura da intenção + +- **Outcome:** depois do merge, o agente de `work-issue` consegue logar em `https://staging.jorgesolla1313.com.br` com uma conta sintética exclusiva de staging e exercer a feature recém-entregue; a credencial não é commitada, o agente não recebe `DATABASE_URL` de staging/produção e nenhum dado real é exportado. +- **O que NÃO negociar:** (1) credencial nunca versionada nem GitHub secret (staging é cópia da produção, com PII real); (2) criação idempotente e **restrita a staging** — nunca produção, nunca banco local; (3) agente nunca recebe `DATABASE_URL` de staging/prod nem `ALLOW_REMOTE_DB`, acesso só por login; (4) sem segundo mecanismo de autenticação, sem conta por papel/feature, sem e2e de login contra staging, sem UI; (5) papel `coordinator` e identidade `Agente de Teste (staging)` / `agente-teste@teqo.invalid` são **assumidos** (validar com produto); (6) não tocar dados de terceiros nem alterar a cópia real. +- **O que reavaliar:** a hipótese "reusar o seed mínimo" já foi descartada na intenção (guard local-only + pins colidem com a cópia real); a "Direção no codebase" acerta o precedente (`scripts/seed-minimal.mjs`/manifest) e a superfície de login (`auth.ts`), mas o mecanismo de guard não existe — `assertLocalDatabase` sozinho **não** distingue staging de produção (proxy socat reescreve o host para `127.0.0.1`; `NODE_ENV=production` no env file). O discriminador confiável é o **nome do banco** (`teqo_staging`). + +## Abordagem recomendada + +```mermaid +flowchart LR + H["Humano · bootstrap do homeserver
STAGING_TEST_ACCOUNT_CONFIRM=1
STAGING_TEST_ACCOUNT_PASSWORD"] --> S["scripts/bootstrap-staging-test-account.mjs"] + S --> G{"guard: databaseName == teqo_staging?
sem ALLOW_REMOTE_DB · TEQO_ENV≠production"} + G -- "não" --> X["fecha fechado — nada é escrito"] + G -- "sim" --> U["upsert campaignUser por email
(lib/staging-test-account.mjs)"] + U -- "create" --> C["hook ensureCampaignUserContactIdentity
cria Contact sintético único"] + U -- "update" --> P["sincroniza name/role/password
não toca contact nem terceiros"] + U --> DB[("teqo_staging · cópia de prod")] + A["Agente work-issue (OPS121)"] -- "email + senha fornecidos no run" --> L["/campanha/login"] + L --> T["exercita só a feature entregue"] + DB -. "nunca acessado pelo agente" .-> A +``` + +**Opções consideradas:** A — script de ops novo rodado manualmente no bootstrap do staging, com guard de banco/flag, senha vinda de env obrigatória, documentado no runbook | B — invocar a criação a partir do bloco `staging)` do `deploy-homeserver.sh` a cada deploy | C — criação manual da conta via admin. + +**Recomendação:** **A** — porque o `deploy-homeserver.sh` é genérico de ambiente e não deve ganhar conhecimento de campanha (a intenção proíbe acoplar o deploy à conta); o DB de staging é (re)criado raramente (bootstrap), não a cada deploy, então rodar por deploy é cerimônia e superfície de falha; e um script idempotente com guard próprio é repetível e auditável, sobrevivendo à recriação do banco. A senha vem de `STAGING_TEST_ACCOUNT_PASSWORD` (env obrigatória, fail-closed) e a liberação ao agente no passo OPS121 é feita pelo humano (recomendação da intenção), fechando o gap de spec nos docs do pipeline. + +**Rejeitadas:** B porque acopla campanha ao script de deploy genérico, exige a senha no caminho do deploy, roda a cada SHA e pode mascarar erro de bootstrap como erro de deploy; C porque não é repetível/auditável, não sobrevive à recriação do banco e dependeria de um login de admin de staging; "reusar `db:seed:minimal`" porque o guard é local-only e os pins de municípios/Consent/objetos colidiriam com a cópia real. + +### Componentes / mudanças + +- **`scripts/lib/staging-test-account.mjs`** (novo, `scripts/…`): dono único do conhecimento da conta — constante `STAGING_TEST_ACCOUNT = { name: 'Agente de Teste (staging)', email: 'agente-teste@teqo.invalid', role: 'coordinator' }`, `STAGING_DATABASE_NAME = 'teqo_staging'`, `STAGING_TEST_ACCOUNT_CONFIRM_FLAG = 'STAGING_TEST_ACCOUNT_CONFIRM'`; guard puro e injetável `assertStagingTestAccountTarget({ databaseUrl, nodeEnv, allowRemoteDb, teqoEnv, confirm })` (mesmo estilo de `requiresWriteConfirm`); e `upsertStagingTestAccount(payload, { password })` (busca por email → `create` ou `update` com `{ name, email, role, password }`). Importa `databaseName`/`requiresWriteConfirm`/`isTruthyEnv`/`dieWithLabel` de `scripts/lib/cli.mjs`. Sem top-level nativo de payload (o `payload` entra por parâmetro) — importável pelo unit pin e pelo int test. +- **`scripts/bootstrap-staging-test-account.mjs`** (novo, `scripts/…`): CLI fina — `loadCliEnv()`, lê `DATABASE_URL` e `STAGING_TEST_ACCOUNT_PASSWORD` (fail-closed se ausente ou curta), chama o guard, `getPayload({ config })`, chama `upsertStagingTestAccount`, loga o nome do banco-alvo e o email (nunca a senha), `process.exit(0)`. Não usa `db:seed:minimal`. +- **`scripts/lib/cli.mjs`** (`scripts/…`): adiciona o helper genérico `databaseName(url)` (decodifica o pathname com o mesmo spelling de `db-reset.mjs`/`assertTestDatabase.ts`), single-sourcing do 3º call site. +- **`scripts/db-reset.mjs`** e **`tests/helpers/assertTestDatabase.ts`**: adotam `databaseName` (sem mudança de semântica — só remove a 3ª cópia do spelling). +- **`package.json`**: script `"campaign:staging:test-account": "cross-env NODE_OPTIONS=\"--no-deprecation --import=tsx/esm --import=./scripts/seed-loader.mjs\" node scripts/bootstrap-staging-test-account.mjs"` (mesmo shape de `db:seed:minimal`/`camara:import`). +- **Docs:** `docs/ops/teqo-1313-deploy.md` (§Bootstrap do staging — nova entrada: var da senha no passo do env file + comando de criação/atualização da conta, re-rodar após recriação do DB); `.agents/skills/work-issue/execution-pipeline.md` (§Verificação pós-deploy §2 — onde a credencial vive e como o humano a obtém); `.agents/skills/work-issue/SKILL.md` (Passo 8 — uma linha do delta); `docs/changelog/2026-09-17-ops125.md` (entrada nova). +- **Migration:** sem migration (nenhuma mudança de schema/collection/field — a conta usa `campaignUser` existente). +- **Access / Consent:** sem nova regra de access e sem novo `Consent` (conta de staff, não opt-in de titular). A criação roda pela Local API sem `user`, contando com o bypass default do Payload (mesmo precedente de `seed-minimal.mjs`); o hook `ensureCampaignUserContactIdentity` (`CampaignUser.ts:214-244`) roda no `create` sem `req.user` e cria o `Contact` sintético; no `update` sem `req.user` ele retorna cedo (`:229`) e **não** reescreve a ficha — comportamento desejado. +- **UI:** N/A — Impeccable A, sem UI. + +### Arquivos a tocar e pins + +**Criados:** `scripts/lib/staging-test-account.mjs`, `scripts/bootstrap-staging-test-account.mjs`, `tests/unit/stagingTestAccount.unit.spec.ts`, `tests/int/campaignStagingTestAccount.int.spec.ts`, `docs/changelog/2026-09-17-ops125.md`. +**Editados:** `scripts/lib/cli.mjs`, `scripts/db-reset.mjs`, `tests/helpers/assertTestDatabase.ts`, `package.json`, `docs/ops/teqo-1313-deploy.md`, `.agents/skills/work-issue/execution-pipeline.md`, `.agents/skills/work-issue/SKILL.md`. +**Pins tocados/existentes a respeitar:** `tests/unit/scriptCliConventions.unit.spec.ts` (importar `dieWithLabel` de `scripts/lib/cli.mjs`, nunca re-escrever), `tests/unit/codebaseConventions.unit.spec.ts:343-370` (skeleton CLI single-sourced — não re-spellar `die`/dotenv/`LOCAL_HOSTS`), `tests/unit/cliEnvFlags.unit.spec.ts` (adicionar casos de `databaseName`; não alterar a semântica exata de `requiresWriteConfirm`), `tests/unit/deployScript.unit.spec.ts:176-236` (**não tocar** `deploy-homeserver.sh` — este plano não altera o script de deploy), `tests/unit/seedMinimalManifest.unit.spec.ts` (**não alterar** o manifest do seed local). +**Novos testes:** unit para o guard/flag (matriz de aceitação/recusa de `assertStagingTestAccountTarget` + pins de identidade `coordinator`/`@teqo.invalid`); int para o login da conta criada (`upsertStagingTestAccount` duas vezes → idempotente, `payload.login({ collection:'campaignUser', data:{ email, password } })` retorna token, `role==='coordinator'`, `contact` vinculado, nenhum outro doc alterado), seguindo o padrão de `tests/int/campaignAuth.int.spec.ts`. + +### Dados → forma (se aplicável) + +N/A — item de operação/credencial; nenhum número, agregado ou dashboard é exibido (a forma foi adiada na intenção: sem dashboard de acesso, sem auditoria de login nova). + +## Decisões de engenharia + +### (a) Onde vive a criação da conta + +**Opções:** A) script de ops novo (`scripts/bootstrap-staging-test-account.mjs`) rodado manualmente no bootstrap do staging e documentado no runbook | B) invocado pelo bloco `staging)` do `deploy-homeserver.sh` a cada deploy | C) criação manual via admin. +**Recomendação:** A — o DB de staging é recriado no bootstrap (não por deploy), então a criação é evento de bootstrap; o script de deploy permanece genérico e sem conhecimento de campanha; o runbook ganha o comando repetível. Gatilho de revisitação: se o DB de staging passar a ser recriado com frequência automatizada, reavaliar encadear no bootstrap (não no deploy). +**Rejeitadas:** B porque acopla campanha ao deploy genérico, exige `STAGING_TEST_ACCOUNT_PASSWORD` no caminho do deploy a cada SHA e transforma falha de conta em falha de deploy (o deploy já é longo e serializado); C porque é irrepetível, sem auditoria e perde a conta na recriação do DB. + +### (b) Discriminador / guard de "restrito a staging" + +**Opções:** A) nome do banco `teqo_staging` (exato) | B) `TEQO_ENV=staging` | C) ambos + flag de intenção. +**Recomendação:** **C**, com o nome do banco como discriminador **duro**: `assertStagingTestAccountTarget` exige `databaseName(DATABASE_URL) === 'teqo_staging'` (fail-closed para `teqo_1313`, `teqo`, `teqo_test`, host remoto, URL inválida), exige protocolo `postgresql:`, recusa `ALLOW_REMOTE_DB` (o script nunca precisa dele — o host é `127.0.0.1`), recusa `TEQO_ENV` setado e diferente de `staging`, e exige `STAGING_TEST_ACCOUNT_CONFIRM=1` **sempre** (o bootstrap é ato deliberado; não condiciona a `requiresWriteConfirm`, que seria sempre true no homeserver e false num alvo local — a checagem incondicional fecha a ambiguidade). O nome é o sinal honesto porque o proxy socat reescreve o host; a flag é a intenção deliberada; `requiresWriteConfirm` + `assertLocalDatabase` sozinhos **não** bastam (o host local passa). +**Rejeitadas:** A sozinho deixa a intenção implícita (um `DATABASE_URL` de staging editado por engano roda sem confirmação); B sozinho porque o env file de staging hoje não define `TEQO_ENV` (o workflow o passa por job) — exigir só isso quebraria o bootstrap e é falsificável se alguém exportar `TEQO_ENV=staging` com outro banco. + +### (c) De onde vem a senha + +**Opções:** A) env obrigatória `STAGING_TEST_ACCOUNT_PASSWORD` no `~/stack/teqo-staging.env` (fail-closed; determinística entre re-runs) | B) senha aleatória impressa a cada execução | C) senha hardcoded no script. +**Recomendação:** A — o script não cria nem persiste segredo por conta própria; a senha é um valor de ambiente do homeserver (chmod 600, nunca commitado), e a idempotência fica verdadeira (re-run sincroniza a mesma senha). O guard falha fechado se a var faltar ou for curta (mínimo de 16 chars), antes de instanciar o Payload. +**Rejeitadas:** B porque rotaciona o segredo silenciosamente a cada re-run, exige captura humana e quebra o "determinística entre re-runs"; C porque é segredo versionado — proibido pela intenção (staging com PII real). + +### (d) Como o agente recebe a credencial no passo OPS121 + +**Opções:** A) o humano fornece no run de verificação | B) arquivo gitignored no worktree provisionado. +**Recomendação:** A, fechando o gap de spec nos docs: `.agents/skills/work-issue/execution-pipeline.md` §Verificação pós-deploy §2 ganha que a credencial é `agente-teste@teqo.invalid` + a senha em `~/stack/teqo-staging.env` na var `STAGING_TEST_ACCOUNT_PASSWORD` (chmod 600), **fornecida pelo humano que opera o homeserver no momento do teste**; o agente **nunca** lê o env file, nunca recebe `DATABASE_URL`/`ALLOW_REMOTE_DB` e nunca cola a credencial no PR/Issue (só o desfecho). `work-issue/SKILL.md` Passo 8 ganha uma linha apontando para essa fonte. `docs/ops/teqo-1313-deploy.md` §Bootstrap do staging registra a var no passo do env file e o comando de criação. +**Rejeitadas:** B porque espalha o segredo para o env do agente/worktree por padrão, não existe no Cursor Cloud e aumenta o risco de vazamento em Issue/PR/log. + +### (e) Idempotência / upsert + +**Opções:** A) lookup por `email` (unique) antes de create; update sincroniza `role`/`name`/senha | B) create cego e tratar erro de unique | C) deletar+recriar. +**Recomendação:** A — find por `email: { equals: 'agente-teste@teqo.invalid' }`; se ausente, `create({ name, email, role, password })`; se presente, `update({ name, role, email, password })`. Nunca passa `contact` no update, então o hook cedo-retorna e **não** toca a ficha; no `create`, o hook cria um `Contact` sintético único (email `@teqo.invalid` **não** é filtrado por `isPlanilhaPlaceholderEmail`, que só cobre `@planilha.invalid`) — aceitável, sintético, uma vez, sem tocar terceiros. Não deleta nem altera nenhum outro `campaignUser`. +**Rejeitadas:** B porque depende de mensagem de erro do driver (frágil) e não garante o sync de `role`/`name`/senha; C porque destrói o histórico/link de ficha e pode cascatear passkeys/notificações dos hooks de delete, sem necessidade. + +### (f) Reuso vs. helper novo (`databaseName`) + +**Opções:** A) adicionar `databaseName(url)` a `scripts/lib/cli.mjs` e adotar nos 3 sites | B) extrair inline no script novo (padrão de `db-reset.mjs`) | C) novo `scripts/lib/staging-test-account.mjs` com o extrator próprio e nada em `cli.mjs`. +**Recomendação:** **A + C**: `databaseName` genérico e unit-testado em `cli.mjs` (é o 3º call site e o spelling `decodeURIComponent(pathname.replace(/^\//, ''))` é safety-critical — um bug de decode desviaria o guard), com adoção em `db-reset.mjs` e `tests/helpers/assertTestDatabase.ts`; o conhecimento específico de staging (constantes + guard) fica em `scripts/lib/staging-test-account.mjs`, espelhando o precedente do manifest do seed. +**Rejeitadas:** B porque criaria uma 3ª cópia do mesmo spelling — exatamente o drift que os pins de convenção combatem; C puro porque deixa o spelling duplicado em 3 lugares; overloading de `cli.mjs` com lógica de staging porque o módulo é genérico por contrato. + +## Fases verificáveis + +1. **Tracer / schema+server** (~0,3 dia) — criar `scripts/lib/staging-test-account.mjs` (constantes + `assertStagingTestAccountTarget` + `upsertStagingTestAccount`), `scripts/bootstrap-staging-test-account.mjs`, helper `databaseName` em `cli.mjs` com adoção nos outros 2 sites, entrada em `package.json`. Verificação: unit pin (`tests/unit/stagingTestAccount.unit.spec.ts` + casos novos em `cliEnvFlags.unit.spec.ts`) e int (`tests/int/campaignStagingTestAccount.int.spec.ts`) — o `create`/`update` idempotente e o `payload.login` verde; o caminho CLI completo só é executável no homeserver (o guard recusa o nome local por design, fail-closed). +2. **Docs / skill** (~0,15 dia) — subseção do runbook (var + comando + re-run pós-recriação), §2 do `execution-pipeline.md`, Passo 8 do `SKILL.md`, changelog. Sem tocar `docs/AGENT-OPS.md` (tabela de ambientes é do OPS103). +3. **Gates** (`pnpm gate:fast` para lint/format/typecheck/knip/cycles/unit; `pnpm test:int` para o novo spec) e push via `pnpm push` no PR `--base main` com `Closes #1126`. + +## Rabbit holes / Não escopo (engenharia) + +- **"Já que roda no staging, aproveita e semeia o mínimo."** Não — pins de municípios/Consent/objetos colidiriam com a cópia real; só a conta. +- **"Encadeia no `deploy-homeserver.sh` para nunca esquecer."** Não — acopla campanha ao deploy genérico e roda por SHA; a criação é de bootstrap. +- **"Corrige agora os docs que dizem que staging é sintético."** Não — `docs/AGENT-OPS.md:10`, `docs/ops/teqo-1313-deploy.md:191` e `docs/plans/ops103-staging-homeserver.md:52` são do OPS103 (dono do ambiente); registrar como débito, não editar aqui. +- **"Uma conta por papel para testar lockdowns."** Não — uma `coordinator`; papéis escopados só com evidência e item próprio. +- **"e2e automatizado de login contra staging."** Fora de escopo no OPS103; a verificação é manual/pelo browser da sessão. +- **"Auditoria de login / dashboard de acesso."** Fora — a intenção adiou a forma. +- **"Criar usuário admin (`users`)."** Não — é `campaignUser`; o fluxo de `/campanha` usa `campaign-token`. + +## Riscos e mitigação + +- **Guard falso-negativo (DB de staging renomeado):** o script recusa e para (fail-closed, direção segura); documentar a dependência do nome `teqo_staging` no runbook e o comando de bootstrap. +- **`NODE_ENV=production` + proxy socat mascarando o alvo:** mitigado por exigir o **nome do banco** + flag de intenção + recusa de `ALLOW_REMOTE_DB`/`TEQO_ENV≠staging` (não confiar no host). +- **Credencial vazando para Issue/PR/log:** o script nunca loga a senha; docs fixam "nunca colar no PR/Issue" e o humano é a fonte; a var vive só no homeserver (chmod 600). +- **Contact sintético criado na cópia de produção:** aceito e documentado (uma linha sintética, criada só no `create`; o `update` não toca a ficha e não altera dados de terceiros). +- **Colisão improvável com conta real de mesmo email:** o find-por-email trataria como a mesma conta; se um doc real tiver esse email e papel `leader` com atribuições, o hook `preventAssignedAdvisorDowngrade` pode falhar — o erro é fail-closed e visível no bootstrap. +- **Contradição documental com o OPS103 (staging "sintético"):** a subseção nova não repete a afirmação; o débito fica registrado como fora de escopo. +- **Confusão de dev tentando rodar o CLI local:** o guard recusa qualquer banco que não seja `teqo_staging` por design; o runbook deixa explícito que o script é do homeserver. + +## Aceite de engenharia + +- [ ] Aceite de produto da intenção ainda coberto: conta sintética de staging criada/atualizada de forma idempotente, login por email funciona, credencial não commitada e liberada no run pelo humano. +- [ ] Invariantes AGENTS/engineering-standards: sem collection paralela; Local API sem `user` usa bypass default documentado; nenhuma escrita multi-collection nova; identificadores em inglês, strings pt-BR; guards de banco existentes intactos. +- [ ] Testes de domínio previstos (unit/int) onde access/write paths mudam: unit do guard/flag + `databaseName`; int do upsert idempotente + login da conta; pins de convenção CLI preservados. +- [ ] `pnpm gate:fast` e `pnpm test:int` verdes; `scripts/deploy-homeserver.sh` intocado. + +## Self-score (decision-quality) + +1. **Decisões caras têm rejeitadas?** Sim — (a) onde vive a criação, (b) discriminador/guard, (c) origem da senha, (e) idempotência e (f) single-sourcing trazem Opções/Recomendação/Rejeitadas explícitas. ✅ +2. **Abordagem cabe no appetite?** Sim — ~0,3 dia de script/lib/testes + ~0,15 de docs, dentro de ~0,5 dia; sem migration e sem UI. ✅ +3. **Rabbit holes nomeados?** Sim — seed no staging, encadear no deploy, correção dos docs do OPS103, conta por papel, e2e, auditoria. ✅ +4. **Depth check (reusa shells/helpers)?** Sim — reusa `campaignUser`/hooks e `auth.ts` de login, `cli.mjs` (`dieWithLabel`/`requiresWriteConfirm`/novo `databaseName`) e o precedente do manifest; não cria collection/Consent/access nem módulo pass-through. ✅ +5. **Intenção permanece satisfeita (engenharia não reescreveu o outcome)?** Sim — entrega a conta e o caminho de credencial sem tocar PII, sem DB pelo agente e sem segundo auth; assume papel/identidade conforme literal da intenção. ✅ + +**Nota: 5/5.** Ressalva: a correção dos docs que afirmam que staging é sintético permanece como débito do OPS103 (fora deste lote); a subseção nova evita propagar a afirmação. diff --git a/package.json b/package.json index 72ef1e37..92eeb7be 100644 --- a/package.json +++ b/package.json @@ -33,6 +33,7 @@ "db:seed:projecao": "cross-env NODE_OPTIONS=\"--no-deprecation --import=tsx/esm --import=./scripts/seed-loader.mjs\" node scripts/import-projecao.mjs", "media:recover": "cross-env NODE_OPTIONS=\"--no-deprecation --import=tsx/esm --import=./scripts/seed-loader.mjs\" node scripts/recover-media.mjs", "db:seed:minimal": "cross-env NODE_OPTIONS=\"--no-deprecation --import=tsx/esm --import=./scripts/seed-loader.mjs\" node scripts/seed-minimal.mjs", + "campaign:staging:test-account": "cross-env NODE_OPTIONS=\"--no-deprecation --import=tsx/esm --import=./scripts/seed-loader.mjs\" node scripts/bootstrap-staging-test-account.mjs", "db:check:push-chain": "node scripts/check-push-chain.mjs", "ops79:reconcile": "node scripts/reconcile-campaign-vertical.mjs", "ops79:migrate": "node scripts/migrate-signature-orphan.mjs", diff --git a/scripts/bootstrap-staging-test-account.mjs b/scripts/bootstrap-staging-test-account.mjs new file mode 100644 index 00000000..42a4c3a6 --- /dev/null +++ b/scripts/bootstrap-staging-test-account.mjs @@ -0,0 +1,62 @@ +/** + * OPS125 — bootstrap the synthetic staging test account. + * + * Creates/updates the `campaignUser` the `work-issue` post-deploy verification + * (OPS121) logs in with at `https://staging.jorgesolla1313.com.br`. Runs ONLY + * against the `teqo_staging` database on the homeserver, guarded by + * `assertStagingTestAccountTarget` (see `scripts/lib/staging-test-account.mjs`). + * + * The password comes from `STAGING_TEST_ACCOUNT_PASSWORD` (never in code, never + * committed) — read from `~/stack/teqo-staging.env` on the homeserver: + * + * ssh homeserver + * cd ~/teqo-deploy && pnpm install + * set -a; source ~/stack/teqo-staging.env; set +a + * export DATABASE_URL="${DATABASE_URL/@postgres:5432/@127.0.0.1:5434}" + * STAGING_TEST_ACCOUNT_CONFIRM=1 pnpm campaign:staging:test-account + * + * Re-run after recreating the staging DB (e.g. a fresh copy of production). + * Runbook: docs/ops/teqo-1313-deploy.md §Staging. + * + * The agent that consumes the credential NEVER runs this script and never + * receives `DATABASE_URL` — it only logs into the application. + */ + +import { getPayload } from 'payload' + +import { dieWithLabel, loadCliEnv } from './lib/cli.mjs' +import { + assertStagingTestAccountPassword, + assertStagingTestAccountTarget, + STAGING_DATABASE_NAME, + STAGING_TEST_ACCOUNT, + STAGING_TEST_ACCOUNT_PASSWORD_ENV, + upsertStagingTestAccount, +} from './lib/staging-test-account.mjs' + +loadCliEnv() + +const die = dieWithLabel('staging:test-account') + +const run = async () => { + const password = process.env[STAGING_TEST_ACCOUNT_PASSWORD_ENV] + try { + assertStagingTestAccountPassword(password) + assertStagingTestAccountTarget() + } catch (error) { + die(error instanceof Error ? error.message : String(error)) + } + + const config = (await import('../src/payload.config.ts')).default + const payload = await getPayload({ config }) + const result = await upsertStagingTestAccount(payload, { password }) + + // Never log the password — only the identity and the target. + console.log( + `[staging:test-account] ${result.operation} ${STAGING_TEST_ACCOUNT.email} ` + + `(role ${STAGING_TEST_ACCOUNT.role}, id ${result.id}) on ${STAGING_DATABASE_NAME}`, + ) + process.exit(0) +} + +run().catch((error) => die(error?.message || String(error))) diff --git a/scripts/db-reset.mjs b/scripts/db-reset.mjs index 4c6928b3..3dd90dbf 100644 --- a/scripts/db-reset.mjs +++ b/scripts/db-reset.mjs @@ -25,7 +25,13 @@ import { execFileSync } from 'node:child_process' import pg from 'pg' -import { dieWithLabel, loadCliEnv, LOCAL_HOSTS, TEST_DATABASE_NAME_RE } from './lib/cli.mjs' +import { + databaseName, + dieWithLabel, + loadCliEnv, + LOCAL_HOSTS, + TEST_DATABASE_NAME_RE, +} from './lib/cli.mjs' loadCliEnv() @@ -52,16 +58,14 @@ if (!LOCAL_HOSTS.has(parsedUrl.hostname)) { ) } -let databaseName -try { - databaseName = decodeURIComponent(parsedUrl.pathname.replace(/^\//, '')) -} catch { +const targetDatabaseName = databaseName(parsedUrl) +if (targetDatabaseName === null) { die('DATABASE_URL database name has invalid percent-encoding.') } -if (!TEST_DATABASE_NAME_RE.test(databaseName)) { +if (!TEST_DATABASE_NAME_RE.test(targetDatabaseName)) { die( - `Refusing to reset database "${databaseName}" — the name must match ` + + `Refusing to reset database "${targetDatabaseName}" — the name must match ` + '`teqo_test` or `teqo__test` (TEST_DATABASE_NAME_RE). The dev database is never reset.', ) } @@ -69,7 +73,7 @@ if (!TEST_DATABASE_NAME_RE.test(databaseName)) { try { const pool = new pg.Pool({ connectionString: databaseUrl, max: 1 }) try { - console.log(`[db:reset] Dropping schema public on "${databaseName}"…`) + console.log(`[db:reset] Dropping schema public on "${targetDatabaseName}"…`) await pool.query('DROP SCHEMA public CASCADE') await pool.query('CREATE SCHEMA public') console.log('[db:reset] Schema rebuilt — running migrate…') diff --git a/scripts/lib/cli.mjs b/scripts/lib/cli.mjs index 4e4b32ec..18f9532e 100644 --- a/scripts/lib/cli.mjs +++ b/scripts/lib/cli.mjs @@ -147,6 +147,25 @@ export const databaseHostname = (databaseUrl) => { } } +/** + * Database name of a connection string (percent-decoded), or null when the URL + * is missing/unparseable or its name carries invalid percent-encoding. THE one + * spelling of the name extraction shared by the fail-closed DB-name guards + * (`db-reset`, `assertTestDatabase`, staging test-account) — a decode bug here + * would silently misdirect a guard, so it is unit-pinned. + * + * @param {unknown} databaseUrl + * @returns {string | null} + */ +export const databaseName = (databaseUrl) => { + try { + const url = new URL(String(databaseUrl ?? '')) + return decodeURIComponent(url.pathname.replace(/^\//, '')) + } catch { + return null + } +} + /** * True when the connection string targets one of the LOCAL_HOSTS. * diff --git a/scripts/lib/staging-test-account.mjs b/scripts/lib/staging-test-account.mjs new file mode 100644 index 00000000..89a01daa --- /dev/null +++ b/scripts/lib/staging-test-account.mjs @@ -0,0 +1,171 @@ +/** + * Staging test account (OPS125) — the ONE source of the synthetic `campaignUser` + * the `work-issue` post-deploy verification (OPS121) logs in with at + * `https://staging.jorgesolla1313.com.br`. + * + * Why it exists: the verification agent tried the minimal seed credentials, + * which never exist on staging (the staging DB is a copy of production, not a + * `db:seed:minimal` target). This module owns the account identity and the + * fail-closed guard that keeps its creation restricted to staging. + * + * The password is NEVER in code — it comes from `STAGING_TEST_ACCOUNT_PASSWORD` + * in the homeserver env file (`~/stack/teqo-staging.env`, chmod 600), so the + * re-run is idempotent AND deterministic. + * + * Guard discriminators (the host alone cannot decide: the homeserver socat + * proxy rewrites the DB host to 127.0.0.1 and the env file sets + * `NODE_ENV=production`, so `assertLocalDatabase`/`requiresWriteConfirm` alone + * cannot tell staging from production): + * 1. the database NAME must be exactly `teqo_staging` (the honest signal); + * 2. the protocol must be `postgresql:`; + * 3. the host must still be in the local allowlist (defense in depth); + * 4. `ALLOW_REMOTE_DB` is refused (this script never needs it); + * 5. `TEQO_ENV`, when set, must be `staging`; + * 6. the explicit intent flag is ALWAYS required — the bootstrap is a + * deliberate act, never a side effect. + */ + +import { + databaseHostname, + databaseName, + isLocalDatabaseUrl, + isRemoteDbOverrideSet, + isTruthyEnv, +} from './cli.mjs' + +export const STAGING_TEST_ACCOUNT = Object.freeze({ + name: 'Agente de Teste (staging)', + email: 'agente-teste@teqo.invalid', + role: 'coordinator', +}) + +export const STAGING_DATABASE_NAME = 'teqo_staging' +export const STAGING_TEST_ACCOUNT_CONFIRM_FLAG = 'STAGING_TEST_ACCOUNT_CONFIRM' +export const STAGING_TEST_ACCOUNT_PASSWORD_ENV = 'STAGING_TEST_ACCOUNT_PASSWORD' +export const MIN_STAGING_TEST_ACCOUNT_PASSWORD_LENGTH = 16 + +/** + * Fail-closed password contract: the CLI demands it before instantiating the + * Payload runtime. Pure so the unit pin covers the exact threshold. + * + * @param {unknown} password + */ +export const assertStagingTestAccountPassword = (password) => { + if (typeof password !== 'string' || password.length === 0) { + throw new Error( + `${STAGING_TEST_ACCOUNT_PASSWORD_ENV} não está definida — a senha vive no env file do homeserver (chmod 600).`, + ) + } + if (password.length < MIN_STAGING_TEST_ACCOUNT_PASSWORD_LENGTH) { + throw new Error( + `${STAGING_TEST_ACCOUNT_PASSWORD_ENV} tem menos de ${MIN_STAGING_TEST_ACCOUNT_PASSWORD_LENGTH} caracteres — use uma senha sintética longa.`, + ) + } +} + +/** + * Fail-closed guard for the staging-only write. Pure and injectable (no env + * mutation) so the acceptance matrix is unit-pinned. + * + * @param {{ + * databaseUrl?: string | null, + * allowRemoteDb?: boolean, + * teqoEnv?: string | null, + * confirm?: boolean, + * }} [options] + */ +export const assertStagingTestAccountTarget = ({ + databaseUrl = process.env.DATABASE_URL, + allowRemoteDb = isRemoteDbOverrideSet(), + teqoEnv = process.env.TEQO_ENV, + confirm = isTruthyEnv(process.env[STAGING_TEST_ACCOUNT_CONFIRM_FLAG]), +} = {}) => { + const targetDatabaseName = databaseName(databaseUrl) + if (targetDatabaseName !== STAGING_DATABASE_NAME) { + throw new Error( + `banco-alvo "${targetDatabaseName ?? '(inválido)'}" ≠ "${STAGING_DATABASE_NAME}" — ` + + 'este script só escreve na conta de teste do staging.', + ) + } + + let protocol = null + try { + protocol = new URL(String(databaseUrl ?? '')).protocol + } catch { + protocol = null + } + if (protocol !== 'postgresql:') { + throw new Error( + `protocolo "${protocol ?? '(inválido)'}" ≠ "postgresql:" — a conta de teste só é criada num alvo PostgreSQL.`, + ) + } + + if (!isLocalDatabaseUrl(databaseUrl)) { + // Never echo the URL — it carries the DB password (reviewer finding). + throw new Error( + `host "${databaseHostname(databaseUrl) ?? '(inválido)'}" fora do allowlist local — a conta de teste nunca é criada em alvo remoto.`, + ) + } + + if (allowRemoteDb) { + throw new Error( + 'ALLOW_REMOTE_DB está setado — remova; este script nunca precisa do override remoto.', + ) + } + + if (teqoEnv !== undefined && teqoEnv !== null && teqoEnv !== '' && teqoEnv !== 'staging') { + throw new Error( + `TEQO_ENV="${teqoEnv}" ≠ "staging" — este script só roda no bootstrap do staging.`, + ) + } + + if (!confirm) { + throw new Error( + `confirme a intenção com ${STAGING_TEST_ACCOUNT_CONFIRM_FLAG}=1 — a conta é criada só no bootstrap deliberado do staging.`, + ) + } +} + +/** + * Idempotent upsert of the staging test account, keyed by its unique `email`. + * Creates on first run, then syncs `name`/`role`/`password` on every re-run + * (the C155-style deterministic bootstrap). Never passes `contact`, so the + * `CampaignUser` hook leaves the linked ficha untouched on updates; the first + * `create` lets the hook materialize the synthetic `Contact`. + * + * Runs through the Local API without `user` — the default Payload bypass, the + * same precedent as `scripts/seed-minimal.mjs`. + * + * @param {import('payload').Payload} payload + * @param {{ password: string }} options + * @returns {Promise<{ operation: 'created' | 'updated', id: number }>} + */ +export const upsertStagingTestAccount = async (payload, { password }) => { + const data = { + name: STAGING_TEST_ACCOUNT.name, + email: STAGING_TEST_ACCOUNT.email, + role: STAGING_TEST_ACCOUNT.role, + password, + } + + const existing = await payload.find({ + collection: 'campaignUser', + where: { email: { equals: STAGING_TEST_ACCOUNT.email } }, + depth: 0, + pagination: false, + }) + + const doc = existing.docs[0] + if (doc) { + const updated = await payload.update({ + collection: 'campaignUser', + id: doc.id, + data, + depth: 0, + }) + return { operation: 'updated', id: updated.id } + } + + const created = await payload.create({ collection: 'campaignUser', data, depth: 0 }) + return { operation: 'created', id: created.id } +} diff --git a/scripts/lib/test-affected-core.mjs b/scripts/lib/test-affected-core.mjs index fd68f981..1ed57a6a 100644 --- a/scripts/lib/test-affected-core.mjs +++ b/scripts/lib/test-affected-core.mjs @@ -66,6 +66,7 @@ export const SCRIPTS_SPEC_PINNED = [ 'scripts/lib/portalTransparenciaEmendas.mjs', 'scripts/lib/seed-minimal-manifest.mjs', 'scripts/lib/sollaCeuciSalvadorMetrics.mjs', + 'scripts/lib/staging-test-account.mjs', 'scripts/lib/test-affected-core.mjs', 'scripts/lib/testing-audit-metrics-core.mjs', 'scripts/lib/worktree-env.mjs', diff --git a/tests/helpers/assertTestDatabase.ts b/tests/helpers/assertTestDatabase.ts index 8efcbf67..4d83e8dd 100644 --- a/tests/helpers/assertTestDatabase.ts +++ b/tests/helpers/assertTestDatabase.ts @@ -16,7 +16,7 @@ // same hosts (OPS50/OPS62). // TEST_DATABASE_NAME_RE is the shared name contract (cli.mjs) — the same // regex `scripts/db-reset.mjs` uses before dropping the schema (OPS88). -import { defaultGatewayHost, TEST_DATABASE_NAME_RE } from '../../scripts/lib/cli.mjs' +import { databaseName, defaultGatewayHost, TEST_DATABASE_NAME_RE } from '../../scripts/lib/cli.mjs' export function assertTestDatabase(databaseUrl: string | undefined): void { if (!databaseUrl) { @@ -33,7 +33,7 @@ export function assertTestDatabase(databaseUrl: string | undefined): void { throw new Error('DATABASE_URL is not a valid PostgreSQL connection string.') } - const databaseName = decodeURIComponent(parsedUrl.pathname.replace(/^\//, '')) + const targetDatabaseName = databaseName(parsedUrl) const localHosts = new Set([ 'localhost', '127.0.0.1', @@ -47,12 +47,13 @@ export function assertTestDatabase(databaseUrl: string | undefined): void { if ( parsedUrl.protocol !== 'postgresql:' || - !TEST_DATABASE_NAME_RE.test(databaseName) || + targetDatabaseName === null || + !TEST_DATABASE_NAME_RE.test(targetDatabaseName) || !localHosts.has(parsedUrl.hostname) ) { throw new Error( `Refusing to run tests using protocol "${parsedUrl.protocol || '(unknown)'}" against database ` + - `"${databaseName || '(unknown)'}" on host ` + + `"${targetDatabaseName ?? '(unknown)'}" on host ` + `"${parsedUrl.hostname || '(unknown)'}". Tests require a local database named ` + '`teqo_test` or `teqo__test` (per-worktree isolation from `pnpm worktree next`) ' + 'over postgresql: on localhost, 127.0.0.1, or ::1. ' + diff --git a/tests/int/campaignStagingTestAccount.int.spec.ts b/tests/int/campaignStagingTestAccount.int.spec.ts new file mode 100644 index 00000000..22254b31 --- /dev/null +++ b/tests/int/campaignStagingTestAccount.int.spec.ts @@ -0,0 +1,93 @@ +// @vitest-environment node + +import type { Payload } from 'payload' +import { beforeAll, describe, expect, it } from 'vitest' + +import config from '@/payload.config' +import { getPayload } from 'payload' + +import { + STAGING_TEST_ACCOUNT, + upsertStagingTestAccount, +} from '../../scripts/lib/staging-test-account.mjs' + +import { installCampaignFixtures } from '../helpers/campaignFixtures' + +let payload: Payload +const campaignFixtures = installCampaignFixtures({ + getPayload: () => payload, + setPayload: (nextPayload) => { + payload = nextPayload + }, +}) + +/** + * The account's synthetic `Contact` is materialized by the collection hook on + * the root payload (not the fixture proxy), so it is not auto-owned. Own it + * explicitly or every run leaves an orphan ficha behind. + */ +const ownAccount = async (id: number) => { + campaignFixtures().own('campaignUser', id) + const doc = await payload.findByID({ collection: 'campaignUser', id, depth: 0 }) + if (typeof doc.contact === 'number') campaignFixtures().own('contact', doc.contact) + return doc +} + +describe('staging test account bootstrap (OPS125)', () => { + beforeAll(async () => { + payload = await getPayload({ config: await config }) + }) + + it('upserts the account idempotently and logs in by email', async () => { + const password = campaignFixtures().value('staging-password') + + const first = await upsertStagingTestAccount(campaignFixtures().payload, { password }) + const doc = await ownAccount(first.id) + + const second = await upsertStagingTestAccount(campaignFixtures().payload, { password }) + expect(second.id).toBe(first.id) + + const accounts = await payload.find({ + collection: 'campaignUser', + where: { email: { equals: STAGING_TEST_ACCOUNT.email } }, + depth: 0, + pagination: false, + }) + expect(accounts.docs).toHaveLength(1) + + const login = await payload.login({ + collection: 'campaignUser', + data: { email: STAGING_TEST_ACCOUNT.email, password }, + }) + expect(typeof login.token).toBe('string') + expect(login.token?.length).toBeGreaterThan(20) + expect(login.user?.role).toBe('coordinator') + // login hydrates the relationship; compare the resolved id to the owned ficha. + expect(campaignFixtures().id(login.user!.contact!)).toBe(doc.contact) + }) + + it('syncs the identity and password on a re-run', async () => { + const password = campaignFixtures().value('staging-password') + const rotated = `${password}-rotated` + + const created = await upsertStagingTestAccount(campaignFixtures().payload, { password }) + await ownAccount(created.id) + + const updated = await upsertStagingTestAccount(campaignFixtures().payload, { + password: rotated, + }) + expect(updated).toEqual({ operation: 'updated', id: created.id }) + + await expect( + payload.login({ + collection: 'campaignUser', + data: { email: STAGING_TEST_ACCOUNT.email, password }, + }), + ).rejects.toThrow() + const rotatedLogin = await payload.login({ + collection: 'campaignUser', + data: { email: STAGING_TEST_ACCOUNT.email, password: rotated }, + }) + expect(rotatedLogin.user?.name).toBe(STAGING_TEST_ACCOUNT.name) + }) +}) diff --git a/tests/unit/cliEnvFlags.unit.spec.ts b/tests/unit/cliEnvFlags.unit.spec.ts index 12ea481a..04f732f5 100644 --- a/tests/unit/cliEnvFlags.unit.spec.ts +++ b/tests/unit/cliEnvFlags.unit.spec.ts @@ -5,6 +5,7 @@ import { afterEach, describe, expect, it } from 'vitest' import { ALLOW_REMOTE_DB_FLAG, databaseHostname, + databaseName, isLocalDatabaseUrl, isRemoteDbOverrideSet, isTruthyEnv, @@ -50,6 +51,18 @@ describe('isRemoteDbOverrideSet (behavior preserved through the delegation refac }) }) +describe('databaseName (OPS125 — the one spelling shared by the DB-name guards)', () => { + it('reads the decoded database name and refuses missing/invalid URLs', () => { + expect(databaseName('postgresql://teqo:teqo@localhost:5432/teqo_staging')).toBe('teqo_staging') + expect(databaseName('postgresql://teqo:teqo@postgres:5432/teqo_1313')).toBe('teqo_1313') + expect(databaseName('postgresql://teqo:teqo@localhost:5432/teqo%5Fwt155')).toBe('teqo_wt155') + expect(databaseName('postgresql://teqo:teqo@localhost:5432/')).toBe('') + expect(databaseName(undefined)).toBeNull() + expect(databaseName('not a url')).toBeNull() + expect(databaseName('postgresql://teqo:teqo@localhost:5432/%E0%A4%A')).toBeNull() + }) +}) + describe('databaseHostname / isLocalDatabaseUrl (C155)', () => { it('reads the hostname and refuses missing/invalid connection strings', () => { expect(databaseHostname('postgresql://teqo:teqo@localhost:5432/teqo_wt155')).toBe('localhost') diff --git a/tests/unit/stagingTestAccount.unit.spec.ts b/tests/unit/stagingTestAccount.unit.spec.ts new file mode 100644 index 00000000..39c13ccb --- /dev/null +++ b/tests/unit/stagingTestAccount.unit.spec.ts @@ -0,0 +1,147 @@ +// @vitest-environment node + +import { describe, expect, it } from 'vitest' + +import { + assertStagingTestAccountPassword, + assertStagingTestAccountTarget, + MIN_STAGING_TEST_ACCOUNT_PASSWORD_LENGTH, + STAGING_DATABASE_NAME, + STAGING_TEST_ACCOUNT, + STAGING_TEST_ACCOUNT_CONFIRM_FLAG, + STAGING_TEST_ACCOUNT_PASSWORD_ENV, +} from '../../scripts/lib/staging-test-account.mjs' + +const STAGING_URL = `postgresql://teqo:teqo@127.0.0.1:5434/${STAGING_DATABASE_NAME}` + +const stagingTarget = (overrides = {}) => ({ + databaseUrl: STAGING_URL, + allowRemoteDb: false, + teqoEnv: undefined, + confirm: true, + ...overrides, +}) + +describe('staging test account identity (OPS125)', () => { + it('pins the identity the work-issue verification logs in with', () => { + expect(STAGING_TEST_ACCOUNT).toEqual({ + name: 'Agente de Teste (staging)', + email: 'agente-teste@teqo.invalid', + role: 'coordinator', + }) + expect(STAGING_DATABASE_NAME).toBe('teqo_staging') + expect(STAGING_TEST_ACCOUNT_CONFIRM_FLAG).toBe('STAGING_TEST_ACCOUNT_CONFIRM') + expect(STAGING_TEST_ACCOUNT_PASSWORD_ENV).toBe('STAGING_TEST_ACCOUNT_PASSWORD') + expect(MIN_STAGING_TEST_ACCOUNT_PASSWORD_LENGTH).toBe(16) + }) +}) + +describe('assertStagingTestAccountPassword (OPS125)', () => { + it('accepts a synthetic password at or above the minimum length', () => { + expect(() => assertStagingTestAccountPassword('x'.repeat(16))).not.toThrow() + expect(() => assertStagingTestAccountPassword('a-long-synthetic-password')).not.toThrow() + }) + + it('refuses a missing/empty or short password', () => { + for (const value of [undefined, null, '', 'x'.repeat(15)]) { + expect(() => assertStagingTestAccountPassword(value)).toThrow(/STAGING_TEST_ACCOUNT_PASSWORD/) + } + }) +}) + +describe('assertStagingTestAccountTarget (OPS125 fail-closed guard)', () => { + it('accepts the staging database with the intent flag set', () => { + expect(() => assertStagingTestAccountTarget(stagingTarget())).not.toThrow() + }) + + it('accepts a local staging target when the intent flag is set', () => { + expect(() => + assertStagingTestAccountTarget( + stagingTarget({ + databaseUrl: `postgresql://teqo:teqo@postgres:5432/${STAGING_DATABASE_NAME}`, + }), + ), + ).not.toThrow() + }) + + it('accepts TEQO_ENV=staging and an absent TEQO_ENV', () => { + expect(() => + assertStagingTestAccountTarget(stagingTarget({ teqoEnv: 'staging' })), + ).not.toThrow() + expect(() => assertStagingTestAccountTarget(stagingTarget({ teqoEnv: '' }))).not.toThrow() + }) + + it('refuses any database that is not exactly teqo_staging', () => { + for (const name of ['teqo_1313', 'teqo', 'teqo_test', 'teqo_staging_copy', '']) { + expect( + () => + assertStagingTestAccountTarget( + stagingTarget({ databaseUrl: `postgresql://teqo:teqo@127.0.0.1:5434/${name}` }), + ), + name, + ).toThrow(/banco-alvo/) + } + }) + + it('refuses a missing or unparseable database URL', () => { + expect(() => assertStagingTestAccountTarget(stagingTarget({ databaseUrl: undefined }))).toThrow( + /banco-alvo/, + ) + expect(() => + assertStagingTestAccountTarget(stagingTarget({ databaseUrl: 'not a url' })), + ).toThrow(/banco-alvo/) + }) + + it('refuses a staging-named database on a remote host', () => { + expect(() => + assertStagingTestAccountTarget( + stagingTarget({ + databaseUrl: `postgresql://teqo:teqo@db.example.com:5432/${STAGING_DATABASE_NAME}`, + }), + ), + ).toThrow(/allowlist local/) + }) + + it('never leaks the connection string (and its password) in the refusal', () => { + const secret = 'super-secret-db-password' + let message = '' + try { + assertStagingTestAccountTarget( + stagingTarget({ + databaseUrl: `postgresql://teqo:${secret}@db.example.com:5432/${STAGING_DATABASE_NAME}`, + }), + ) + } catch (error) { + message = error instanceof Error ? error.message : String(error) + } + expect(message).toMatch(/allowlist local/) + expect(message).not.toContain(secret) + expect(message).not.toContain('postgresql://') + }) + + it('refuses a non-postgresql protocol even on a local staging-named target', () => { + expect(() => + assertStagingTestAccountTarget( + stagingTarget({ databaseUrl: `http://127.0.0.1:5434/${STAGING_DATABASE_NAME}` }), + ), + ).toThrow(/protocolo/) + }) + + it('refuses the ALLOW_REMOTE_DB escape', () => { + expect(() => assertStagingTestAccountTarget(stagingTarget({ allowRemoteDb: true }))).toThrow( + /ALLOW_REMOTE_DB/, + ) + }) + + it('refuses a TEQO_ENV that is not staging', () => { + expect(() => assertStagingTestAccountTarget(stagingTarget({ teqoEnv: 'production' }))).toThrow( + /TEQO_ENV/, + ) + }) + + it('demands the intent flag under a production target', () => { + expect(() => assertStagingTestAccountTarget(stagingTarget({ confirm: false }))).toThrow( + /STAGING_TEST_ACCOUNT_CONFIRM/, + ) + }) +})