feat: PIN self-service P3 (/admin/profile/pin) #16
7 changed files with 515 additions and 1 deletions
37
src/app/Auth/UserRepository.php
Normal file
37
src/app/Auth/UserRepository.php
Normal file
|
|
@ -0,0 +1,37 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Auth;
|
||||
|
||||
use App\Core\DatabaseInterface;
|
||||
|
||||
/**
|
||||
* Ecritures sur l'entite user necessaires hors du flux d'authentification
|
||||
* (definition du PIN en self-service ici ; la gestion complete des comptes
|
||||
* arrive avec le CRUD Users). Lecture seule d'affichage = UserDirectory.
|
||||
*/
|
||||
final class UserRepository
|
||||
{
|
||||
public function __construct(private readonly DatabaseInterface $db)
|
||||
{
|
||||
}
|
||||
|
||||
/**
|
||||
* Retourne le nombre de lignes affectees (1 attendu). Le hash argon2id
|
||||
* change a chaque appel (sel aleatoire), donc une cible existante donne
|
||||
* toujours 1 ; 0 revele une cible inexistante (defense en profondeur).
|
||||
*/
|
||||
public function setPinHash(int $userId, string $hash): int
|
||||
{
|
||||
return $this->db->execute('UPDATE user SET pin_hash = :hash WHERE id = :id', ['hash' => $hash, 'id' => $userId]);
|
||||
}
|
||||
|
||||
public function pinIsSet(int $userId): bool
|
||||
{
|
||||
return $this->db->fetch(
|
||||
'SELECT id FROM user WHERE id = :id AND pin_hash IS NOT NULL',
|
||||
['id' => $userId],
|
||||
) !== null;
|
||||
}
|
||||
}
|
||||
116
src/app/Controllers/ProfileController.php
Normal file
116
src/app/Controllers/ProfileController.php
Normal file
|
|
@ -0,0 +1,116 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Controllers;
|
||||
|
||||
use App\Auth\Csrf;
|
||||
use App\Auth\GuardResult;
|
||||
use App\Auth\PasswordHasher;
|
||||
use App\Auth\PinVerifier;
|
||||
use App\Auth\UserRepository;
|
||||
use App\Core\Response;
|
||||
|
||||
/**
|
||||
* Profil self-service : definition / changement du PIN d'action sensible de
|
||||
* l'utilisateur connecte (prerequis au modele "identifiant equipier + PIN" des
|
||||
* actions sensibles, RG-T13). Accessible a tout utilisateur authentifie ; aucune
|
||||
* permission specifique (on n'agit que sur son propre compte = session userId).
|
||||
*
|
||||
* Non `final` : les tests sous-classent pour injecter des doubles.
|
||||
*/
|
||||
class ProfileController extends AdminController
|
||||
{
|
||||
/**
|
||||
* @param array<string, string> $params
|
||||
*/
|
||||
public function showPin(array $params = []): Response
|
||||
{
|
||||
$guard = $this->guard();
|
||||
if ($guard instanceof Response) {
|
||||
return $guard;
|
||||
}
|
||||
|
||||
$userId = $guard->userId;
|
||||
if ($userId === null) {
|
||||
return Response::make('', 302, ['Location' => '/login']);
|
||||
}
|
||||
|
||||
return $this->adminView('admin/profile/pin', [
|
||||
'title' => 'Mon PIN - Wakdo Admin',
|
||||
'activeNav' => '',
|
||||
'pinIsSet' => $this->userRepository()->pinIsSet($userId),
|
||||
'error' => null,
|
||||
], $guard);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, string> $params
|
||||
*/
|
||||
public function updatePin(array $params = []): Response
|
||||
{
|
||||
$guard = $this->guard();
|
||||
if ($guard instanceof Response) {
|
||||
return $guard;
|
||||
}
|
||||
|
||||
$userId = $guard->userId;
|
||||
if ($userId === null) {
|
||||
return Response::make('', 302, ['Location' => '/login']);
|
||||
}
|
||||
|
||||
$form = $this->request->formBody();
|
||||
if (!Csrf::validate($this->sessionManager(), $form['_csrf'] ?? null)) {
|
||||
return Response::make('Requete invalide.', 403, ['Content-Type' => 'text/plain; charset=utf-8']);
|
||||
}
|
||||
|
||||
$pin = $form['pin'] ?? '';
|
||||
$confirm = $form['pin_confirm'] ?? '';
|
||||
$error = null;
|
||||
|
||||
if (!$this->pinVerifier()->meetsLengthPolicy($pin)) {
|
||||
$error = 'Le PIN doit etre uniquement numerique et respecter la longueur requise.';
|
||||
} elseif ($pin !== $confirm) {
|
||||
$error = 'Les PIN ne correspondent pas.';
|
||||
}
|
||||
|
||||
if ($error !== null) {
|
||||
return $this->renderPinForm($guard, $userId, $error, 422);
|
||||
}
|
||||
|
||||
// Gate sur 1 ligne affectee : une cible inexistante (0 ligne) ne doit pas
|
||||
// produire un faux "PIN enregistre" (defense en profondeur).
|
||||
if ($this->userRepository()->setPinHash($userId, $this->passwordHasher()->hash($pin)) !== 1) {
|
||||
return $this->renderPinForm($guard, $userId, 'Echec de l enregistrement du PIN.', 500);
|
||||
}
|
||||
|
||||
$this->setFlash('PIN enregistre.');
|
||||
|
||||
return Response::make('', 302, ['Location' => '/admin/profile/pin']);
|
||||
}
|
||||
|
||||
private function renderPinForm(GuardResult $guard, int $userId, ?string $error, int $status): Response
|
||||
{
|
||||
return $this->adminView('admin/profile/pin', [
|
||||
'title' => 'Mon PIN - Wakdo Admin',
|
||||
'activeNav' => '',
|
||||
'pinIsSet' => $this->userRepository()->pinIsSet($userId),
|
||||
'error' => $error,
|
||||
], $guard, $status);
|
||||
}
|
||||
|
||||
protected function userRepository(): UserRepository
|
||||
{
|
||||
return new UserRepository($this->database);
|
||||
}
|
||||
|
||||
protected function pinVerifier(): PinVerifier
|
||||
{
|
||||
return new PinVerifier($this->database, $this->config, $this->passwordHasher());
|
||||
}
|
||||
|
||||
protected function passwordHasher(): PasswordHasher
|
||||
{
|
||||
return new PasswordHasher($this->config);
|
||||
}
|
||||
}
|
||||
49
src/app/Views/admin/profile/pin.php
Normal file
49
src/app/Views/admin/profile/pin.php
Normal file
|
|
@ -0,0 +1,49 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
/**
|
||||
* Formulaire de definition / changement du PIN de l'utilisateur connecte.
|
||||
* Injecte dans admin/layout.php. Le PIN sert a re-autoriser les actions sensibles.
|
||||
*
|
||||
* @var string $csrfToken
|
||||
* @var bool $pinIsSet
|
||||
* @var string|null $error
|
||||
*/
|
||||
|
||||
$csrf = htmlspecialchars($csrfToken ?? '', ENT_QUOTES, 'UTF-8');
|
||||
$alreadySet = isset($pinIsSet) && $pinIsSet === true;
|
||||
$errorMessage = isset($error) && is_string($error) ? $error : null;
|
||||
?>
|
||||
<div class="page-header">
|
||||
<div>
|
||||
<h1 class="page-title">Mon PIN</h1>
|
||||
<p class="page-subtitle">PIN de confirmation des actions sensibles (annulation, prix, suppressions...)</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<section>
|
||||
<p><small>Statut : <?= $alreadySet ? 'un PIN est defini.' : 'aucun PIN defini pour l instant.' ?></small></p>
|
||||
|
||||
<?php if ($errorMessage !== null): ?>
|
||||
<p role="alert"><?= htmlspecialchars($errorMessage, ENT_QUOTES, 'UTF-8') ?></p>
|
||||
<?php endif; ?>
|
||||
|
||||
<form method="post" action="/admin/profile/pin" class="form-card">
|
||||
<input type="hidden" name="_csrf" value="<?= $csrf ?>">
|
||||
|
||||
<div class="form-group">
|
||||
<label class="form-label" for="pin">Nouveau PIN</label>
|
||||
<input class="form-input" type="password" id="pin" name="pin" inputmode="numeric" autocomplete="off" required>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label class="form-label" for="pin_confirm">Confirmer le PIN</label>
|
||||
<input class="form-input" type="password" id="pin_confirm" name="pin_confirm" inputmode="numeric" autocomplete="off" required>
|
||||
</div>
|
||||
|
||||
<div class="form-actions">
|
||||
<button class="btn btn-primary" type="submit">Enregistrer</button>
|
||||
</div>
|
||||
</form>
|
||||
</section>
|
||||
|
|
@ -18,6 +18,7 @@ use App\Controllers\HealthController;
|
|||
use App\Controllers\HomeController;
|
||||
use App\Controllers\MeController;
|
||||
use App\Controllers\PasswordResetController;
|
||||
use App\Controllers\ProfileController;
|
||||
use App\Core\Autoloader;
|
||||
use App\Core\Config;
|
||||
use App\Core\Database;
|
||||
|
|
@ -74,6 +75,10 @@ try {
|
|||
$router->add('POST', '/admin/categories/{id}', [CategoryController::class, 'update']);
|
||||
$router->add('POST', '/admin/categories/{id}/toggle', [CategoryController::class, 'toggle']);
|
||||
|
||||
// Profil self-service : definition du PIN d'action sensible (RG-T13).
|
||||
$router->add('GET', '/admin/profile/pin', [ProfileController::class, 'showPin']);
|
||||
$router->add('POST', '/admin/profile/pin', [ProfileController::class, 'updatePin']);
|
||||
|
||||
$response = $router->dispatch(Request::fromGlobals());
|
||||
$response->send();
|
||||
} catch (Throwable $exception) {
|
||||
|
|
|
|||
80
tests/Integration/UserRepositoryDbTest.php
Normal file
80
tests/Integration/UserRepositoryDbTest.php
Normal file
|
|
@ -0,0 +1,80 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Tests\Integration;
|
||||
|
||||
use PHPUnit\Framework\TestCase;
|
||||
use Throwable;
|
||||
use App\Auth\PasswordHasher;
|
||||
use App\Auth\UserRepository;
|
||||
use App\Core\Config;
|
||||
use App\Core\Database;
|
||||
|
||||
/**
|
||||
* Ecriture du PIN (UserRepository) contre une vraie MariaDB. Auto-skip si
|
||||
* WAKDO_DB_TESTS != 1. User jetable (.invalid), supprime en tearDown.
|
||||
*/
|
||||
final class UserRepositoryDbTest extends TestCase
|
||||
{
|
||||
private Database $db;
|
||||
private Config $config;
|
||||
private int $userId = 0;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
if (getenv('WAKDO_DB_TESTS') !== '1') {
|
||||
self::markTestSkipped('Tests DB desactives (definir WAKDO_DB_TESTS=1 + DB_*).');
|
||||
}
|
||||
|
||||
$this->config = new Config();
|
||||
$this->db = new Database($this->config);
|
||||
|
||||
try {
|
||||
$this->db->fetch('SELECT 1');
|
||||
} catch (Throwable $exception) {
|
||||
self::markTestSkipped('Base injoignable: ' . $exception->getMessage());
|
||||
}
|
||||
|
||||
$roleId = (int) ($this->db->fetch('SELECT id FROM role ORDER BY id LIMIT 1')['id'] ?? 0);
|
||||
$hasher = new PasswordHasher($this->config);
|
||||
$this->db->execute(
|
||||
'INSERT INTO user (email, password_hash, first_name, last_name, role_id, is_active) '
|
||||
. 'VALUES (:email, :pwd, :fn, :ln, :role, 1)',
|
||||
[
|
||||
'email' => 'it-userrepo-' . bin2hex(random_bytes(6)) . '@wakdo.invalid',
|
||||
'pwd' => $hasher->hash('IntegrationPass1'),
|
||||
'fn' => 'Integration',
|
||||
'ln' => 'UserRepo',
|
||||
'role' => $roleId,
|
||||
],
|
||||
);
|
||||
$this->userId = (int) ($this->db->fetch('SELECT LAST_INSERT_ID() AS id')['id'] ?? 0);
|
||||
}
|
||||
|
||||
protected function tearDown(): void
|
||||
{
|
||||
if ($this->userId !== 0) {
|
||||
$this->db->execute('DELETE FROM user WHERE id = :id', ['id' => $this->userId]);
|
||||
$this->userId = 0;
|
||||
}
|
||||
}
|
||||
|
||||
public function testSetPinHashAndPinIsSet(): void
|
||||
{
|
||||
$repo = new UserRepository($this->db);
|
||||
$hasher = new PasswordHasher($this->config);
|
||||
|
||||
// Aucun PIN au depart.
|
||||
self::assertFalse($repo->pinIsSet($this->userId));
|
||||
|
||||
$repo->setPinHash($this->userId, $hasher->hash('4729'));
|
||||
|
||||
self::assertTrue($repo->pinIsSet($this->userId));
|
||||
|
||||
// Le hash stocke est verifiable et n'est pas le PIN en clair.
|
||||
$stored = (string) ($this->db->fetch('SELECT pin_hash FROM user WHERE id = :id', ['id' => $this->userId])['pin_hash'] ?? '');
|
||||
self::assertNotSame('4729', $stored);
|
||||
self::assertTrue($hasher->verify('4729', $stored));
|
||||
}
|
||||
}
|
||||
|
|
@ -117,9 +117,15 @@ final class FakeDatabase implements DatabaseInterface
|
|||
/** Resultat de CategoryRepository::slugExists(). */
|
||||
public bool $categorySlugTaken = false;
|
||||
|
||||
/** Resultat de UserRepository::pinIsSet() (true = un PIN est defini). */
|
||||
public bool $userPinSet = false;
|
||||
|
||||
/** Si non nul, execute() leve cette exception (simulation panne DB / violation de contrainte). */
|
||||
public ?Throwable $failOnExecute = null;
|
||||
|
||||
/** Nombre de lignes affectees renvoye par execute() (1 par defaut). */
|
||||
public int $executeRowCount = 1;
|
||||
|
||||
/** @var list<array{sql: string, params: array<string|int, mixed>}> */
|
||||
public array $writes = [];
|
||||
|
||||
|
|
@ -166,6 +172,10 @@ final class FakeDatabase implements DatabaseInterface
|
|||
return $this->pinUserRow;
|
||||
}
|
||||
|
||||
if (str_contains($sql, 'FROM user WHERE id = :id AND pin_hash IS NOT NULL')) {
|
||||
return $this->userPinSet ? ['id' => 1] : null;
|
||||
}
|
||||
|
||||
if (str_contains($sql, 'FROM category WHERE id = :id')) {
|
||||
return $this->categoryRow;
|
||||
}
|
||||
|
|
@ -216,7 +226,7 @@ final class FakeDatabase implements DatabaseInterface
|
|||
|
||||
$this->writes[] = ['sql' => $sql, 'params' => $params];
|
||||
|
||||
return 1;
|
||||
return $this->executeRowCount;
|
||||
}
|
||||
|
||||
public function transaction(callable $fn): void
|
||||
|
|
|
|||
217
tests/Unit/Admin/ProfileControllerTest.php
Normal file
217
tests/Unit/Admin/ProfileControllerTest.php
Normal file
|
|
@ -0,0 +1,217 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Tests\Unit\Admin;
|
||||
|
||||
use PHPUnit\Framework\TestCase;
|
||||
use App\Auth\Authorizer;
|
||||
use App\Auth\Csrf;
|
||||
use App\Auth\SessionGuard;
|
||||
use App\Auth\SessionManager;
|
||||
use App\Auth\UserDirectory;
|
||||
use App\Auth\UserRepository;
|
||||
use App\Controllers\ProfileController;
|
||||
use App\Core\Config;
|
||||
use App\Core\Database;
|
||||
use App\Core\Request;
|
||||
use App\Tests\Support\FakeDatabase;
|
||||
|
||||
final class TestProfileController extends ProfileController
|
||||
{
|
||||
public function __construct(
|
||||
Request $request,
|
||||
Config $config,
|
||||
Database $database,
|
||||
private readonly SessionManager $testSession,
|
||||
private readonly FakeDatabase $fakeDb,
|
||||
) {
|
||||
parent::__construct($request, $config, $database);
|
||||
}
|
||||
|
||||
protected function sessionManager(): SessionManager
|
||||
{
|
||||
return $this->testSession;
|
||||
}
|
||||
|
||||
protected function sessionGuard(): SessionGuard
|
||||
{
|
||||
return new SessionGuard($this->testSession, $this->fakeDb, $this->config);
|
||||
}
|
||||
|
||||
protected function authorizer(): Authorizer
|
||||
{
|
||||
return new Authorizer($this->fakeDb);
|
||||
}
|
||||
|
||||
protected function userDirectory(): UserDirectory
|
||||
{
|
||||
return new UserDirectory($this->fakeDb);
|
||||
}
|
||||
|
||||
protected function userRepository(): UserRepository
|
||||
{
|
||||
return new UserRepository($this->fakeDb);
|
||||
}
|
||||
}
|
||||
|
||||
final class ProfileControllerTest extends TestCase
|
||||
{
|
||||
/** @var list<string> */
|
||||
private array $touchedKeys = [];
|
||||
|
||||
private SessionManager $session;
|
||||
private string $csrf = '';
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
$this->setEnv('SESSION_LIFETIME_IDLE', '14400');
|
||||
$this->setEnv('SESSION_LIFETIME_ABSOLUTE', '36000');
|
||||
$this->setEnv('STAFF_PIN_MIN_LENGTH', '4');
|
||||
$this->setEnv('STAFF_PIN_MAX_LENGTH', '12');
|
||||
$this->setEnv('ARGON2_MEMORY_COST', '1024');
|
||||
$this->setEnv('ARGON2_TIME_COST', '1');
|
||||
$this->setEnv('ARGON2_THREADS', '1');
|
||||
|
||||
$this->session = new SessionManager(new Config(), true);
|
||||
$now = time();
|
||||
$this->session->set('user_id', 1);
|
||||
$this->session->set('role_id', 1);
|
||||
$this->session->set('logged_in_at', $now - 100);
|
||||
$this->session->set('last_activity', $now - 50);
|
||||
$this->csrf = Csrf::token($this->session);
|
||||
}
|
||||
|
||||
protected function tearDown(): void
|
||||
{
|
||||
foreach ($this->touchedKeys as $key) {
|
||||
putenv($key);
|
||||
}
|
||||
$this->touchedKeys = [];
|
||||
}
|
||||
|
||||
private function setEnv(string $key, string $value): void
|
||||
{
|
||||
$this->touchedKeys[] = $key;
|
||||
putenv($key . '=' . $value);
|
||||
}
|
||||
|
||||
private function permittedDb(): FakeDatabase
|
||||
{
|
||||
$db = new FakeDatabase();
|
||||
$db->guardUserRow = ['is_active' => 1];
|
||||
$db->userDisplayRow = ['first_name' => 'Corentin', 'last_name' => 'J', 'role_label' => 'Administrateur'];
|
||||
$db->canResult = true;
|
||||
$db->permissionCodes = ['category.manage'];
|
||||
|
||||
return $db;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, string> $form
|
||||
*/
|
||||
private function post(array $form): Request
|
||||
{
|
||||
return new Request(
|
||||
'POST',
|
||||
'/admin/profile/pin',
|
||||
[],
|
||||
['content-type' => 'application/x-www-form-urlencoded'],
|
||||
http_build_query($form),
|
||||
'203.0.113.5',
|
||||
);
|
||||
}
|
||||
|
||||
private function controller(Request $request, FakeDatabase $db): TestProfileController
|
||||
{
|
||||
return new TestProfileController($request, new Config(), new Database(new Config()), $this->session, $db);
|
||||
}
|
||||
|
||||
public function testRedirectsToLoginWithoutSession(): void
|
||||
{
|
||||
$request = new Request('GET', '/admin/profile/pin', [], [], '', '203.0.113.5');
|
||||
$response = $this->controller($request, new FakeDatabase())->showPin();
|
||||
|
||||
self::assertSame(302, $response->status());
|
||||
self::assertSame('/login', $response->header('Location'));
|
||||
}
|
||||
|
||||
public function testShowPinReflectsStatus(): void
|
||||
{
|
||||
$request = new Request('GET', '/admin/profile/pin', [], [], '', '203.0.113.5');
|
||||
|
||||
$db = $this->permittedDb();
|
||||
$db->userPinSet = false;
|
||||
$response = $this->controller($request, $db)->showPin();
|
||||
self::assertSame(200, $response->status());
|
||||
self::assertStringContainsString('name="pin"', $response->body());
|
||||
self::assertStringContainsString('aucun PIN defini', $response->body());
|
||||
|
||||
$db2 = $this->permittedDb();
|
||||
$db2->userPinSet = true;
|
||||
self::assertStringContainsString('un PIN est defini', $this->controller($request, $db2)->showPin()->body());
|
||||
}
|
||||
|
||||
public function testUpdatePinValidStoresHashAndRedirects(): void
|
||||
{
|
||||
$db = $this->permittedDb();
|
||||
$response = $this->controller($this->post(['_csrf' => $this->csrf, 'pin' => '4729', 'pin_confirm' => '4729']), $db)->updatePin();
|
||||
|
||||
self::assertSame(302, $response->status());
|
||||
self::assertSame('/admin/profile/pin', $response->header('Location'));
|
||||
self::assertSame('PIN enregistre.', $this->session->get('_flash'));
|
||||
|
||||
// Invariant central : la cible est l'utilisateur de la SESSION (1, pose en
|
||||
// setUp), jamais un champ de formulaire ; et c'est un hash, pas le PIN clair.
|
||||
$write = null;
|
||||
foreach ($db->writes as $w) {
|
||||
if (str_contains($w['sql'], 'UPDATE user SET pin_hash')) {
|
||||
$write = $w;
|
||||
break;
|
||||
}
|
||||
}
|
||||
self::assertNotNull($write);
|
||||
self::assertSame(1, $write['params']['id'] ?? null);
|
||||
self::assertNotSame('4729', $write['params']['hash'] ?? null);
|
||||
}
|
||||
|
||||
public function testUpdatePinFailsWhenNoRowAffected(): void
|
||||
{
|
||||
// Cible inexistante (0 ligne affectee) : pas de faux succes, pas de flash.
|
||||
$db = $this->permittedDb();
|
||||
$db->executeRowCount = 0;
|
||||
|
||||
$response = $this->controller($this->post(['_csrf' => $this->csrf, 'pin' => '4729', 'pin_confirm' => '4729']), $db)->updatePin();
|
||||
|
||||
self::assertSame(500, $response->status());
|
||||
self::assertNull($this->session->get('_flash'));
|
||||
}
|
||||
|
||||
public function testUpdatePinMismatchRerenders422(): void
|
||||
{
|
||||
$db = $this->permittedDb();
|
||||
$response = $this->controller($this->post(['_csrf' => $this->csrf, 'pin' => '4729', 'pin_confirm' => '0000']), $db)->updatePin();
|
||||
|
||||
self::assertSame(422, $response->status());
|
||||
self::assertStringContainsString('ne correspondent pas', $response->body());
|
||||
self::assertFalse($db->wrote('UPDATE user SET pin_hash'));
|
||||
}
|
||||
|
||||
public function testUpdatePinTooShortRerenders422(): void
|
||||
{
|
||||
$db = $this->permittedDb();
|
||||
$response = $this->controller($this->post(['_csrf' => $this->csrf, 'pin' => '12', 'pin_confirm' => '12']), $db)->updatePin();
|
||||
|
||||
self::assertSame(422, $response->status());
|
||||
self::assertFalse($db->wrote('UPDATE user SET pin_hash'));
|
||||
}
|
||||
|
||||
public function testUpdatePinRejectsInvalidCsrf(): void
|
||||
{
|
||||
$db = $this->permittedDb();
|
||||
$response = $this->controller($this->post(['_csrf' => 'wrong', 'pin' => '4729', 'pin_confirm' => '4729']), $db)->updatePin();
|
||||
|
||||
self::assertSame(403, $response->status());
|
||||
self::assertFalse($db->wrote('UPDATE user SET pin_hash'));
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue