Files
agenciapsilmno/e2e/patient-intake.spec.js
T
Leonardo 7c20b518d4 Sessoes 1-6 acumuladas: hardening B2, defesa em camadas, +192 testes
Repositorio estava ha ~5 sessoes sem commit. Consolida tudo desde d088a89.

Ver commit.md na raiz para descricao completa por sessao.

# Numeros
- A# auditoria abertos: 0/30
- V# verificacoes abertos: 5/52 (todos adiados com plano)
- T# testes escritos: 10/10
- Vitest: 192/192
- SQL integration: 33/33
- E2E (Playwright, novo): 5/5
- Migrations: 17 (10 novas Sessao 6)
- Areas auditadas: 7 (+documentos com 10 V#)

# Highlights Sessao 6 (hoje)
- V#34/V#41 Opcao B2: tenant_features com plano + override (RPC SECURITY DEFINER, tela /saas/tenant-features)
- A#20 rev2 self-hosted: defesa em 5 camadas (honeypot + rate limit + math captcha condicional + paranoid mode + dashboard /saas/security)
- Documentos hardening (V#43-V#49): tenant scoping em storage policies (vazamento entre clinicas eliminado), RPC validate_share_token, signatures policy granular
- SaaS Twilio Config (/saas/twilio-config): UI editavel para SID/webhook/cotacao; AUTH_TOKEN permanece em env var
- T#9 + T#10: useAgendaEvents.spec.js + Playwright E2E (descobriu bug no front que foi corrigido)

# Sessoes anteriores (1-5) consolidadas
- Sessao 1: auth/router/session, normalizeRole extraido
- Sessao 2: agenda - composables/services consolidados
- Sessao 3: pacientes - tenant_id em todas queries
- Sessao 4: security review pagina publica - 14/15 vulnerabilidades corrigidas
- Sessao 5: SaaS - P0 (A#30: 7 tabelas com RLS off corrigidas)

# .gitignore ajustado
- supabase/* + !supabase/functions/ (mantem 10 edge functions, ignora .temp/migrations gerados pelo CLI)
- database-novo/backups/ (regeneravel via db.cjs backup)
- test-results/ + playwright-report/
- .claude/settings.local.json (config local com senha de dev removida do tracking)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-19 15:42:46 -03:00

150 lines
6.1 KiB
JavaScript

// =============================================================================
// T#10 — Golden path: paciente abre link → preenche → submit → sucesso
// =============================================================================
// Strategy: intercepta as chamadas pra Supabase Functions/REST e responde
// com mocks. Sem precisar de banco real ou edge functions rodando.
//
// Cobre as camadas de defesa em camadas (A#20 rev2):
// - honeypot (campo invisível, garantimos que não preenchemos)
// - submit normal funciona
// - 403 captcha-required → componente MathCaptchaChallenge aparece
// - 429 rate-limited → toast amigável
// - token UUID inválido bloqueia a página
// =============================================================================
import { test, expect } from '@playwright/test';
const VALID_TOKEN = '550e8400-e29b-41d4-a716-446655440000'; // UUID v4 válido (TOKEN_RX bate)
const PUBLIC_URL = `/cadastro/paciente?t=${VALID_TOKEN}`;
const FUNCTIONS_RX = /\/functions\/v1\/submit-patient-intake/;
// Helper: preenche os 3 campos obrigatórios + consent
async function fillRequired(page, { nome, email, telefone }) {
await page.locator('#f_nome').fill(nome);
await page.locator('#f_email_principal').fill(email);
await page.locator('#f_telefone').fill(telefone);
// PrimeVue Checkbox é um div com role=checkbox; .check() não funciona, precisa click
await page.locator('label[for="ext_consent"]').click();
}
test.describe('Cadastro paciente externo — golden path', () => {
test('paciente preenche e submete com sucesso (sem captcha)', async ({ page }) => {
await page.route(FUNCTIONS_RX, async (route) => {
const body = route.request().postDataJSON();
// honeypot NÃO foi preenchido (humano)
expect(body?.website).toBeFalsy();
expect(body?.token).toBe(VALID_TOKEN);
expect(body?.payload?.nome_completo).toBe('Maria da Silva');
expect(body?.payload?.consent).toBe(true);
await route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({ ok: true, intake_id: 'fake-intake-uuid' })
});
});
await page.goto(PUBLIC_URL);
await expect(page.getByText('Pré-cadastro do paciente')).toBeVisible();
await fillRequired(page, {
nome: 'Maria da Silva',
email: 'maria@test.com',
telefone: '11987654321'
});
await page.getByRole('button', { name: 'Enviar cadastro', exact: true }).click();
await expect(page.getByText(/Enviado com sucesso/i).first()).toBeVisible({ timeout: 10_000 });
});
test('rate limit (429) mostra mensagem amigável de tentativas', async ({ page }) => {
await page.route(FUNCTIONS_RX, async (route) => {
await route.fulfill({
status: 429,
contentType: 'application/json',
body: JSON.stringify({ error: 'rate-limited', retry_after_seconds: 600 })
});
});
await page.goto(PUBLIC_URL);
await expect(page.getByText('Pré-cadastro do paciente')).toBeVisible();
await fillRequired(page, {
nome: 'João Bot',
email: 'joao@test.com',
telefone: '11999999999'
});
await page.getByRole('button', { name: 'Enviar cadastro', exact: true }).click();
await expect(page.getByText(/Muitas tentativas/i)).toBeVisible({ timeout: 6_000 });
});
test('captcha-required mostra MathCaptchaChallenge e bloqueia botão até resposta', async ({ page }) => {
let firstSubmitCall = true;
await page.route(FUNCTIONS_RX, async (route) => {
const url = route.request().url();
if (url.includes('/captcha-challenge')) {
await route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({ challenge: { id: 'fake-challenge-id', question: 'Quanto é 2 + 3?' } })
});
return;
}
if (firstSubmitCall) {
firstSubmitCall = false;
await route.fulfill({
status: 403,
contentType: 'application/json',
body: JSON.stringify({ error: 'captcha-required' })
});
return;
}
// segunda call: ok
await route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({ ok: true, intake_id: 'after-captcha' })
});
});
await page.goto(PUBLIC_URL);
await expect(page.getByText('Pré-cadastro do paciente')).toBeVisible();
await fillRequired(page, {
nome: 'Ana Pereira',
email: 'ana@test.com',
telefone: '11955554444'
});
await page.getByRole('button', { name: 'Enviar cadastro', exact: true }).click();
// MathCaptchaChallenge aparece
await expect(page.getByText('Quanto é 2 + 3?')).toBeVisible({ timeout: 8_000 });
// Botão "Enviar cadastro" do footer fica disabled enquanto sem resposta
await expect(page.getByRole('button', { name: 'Enviar cadastro', exact: true })).toBeDisabled();
});
test('honeypot field existe no DOM mas está fora da viewport', async ({ page }) => {
await page.goto(PUBLIC_URL);
await expect(page.getByText('Pré-cadastro do paciente')).toBeVisible();
const honeypot = page.locator('input#ext_website');
await expect(honeypot).toHaveCount(1);
await expect(honeypot).not.toBeInViewport();
});
test('token inválido bloqueia toda a página de cadastro', async ({ page }) => {
await page.goto('/cadastro/paciente?t=token-invalido');
// Mensagem de erro aparece
await expect(page.getByText(/Link inválido ou ausente/i)).toBeVisible();
// Form não renderiza (botão Enviar não existe)
await expect(page.getByRole('button', { name: 'Enviar cadastro', exact: true })).toHaveCount(0);
});
});