From b9ff66109a1a4d17dabef5dde97fda74e190f705 Mon Sep 17 00:00:00 2001 From: andynnnfw Date: Tue, 6 Oct 2026 10:45:18 -0300 Subject: [PATCH 1/9] fix(erros): paginas de erro levam o visitante para a home - src/Controllers/ErrorController.php: novo backLink() decide o destino do botao de voltar (lojista logado vai ao painel, visitante vai a home publica) e handle() entrega backUrl e backLabel as views - views/errors/400.php, 401.php, 403.php, 429.php, 502.php, 503.php: link fixo Voltar ao Painel trocado por backUrl e backLabel - views/errors/404.php: mesmo link calculado e texto sem citar o dashboard - views/errors/default.php: mesmo link calculado e texto volte para o inicio - views/errors/500.php: mesmo link calculado e codigo do erro impresso com e() - tests/Feature/ErrorPagesTest.php: novo, cobre 404 de visitante e de lojista, depois do logout, 429 da consulta publica, 403 e a ausencia de link fixo nas views - CLAUDE.md: secao Erros explica as variaveis do botao de voltar Task 39 do backlog. --- CLAUDE.md | 2 +- src/Controllers/ErrorController.php | 12 ++++ tests/Feature/ErrorPagesTest.php | 89 +++++++++++++++++++++++++++++ views/errors/400.php | 2 +- views/errors/401.php | 2 +- views/errors/403.php | 2 +- views/errors/404.php | 4 +- views/errors/429.php | 2 +- views/errors/500.php | 4 +- views/errors/502.php | 2 +- views/errors/503.php | 2 +- views/errors/default.php | 4 +- 12 files changed, 114 insertions(+), 13 deletions(-) create mode 100644 tests/Feature/ErrorPagesTest.php diff --git a/CLAUDE.md b/CLAUDE.md index c6acece..df940e2 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -45,7 +45,7 @@ Banco: `mysql -u root -p < database/schema.sql` em instalação nova. Banco já **Proteções de toda requisição** — o `index.php` chama `RequestGuard` antes de rotear: cabeçalhos de segurança (`X-Frame-Options: DENY`, `frame-ancestors 'none'`, `nosniff`, `Referrer-Policy: same-origin`, sem `X-Powered-By`) e parâmetros em formato de lista (`campo[]=`) viram texto vazio, então nenhum `(string)` de `$_GET`/`$_POST` gera warning. O logout é POST com CSRF (o "Sair" do menu é um formulário). Os testes de fluxo rodam com `display_errors` ligado (`tests/Support/router.php`): qualquer warning aparece no HTML e quebra o teste. -**Erros** — `ErrorController::handle($code)` renderiza `views/errors/{code}.php` (fallback `default.php`). Exceções não tratadas e erros fatais são logados e viram 500 pelo `App\Support\ErrorLog` (registrado no `index.php`): uma linha por erro com um código curto, que a página 500 também mostra. Do contexto da requisição o log leva só método e rota, nunca query string, corpo ou argumentos (têm telefone e senha). Detalhes técnicos vão só para `error_log`, cujo destino é `LOG_FILE` ou o do `php.ini`. +**Erros** — `ErrorController::handle($code)` renderiza `views/errors/{code}.php` (fallback `default.php`) e entrega às views o botão de voltar (`$backUrl`, `$backLabel`): lojista logado volta ao painel, qualquer outra pessoa à home pública; página de erro nova usa essas variáveis, nunca link fixo. Exceções não tratadas e erros fatais são logados e viram 500 pelo `App\Support\ErrorLog` (registrado no `index.php`): uma linha por erro com um código curto, que a página 500 também mostra. Do contexto da requisição o log leva só método e rota, nunca query string, corpo ou argumentos (têm telefone e senha). Detalhes técnicos vão só para `error_log`, cujo destino é `LOG_FILE` ou o do `php.ini`. **Modelo de dados** (detalhes em `docs/db/schema-explanation.md`): - `customers` é **só o telefone**, global e único (só dígitos, 10–11, via `PhoneValidator::sanitize`); o mesmo cliente pode ter cartão em várias lojas. diff --git a/src/Controllers/ErrorController.php b/src/Controllers/ErrorController.php index 0fecbb3..f00547c 100644 --- a/src/Controllers/ErrorController.php +++ b/src/Controllers/ErrorController.php @@ -6,6 +6,10 @@ class ErrorController { public function handle($code = 404, $errorId = null) { http_response_code($code); + // botao de voltar de todas as paginas de erro ($backUrl e $backLabel sao usados nas views). + // a sessao pode nem ter comecado (erro antes do session_start): ai vale como visitante. + [$backUrl, $backLabel] = self::backLink(isset($_SESSION['merchant_id'])); + $viewPath = __DIR__ . "/../../views/errors/{$code}.php"; if (file_exists($viewPath)) { @@ -16,4 +20,12 @@ public function handle($code = 404, $errorId = null) { } return; } + + // para onde o botao de voltar leva: lojista logado volta pro painel; qualquer outra pessoa + // (ex.: cliente na consulta publica de saldo) volta pra home publica, e nao pro login do lojista. + public static function backLink(bool $loggedIn): array { + return $loggedIn + ? [url('merchant/dashboard'), 'Voltar ao Painel'] + : [url('home'), 'Voltar ao início']; + } } diff --git a/tests/Feature/ErrorPagesTest.php b/tests/Feature/ErrorPagesTest.php new file mode 100644 index 0000000..42401fa --- /dev/null +++ b/tests/Feature/ErrorPagesTest.php @@ -0,0 +1,89 @@ +get('rota/que-nao-existe'); + + $this->assertSame(404, $status); + $this->assertStringContainsString(self::HOME, $this->lastBody); + $this->assertStringContainsString('Voltar ao início', $this->lastBody); + $this->assertStringNotContainsString('merchant', $this->lastBody, 'visitante nao e mandado pro login do lojista'); + } + + public function testLojistaLogadoNoErro404VoltaParaOPainel(): void { + $this->createMerchant('erro@teste.test'); + $this->loginAs('erro@teste.test'); + + [$status] = $this->get('merchant/rota-que-nao-existe'); + + $this->assertSame(404, $status); + $this->assertStringContainsString(self::PAINEL, $this->lastBody); + $this->assertStringContainsString('Voltar ao Painel', $this->lastBody); + } + + // depois do logout a pessoa volta a ser visitante + public function testDepoisDeSairOBotaoVoltaALevarParaAHome(): void { + $this->createMerchant('erro@teste.test'); + $this->loginAs('erro@teste.test'); + $this->post('merchant/logout', [], true, 'merchant/dashboard'); + + $this->get('rota/que-nao-existe'); + + $this->assertStringContainsString(self::HOME, $this->lastBody); + } + + // o cliente da consulta publica que estoura o limite ve a 429: nao pode cair no login do lojista + public function testClienteBloqueadoNaConsultaPublicaVoltaParaAHome(): void { + $code = $this->publicCode($this->createMerchant('erro@teste.test')); + $page = 'customer/balance&loja=' . $code; + $fields = ['phone' => '11900000000', 'loja' => $code]; + for ($i = 0; $i < 5; $i++) { + $this->post('customer/balance', $fields, true, $page); + } + + [$status] = $this->post('customer/balance', $fields, true, $page); + + $this->assertSame(429, $status); + $this->assertStringContainsString(self::HOME, $this->lastBody); + $this->assertStringNotContainsString('merchant', $this->lastBody); + } + + // formulario sem o token csrf (403), com ou sem login + public function testErro403SegueAMesmaRegra(): void { + [$status] = $this->post('merchant/login', ['email' => 'x@teste.test', 'password' => 'x'], false); + $this->assertSame(403, $status); + $this->assertStringContainsString(self::HOME, $this->lastBody); + + $this->createMerchant('erro@teste.test'); + $this->loginAs('erro@teste.test'); + [$status] = $this->post('merchant/rewards', ['name' => 'Cafe'], false); + $this->assertSame(403, $status); + $this->assertStringContainsString(self::PAINEL, $this->lastBody); + } + + // nenhuma pagina de erro ficou com o link fixo antigo + public function testTodasAsPaginasDeErroUsamODestinoCalculado(): void { + $views = glob(dirname(__DIR__, 2) . '/views/errors/*.php'); + + $this->assertNotEmpty($views); + foreach ($views as $view) { + $html = (string)file_get_contents($view); + $this->assertStringContainsString('', $html, basename($view)); + $this->assertStringNotContainsString('url=merchant/dashboard', $html, basename($view)); + } + } + + public function testDestinoDoBotao(): void { + $this->assertSame(['/index.php?url=home', 'Voltar ao início'], ErrorController::backLink(false)); + $this->assertSame(['/index.php?url=merchant%2Fdashboard', 'Voltar ao Painel'], ErrorController::backLink(true)); + } +} diff --git a/views/errors/400.php b/views/errors/400.php index e099f88..5c0a24c 100644 --- a/views/errors/400.php +++ b/views/errors/400.php @@ -20,7 +20,7 @@

Requisição inválida

Algo nessa solicitação não veio do jeito certo. Revise os dados e tente novamente.

- Voltar ao Painel + diff --git a/views/errors/401.php b/views/errors/401.php index 5ff4d70..28b80c0 100644 --- a/views/errors/401.php +++ b/views/errors/401.php @@ -20,7 +20,7 @@

Sessão necessária

Para acessar esta página, você precisa estar autenticado. Faça login e tente novamente.

- Voltar ao Painel + diff --git a/views/errors/403.php b/views/errors/403.php index 5eb071f..0e2a08a 100644 --- a/views/errors/403.php +++ b/views/errors/403.php @@ -20,7 +20,7 @@

Acesso negado

Você não tem permissão para ver esta página. Se isso parecer errado, fale com o administrador.

- Voltar ao Painel + diff --git a/views/errors/404.php b/views/errors/404.php index 43232a7..da64873 100644 --- a/views/errors/404.php +++ b/views/errors/404.php @@ -18,9 +18,9 @@

Ops! Você se perdeu?

-

Esse cartão fidelidade parece ter expirado ou nunca existiu. Mas não esquenta, o dashboard está a um clique de distância.

+

Esse cartão fidelidade parece ter expirado ou nunca existiu. Mas não esquenta, o caminho de volta está a um clique de distância.

- Voltar ao Painel + \ No newline at end of file diff --git a/views/errors/429.php b/views/errors/429.php index ebd40e2..57b10fa 100644 --- a/views/errors/429.php +++ b/views/errors/429.php @@ -20,7 +20,7 @@

Calma aí!

Você fez muitas tentativas em pouco tempo. Aguarde alguns instantes e tente novamente.

- Voltar ao Painel + diff --git a/views/errors/500.php b/views/errors/500.php index 897d8f0..d294de9 100644 --- a/views/errors/500.php +++ b/views/errors/500.php @@ -20,10 +20,10 @@

Erro interno

Algo saiu do esperado do nosso lado. Já estamos trabalhando nisso — tente novamente em instantes.

-

Se precisar falar com o suporte, informe o código .

+

Se precisar falar com o suporte, informe o código .

- Voltar ao Painel + diff --git a/views/errors/502.php b/views/errors/502.php index 603211f..64cdc8d 100644 --- a/views/errors/502.php +++ b/views/errors/502.php @@ -20,7 +20,7 @@

Resposta inválida

Recebemos uma resposta inesperada ao processar sua solicitação. Tente novamente em instantes.

- Voltar ao Painel + diff --git a/views/errors/503.php b/views/errors/503.php index 4ec60ab..0b30802 100644 --- a/views/errors/503.php +++ b/views/errors/503.php @@ -20,7 +20,7 @@

Serviço indisponível

Estamos passando por instabilidade ou manutenção. Em breve tudo volta ao normal.

- Voltar ao Painel + diff --git a/views/errors/default.php b/views/errors/default.php index a4073b3..54a31e4 100644 --- a/views/errors/default.php +++ b/views/errors/default.php @@ -18,9 +18,9 @@

Ops! Algo deu errado.

-

Ocorreu um problema ao carregar esta página. Tente novamente em instantes ou volte para o painel.

+

Ocorreu um problema ao carregar esta página. Tente novamente em instantes ou volte para o início.

- Voltar ao Painel + From 0c97af3d4e989e71e622748f16f07a6ce86c07e2 Mon Sep 17 00:00:00 2001 From: andynnnfw Date: Tue, 6 Oct 2026 10:55:18 -0300 Subject: [PATCH 2/9] feat(helpers): format_document para exibir CPF e CNPJ - src/Support/helpers.php: nova funcao format_document (11 digitos vira CPF, 14 vira CNPJ, outro tamanho sai so com os digitos) - tests/Unit/HelpersTest.php: novo, cobre CPF, CNPJ, valor ja formatado, tamanho desconhecido e nulo --- src/Support/helpers.php | 12 ++++++++++++ tests/Unit/HelpersTest.php | 15 +++++++++++++++ 2 files changed, 27 insertions(+) create mode 100644 tests/Unit/HelpersTest.php diff --git a/src/Support/helpers.php b/src/Support/helpers.php index 183598b..bdd8433 100644 --- a/src/Support/helpers.php +++ b/src/Support/helpers.php @@ -27,6 +27,18 @@ function format_phone($phone): string { return $digits; } +// "52998224725" -> "529.982.247-25"; "11222333000181" -> "11.222.333/0001-81" (so pra exibicao) +function format_document($document): string { + $digits = preg_replace('/\D/', '', (string)$document); + if (strlen($digits) === 11) { + return sprintf('%s.%s.%s-%s', substr($digits, 0, 3), substr($digits, 3, 3), substr($digits, 6, 3), substr($digits, 9)); + } + if (strlen($digits) === 14) { + return sprintf('%s.%s.%s/%s-%s', substr($digits, 0, 2), substr($digits, 2, 3), substr($digits, 5, 3), substr($digits, 8, 4), substr($digits, 12)); + } + return $digits; +} + // fuso da aplicacao: APP_TIMEZONE do .env (nome IANA) ou horario de Brasilia. // o index.php e o bootstrap dos testes aplicam no PHP, e o Database usa o mesmo na conexao, // entao PHP e MySQL nunca ficam em fusos diferentes (independe do php.ini e do servidor de banco). diff --git a/tests/Unit/HelpersTest.php b/tests/Unit/HelpersTest.php new file mode 100644 index 0000000..2ac8ca0 --- /dev/null +++ b/tests/Unit/HelpersTest.php @@ -0,0 +1,15 @@ +assertSame('529.982.247-25', format_document('52998224725')); + $this->assertSame('11.222.333/0001-81', format_document('11222333000181')); + $this->assertSame('529.982.247-25', format_document('529.982.247-25'), 'ja formatado continua igual'); + $this->assertSame('123', format_document('123'), 'tamanho desconhecido sai so com os digitos'); + $this->assertSame('', format_document(null)); + } +} From 9b4bdaba5f547ce3600650139fcef8eaa97000c2 Mon Sep 17 00:00:00 2001 From: andynnnfw Date: Tue, 6 Oct 2026 10:55:27 -0300 Subject: [PATCH 3/9] feat(sessao): assinatura da senha para derrubar sessoes antigas - src/Support/SessionGuard.php: passwordSignature gera a assinatura guardada na sessao e passwordChanged diz se a sessao foi aberta com uma senha que nao e mais a da conta - tests/Unit/SessionGuardTest.php: sessao com senha antiga deixa de valer e sessao sem assinatura nao e derrubada --- src/Support/SessionGuard.php | 13 +++++++++++++ tests/Unit/SessionGuardTest.php | 14 ++++++++++++++ 2 files changed, 27 insertions(+) diff --git a/src/Support/SessionGuard.php b/src/Support/SessionGuard.php index b594d59..a869d11 100644 --- a/src/Support/SessionGuard.php +++ b/src/Support/SessionGuard.php @@ -13,6 +13,19 @@ public static function isExpired(?int $lastSeen, int $now, int $idle = self::IDL return $lastSeen !== null && ($now - $lastSeen) > $idle; } + // "assinatura" da senha atual, guardada na sessao no login. o authGuard compara com a do banco + // a cada requisicao: trocou a senha, as sessoes abertas com a senha antiga (outro aparelho, + // alguem que entrou sem permissao) deixam de valer. e um hash do hash: nao revela nada da senha. + public static function passwordSignature(string $passwordHash): string { + return hash('sha256', $passwordHash); + } + + // true se a sessao foi aberta com uma senha que nao e mais a da conta. + // sessao sem assinatura (aberta antes desta regra existir) nao e derrubada. + public static function passwordChanged(?string $sessionSignature, string $passwordHash): bool { + return $sessionSignature !== null && !hash_equals(self::passwordSignature($passwordHash), $sessionSignature); + } + // apaga os dados e o cookie da sessao (logout, conta desativada, sessao expirada) public static function destroy(): void { $_SESSION = []; diff --git a/tests/Unit/SessionGuardTest.php b/tests/Unit/SessionGuardTest.php index bbb632c..1dbb54d 100644 --- a/tests/Unit/SessionGuardTest.php +++ b/tests/Unit/SessionGuardTest.php @@ -17,6 +17,20 @@ public function testSessaoSemRegistroDeAtividadeNaoExpira(): void { $this->assertFalse(SessionGuard::isExpired(null, 1_800_000_000)); } + public function testSessaoAbertaComSenhaAntigaDeixaDeValer(): void { + $old = password_hash('antiga', PASSWORD_BCRYPT); + $new = password_hash('nova', PASSWORD_BCRYPT); + $signature = SessionGuard::passwordSignature($old); + + $this->assertFalse(SessionGuard::passwordChanged($signature, $old)); + $this->assertTrue(SessionGuard::passwordChanged($signature, $new)); + $this->assertStringNotContainsString($old, $signature, 'a sessao nao guarda o hash da senha'); + } + + public function testSessaoSemAssinaturaNaoEDerrubada(): void { + $this->assertFalse(SessionGuard::passwordChanged(null, password_hash('qualquer', PASSWORD_BCRYPT))); + } + public function testLimiteEUmDiaDeBalcao(): void { $this->assertSame(8 * 3600, SessionGuard::IDLE_SECONDS); } From 61540a16a278ef548db8cf8cec7da551991dc2de Mon Sep 17 00:00:00 2001 From: andynnnfw Date: Tue, 6 Oct 2026 10:55:47 -0300 Subject: [PATCH 4/9] feat(perfil): perfil do lojista e troca de senha - src/Controllers/MerchantController.php: renderProfile, handleProfile e handlePasswordChange (exige a senha atual, limite de 5 erros em 15 min); login grava password_sig e authGuard derruba sessao aberta com senha antiga; tamanho minimo da senha vira constante - src/Models/MerchantModel.php: findProfile, updateProfile e updatePasswordHash; findById traz o password_hash - views/merchant/profile.php: nova tela com os formularios de dados da loja e de troca de senha - views/partials/merchant-header.php: item Perfil no menu - views/partials/flash.php: mensagens perfil_atualizado, senha_alterada, senha_atual_incorreta e muitas_tentativas - public/index.php: rota merchant/profile - public/css/components.css: estilo de campo desabilitado - tests/Feature/ProfileTest.php: novo, 12 testes do fluxo de perfil e senha Task 20 do backlog. --- public/css/components.css | 7 + public/index.php | 1 + src/Controllers/MerchantController.php | 111 +++++++++++++- src/Models/MerchantModel.php | 41 ++++- tests/Feature/ProfileTest.php | 198 +++++++++++++++++++++++++ views/merchant/profile.php | 107 +++++++++++++ views/partials/flash.php | 4 + views/partials/merchant-header.php | 1 + 8 files changed, 467 insertions(+), 3 deletions(-) create mode 100644 tests/Feature/ProfileTest.php create mode 100644 views/merchant/profile.php diff --git a/public/css/components.css b/public/css/components.css index 2270e8b..b33a781 100644 --- a/public/css/components.css +++ b/public/css/components.css @@ -31,6 +31,13 @@ box-shadow: 0 0 0 3px var(--focus-ring); } +/* campo so de leitura (ex.: e-mail e documento no perfil): mesma caixa, texto apagado */ +.form-group input:disabled { + color: var(--text-muted); + background: var(--bg-body); + cursor: not-allowed; +} + /* Botão */ .btn-primary { width: 100%; diff --git a/public/index.php b/public/index.php index 27ef691..8496294 100644 --- a/public/index.php +++ b/public/index.php @@ -111,6 +111,7 @@ 'rewards' => $controller->renderRewards(), 'reward-edit' => $controller->renderRewardEdit(), 'customers' => $controller->renderCustomers(), + 'profile' => $controller->renderProfile(), default => (new ErrorController())->handle(404), }; break; diff --git a/src/Controllers/MerchantController.php b/src/Controllers/MerchantController.php index f92a636..e018341 100644 --- a/src/Controllers/MerchantController.php +++ b/src/Controllers/MerchantController.php @@ -39,6 +39,10 @@ class MerchantController { private const PER_PAGE = 20; private const MAX_LOGIN_FAILURES = 5; private const LOGIN_WINDOW_SECONDS = 900; + private const MIN_PASSWORD_LENGTH = 6; + // senha atual errada na troca de senha: mesmo limite do login, contado por conta + private const MAX_PASSWORD_FAILURES = 5; + private const PASSWORD_WINDOW_SECONDS = 900; private $db; private $merchantModel; @@ -388,7 +392,7 @@ public function handleRegister(){ redirect('merchant/register', ['error' => 'documento_invalido']); } - if (strlen($password) < 6) { + if (strlen($password) < self::MIN_PASSWORD_LENGTH) { redirect('merchant/register', ['error' => 'senha_curta']); } @@ -501,6 +505,7 @@ public function handleLogin(){ $_SESSION['merchant_name'] = $merchant['owner_name']; $_SESSION['store_name'] = $merchant['store_name']; $_SESSION['last_seen'] = time(); + $_SESSION['password_sig'] = SessionGuard::passwordSignature($merchant['password_hash']); redirect('merchant/dashboard', ['success' => 'logged']); } @@ -637,6 +642,104 @@ private function handleRewardEdit($merchantId, RewardModel $rewardModel) { redirect('merchant/rewards', ['success' => 'premio_editado']); } + // perfil do lojista: dados da loja e troca de senha (dois formularios na mesma tela) + public function renderProfile() { + $merchantId = $this->authGuard(); + + if ($_SERVER['REQUEST_METHOD'] === 'POST') { + $this->handleProfile($merchantId); + return; + } + + View::render('merchant/profile', [ + 'profile' => $this->merchantModel->findProfile($merchantId), + 'categories' => self::CATEGORIES, + 'states' => self::STATES, + 'minPassword' => self::MIN_PASSWORD_LENGTH, + ]); + } + + private function handleProfile($merchantId) { + Csrf::verify(); + + if (($_POST['action'] ?? '') === 'password') { + $this->handlePasswordChange($merchantId); + } + + // as mesmas regras do cadastro. e-mail e cpf/cnpj nao sao editaveis aqui: mesmo que venham no post, sao ignorados. + $fields = [ + 'owner_name' => trim((string)($_POST['owner_name'] ?? '')), + 'store_name' => trim((string)($_POST['shop_name'] ?? '')), + 'phone' => PhoneValidator::sanitize($_POST['phone'] ?? ''), + 'category' => (string)($_POST['category'] ?? ''), + 'address' => trim((string)($_POST['address'] ?? '')), + 'city' => trim((string)($_POST['city'] ?? '')), + 'state' => (string)($_POST['state'] ?? ''), + ]; + + if ($fields['owner_name'] === '' || $fields['store_name'] === '' || $fields['address'] === '' || $fields['city'] === '' + || !in_array($fields['state'], self::STATES, true) + || !array_key_exists($fields['category'], self::CATEGORIES)) { + redirect('merchant/profile', ['error' => 'campos_invalidos']); + } + + // tamanho das colunas conferido aqui: o mysql sem modo estrito cortaria o texto sem avisar + if (mb_strlen($fields['owner_name']) > 255 || mb_strlen($fields['store_name']) > 255 + || mb_strlen($fields['address']) > 255 || mb_strlen($fields['city']) > 100) { + redirect('merchant/profile', ['error' => 'dados_muito_longos']); + } + + if (!PhoneValidator::isValid($fields['phone'])) { + redirect('merchant/profile', ['error' => 'telefone_invalido']); + } + + $this->merchantModel->updateProfile($merchantId, $fields); + + redirect('merchant/profile', ['success' => 'perfil_atualizado']); + } + + // troca de senha: so com a senha atual (sessao aberta num aparelho esquecido nao basta pra tomar a conta) + private function handlePasswordChange($merchantId): never { + $current = (string)($_POST['current_password'] ?? ''); + $new = (string)($_POST['new_password'] ?? ''); + $confirm = (string)($_POST['new_password_confirm'] ?? ''); + + if ($current === '' || $new === '' || $confirm === '') { + redirect('merchant/profile', ['error' => 'campos_obrigatorios']); + } + + // limite de erros da senha atual, pra ninguem ficar testando senhas por este formulario + $limiter = new RateLimiter($this->db); + $key = 'merchant:' . $merchantId; + if ($limiter->tooMany('password_change', $key, self::MAX_PASSWORD_FAILURES, self::PASSWORD_WINDOW_SECONDS)) { + redirect('merchant/profile', ['error' => 'muitas_tentativas']); + } + + $merchant = $this->merchantModel->findById($merchantId); + if (!$merchant || !password_verify($current, $merchant['password_hash'])) { + $limiter->hit('password_change', $key); + redirect('merchant/profile', ['error' => 'senha_atual_incorreta']); + } + + if (strlen($new) < self::MIN_PASSWORD_LENGTH) { + redirect('merchant/profile', ['error' => 'senha_curta']); + } + + if (!hash_equals($new, $confirm)) { + redirect('merchant/profile', ['error' => 'senhas_diferentes']); + } + + $hash = password_hash($new, PASSWORD_BCRYPT); + $this->merchantModel->updatePasswordHash($merchantId, $hash); + $limiter->clear('password_change', $key); + + // esta sessao continua valendo (com id novo); as outras, abertas com a senha antiga, caem no authGuard + session_regenerate_id(true); + $_SESSION['password_sig'] = SessionGuard::passwordSignature($hash); + + redirect('merchant/profile', ['success' => 'senha_alterada']); + } + // nome, descricao e custo do formulario de premio; null se algo estiver invalido private function readRewardFields(): ?array { $name = trim((string)($_POST['name'] ?? '')); @@ -672,6 +775,12 @@ private function authGuard(): int { redirect('merchant/login', ['error' => 'conta_inativa']); } + // a senha foi trocada depois que esta sessao abriu (em outro aparelho): precisa entrar de novo + if (SessionGuard::passwordChanged($_SESSION['password_sig'] ?? null, $merchant['password_hash'])) { + SessionGuard::destroy(); + redirect('merchant/login', ['error' => 'sessao_expirada']); + } + // nome da loja sempre atualizado no menu (pode ter mudado desde o login) $_SESSION['last_seen'] = time(); $_SESSION['merchant_name'] = $merchant['owner_name']; diff --git a/src/Models/MerchantModel.php b/src/Models/MerchantModel.php index 9855fd1..3552d44 100644 --- a/src/Models/MerchantModel.php +++ b/src/Models/MerchantModel.php @@ -39,14 +39,51 @@ public function findByEmail($email){ return $stmt->fetch(\PDO::FETCH_ASSOC); // array associativo } - // dados que o authGuard confere a cada requisicao + // dados que o authGuard confere a cada requisicao (o hash da senha serve pra derrubar + // as sessoes antigas quando a senha e trocada, ver SessionGuard::passwordSignature) public function findById($merchantId) { - $stmt = $this->db->prepare('SELECT id, owner_name, store_name, status FROM merchants WHERE id = :id'); + $stmt = $this->db->prepare('SELECT id, owner_name, store_name, status, password_hash FROM merchants WHERE id = :id'); $stmt->execute([':id' => $merchantId]); return $stmt->fetch(\PDO::FETCH_ASSOC); } + // dados exibidos na tela de perfil; false se a conta nao existir + public function findProfile($merchantId) { + $stmt = $this->db->prepare( + 'SELECT owner_name, store_name, email, phone, category, cpf, cnpj, address, city, state + FROM merchants WHERE id = :id' + ); + $stmt->execute([':id' => $merchantId]); + + return $stmt->fetch(\PDO::FETCH_ASSOC); + } + + // dados da loja que o proprio lojista edita. e-mail (login) e cpf/cnpj (identidade) ficam de fora de proposito. + public function updateProfile($merchantId, array $fields): void { + $stmt = $this->db->prepare( + 'UPDATE merchants + SET owner_name = :owner_name, store_name = :store_name, phone = :phone, category = :category, + address = :address, city = :city, state = :state + WHERE id = :id' + ); + $stmt->execute([ + ':owner_name' => $fields['owner_name'], + ':store_name' => $fields['store_name'], + ':phone' => $fields['phone'], + ':category' => $fields['category'], + ':address' => $fields['address'], + ':city' => $fields['city'], + ':state' => $fields['state'], + ':id' => $merchantId, + ]); + } + + public function updatePasswordHash($merchantId, string $hash): void { + $stmt = $this->db->prepare('UPDATE merchants SET password_hash = :hash WHERE id = :id'); + $stmt->execute([':hash' => $hash, ':id' => $merchantId]); + } + public function findPublicCode($merchantId): ?string { $stmt = $this->db->prepare('SELECT public_code FROM merchants WHERE id = :id'); $stmt->execute([':id' => $merchantId]); diff --git a/tests/Feature/ProfileTest.php b/tests/Feature/ProfileTest.php new file mode 100644 index 0000000..1deae29 --- /dev/null +++ b/tests/Feature/ProfileTest.php @@ -0,0 +1,198 @@ + 'Ana Souza', + 'shop_name' => 'Padaria da Ana', + 'phone' => '(11) 97777-6666', + 'category' => 'alimentacao', + 'address' => 'Rua Nova, 10', + 'city' => 'Cotia', + 'state' => 'SP', + ]; + + private function merchantField(int $id, string $field) { + return $this->scalar("SELECT $field FROM merchants WHERE id = :id", [':id' => $id]); + } + + private function changePassword(string $current, string $new, ?string $confirm = null): ?string { + return $this->post('merchant/profile', [ + 'action' => 'password', + 'current_password' => $current, + 'new_password' => $new, + 'new_password_confirm' => $confirm ?? $new, + ])[1]; + } + + public function testPerfilExigeLogin(): void { + [$status, $location] = $this->get('merchant/profile'); + + $this->assertSame(302, $status); + $this->assertSame('merchant/login&error=sessao_expirada', $location); + } + + public function testTelaMostraOsDadosAtuaisEOMenuTemOPerfil(): void { + $this->createMerchant('loja@teste.test'); + $this->loginAs('loja@teste.test'); + + [$status] = $this->get('merchant/profile'); + + $this->assertSame(200, $status); + $this->assertStringContainsString('value="Loja Teste"', $this->lastBody); + $this->assertStringContainsString('value="Dona Teste"', $this->lastBody); + $this->assertStringContainsString('value="(11) 98888-7777"', $this->lastBody); + $this->assertStringContainsString('value="loja@teste.test" disabled', $this->lastBody); + $this->assertStringContainsString('