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