chore(OPS125): conta de teste dos agentes no staging (Closes #1126)
Some checks are pending
Deploy / preflight (push) Waiting to run
Deploy / verify (push) Blocked by required conditions
Deploy / deploy staging (teqo-staging) (push) Blocked by required conditions
Deploy / requeue when main advanced (push) Blocked by required conditions
Deploy / deploy production (teqo-1313) (push) Blocked by required conditions

This commit is contained in:
Francisco Solla 2026-09-17 01:54:42 -03:00
parent 1ebae91428
commit 10b58afe4c
15 changed files with 705 additions and 14 deletions

View file

@ -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

View file

@ -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

View 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.

View file

@ -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);

View 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.

View file

@ -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",

View 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)))

View file

@ -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…')

View file

@ -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.
*

View 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 }
}

View file

@ -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',

View file

@ -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. ' +

View 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)
})
})

View file

@ -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')

View 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/,
)
})
})