mirror of
https://github.com/fsolla/teqo.git
synced 2026-11-13 06:43:06 +00:00
chore(OPS125): conta de teste dos agentes no staging (Closes #1126)
This commit is contained in:
parent
f70c5e2d0b
commit
8b2ab91dd1
15 changed files with 705 additions and 14 deletions
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
1
docs/changelog/2026-09-17-ops125.md
Normal file
1
docs/changelog/2026-09-17-ops125.md
Normal file
|
|
@ -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.
|
||||
|
|
@ -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);
|
||||
|
|
|
|||
139
docs/plans/ops125-conta-de-teste-agentes-staging-impl.md
Normal file
139
docs/plans/ops125-conta-de-teste-agentes-staging-impl.md
Normal file
|
|
@ -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<br/>STAGING_TEST_ACCOUNT_CONFIRM=1<br/>STAGING_TEST_ACCOUNT_PASSWORD"] --> S["scripts/bootstrap-staging-test-account.mjs"]
|
||||
S --> G{"guard: databaseName == teqo_staging?<br/>sem ALLOW_REMOTE_DB · TEQO_ENV≠production"}
|
||||
G -- "não" --> X["fecha fechado — nada é escrito"]
|
||||
G -- "sim" --> U["upsert campaignUser por email<br/>(lib/staging-test-account.mjs)"]
|
||||
U -- "create" --> C["hook ensureCampaignUserContactIdentity<br/>cria Contact sintético único"]
|
||||
U -- "update" --> P["sincroniza name/role/password<br/>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.
|
||||
|
|
@ -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",
|
||||
|
|
|
|||
62
scripts/bootstrap-staging-test-account.mjs
Normal file
62
scripts/bootstrap-staging-test-account.mjs
Normal file
|
|
@ -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)))
|
||||
|
|
@ -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_<worktree>_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…')
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
*
|
||||
|
|
|
|||
171
scripts/lib/staging-test-account.mjs
Normal file
171
scripts/lib/staging-test-account.mjs
Normal file
|
|
@ -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 }
|
||||
}
|
||||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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_<worktree>_test` (per-worktree isolation from `pnpm worktree next`) ' +
|
||||
'over postgresql: on localhost, 127.0.0.1, or ::1. ' +
|
||||
|
|
|
|||
93
tests/int/campaignStagingTestAccount.int.spec.ts
Normal file
93
tests/int/campaignStagingTestAccount.int.spec.ts
Normal file
|
|
@ -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)
|
||||
})
|
||||
})
|
||||
|
|
@ -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')
|
||||
|
|
|
|||
147
tests/unit/stagingTestAccount.unit.spec.ts
Normal file
147
tests/unit/stagingTestAccount.unit.spec.ts
Normal file
|
|
@ -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/,
|
||||
)
|
||||
})
|
||||
})
|
||||
Loading…
Reference in a new issue