Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
42 changes: 42 additions & 0 deletions migrations/Version20260619080000.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
<?php

declare(strict_types=1);

namespace DoctrineMigrations;

use Doctrine\DBAL\Schema\Schema;
use Doctrine\DBAL\Types\Types;
use Doctrine\Migrations\AbstractMigration;

/**
* Add `name` and `status` columns to `user`.
*
* `name` is the display name introduced by #45; `status` is the
* `UserStatus` enum (`pending | approved | blocked`) introduced by
* #83 that gates login via the `UserCheckerInterface` landing in #63.
*
* Backfill any existing rows in `up()` so the new not-null constraints
* hold in environments that already have user data (default `name = ''`,
* default `status = 'approved'` — assume historic rows were trusted).
*/
final class Version20260619080000 extends AbstractMigration
{
public function getDescription(): string
{
return 'Add name and status columns to user.';
}

public function up(Schema $schema): void
{
$user = $schema->getTable('user');
$user->addColumn('name', Types::STRING, ['length' => 255, 'notnull' => true, 'default' => '']);
$user->addColumn('status', Types::STRING, ['length' => 32, 'notnull' => true, 'default' => 'approved']);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should default be "approved" in db context?
@tuj ?

}

public function down(Schema $schema): void
{
$user = $schema->getTable('user');
$user->dropColumn('status');
$user->dropColumn('name');
}
}
4 changes: 3 additions & 1 deletion src/Command/UserCreateCommand.php
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@ protected function configure(): void
{
$this
->addArgument('email', InputArgument::REQUIRED, 'The user\'s e-mail address (must be unique).')
->addArgument('name', InputArgument::REQUIRED, 'The user\'s display name.')
->addArgument('password', InputArgument::REQUIRED, 'The user\'s password in clear-text — will be hashed.');
}

Expand All @@ -53,10 +54,11 @@ protected function execute(InputInterface $input, OutputInterface $output): int
{
$io = new SymfonyStyle($input, $output);
$email = (string) $input->getArgument('email');
$name = (string) $input->getArgument('name');
$password = (string) $input->getArgument('password');

try {
$user = $this->userManager->createUser($email, $password);
$user = $this->userManager->createUser($email, $name, $password);
} catch (\DomainException|\InvalidArgumentException $e) {
$io->error($e->getMessage());

Expand Down
4 changes: 2 additions & 2 deletions src/DataFixtures/UserFixtures.php
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,7 @@ public function __construct(private readonly UserManager $userManager)
*/
public function load(ObjectManager $manager): void
{
$this->userManager->createUser('alice@example.test', 'password');
$this->userManager->createUser('bob@example.test', 'password');
$this->userManager->createUser('alice@example.test', 'Alice', 'password');
$this->userManager->createUser('bob@example.test', 'Bob', 'password');
}
}
32 changes: 32 additions & 0 deletions src/Entity/User.php
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,9 @@

namespace App\Entity;

use App\Enum\UserStatus;
use App\Repository\UserRepository;
use Doctrine\DBAL\Types\Types;
use Doctrine\ORM\Mapping as ORM;
use Symfony\Component\Security\Core\User\PasswordAuthenticatedUserInterface;
use Symfony\Component\Security\Core\User\UserInterface;
Expand Down Expand Up @@ -32,6 +34,12 @@ class User implements UserInterface, PasswordAuthenticatedUserInterface
#[ORM\Column]
private ?string $password = null;

#[ORM\Column(length: 255)]
private string $name = '';

#[ORM\Column(type: Types::STRING, length: 32, enumType: UserStatus::class)]
private UserStatus $status = UserStatus::Pending;

public function getId(): ?int
{
return $this->id;
Expand Down Expand Up @@ -96,6 +104,30 @@ public function setPassword(string $password): static
return $this;
}

public function getName(): string
{
return $this->name;
}

public function setName(string $name): static
{
$this->name = $name;

return $this;
}

public function getStatus(): UserStatus
{
return $this->status;
}

public function setStatus(UserStatus $status): static
{
$this->status = $status;

return $this;
}

/**
* Ensure the session doesn't contain actual password hashes by CRC32C-hashing them, as supported since Symfony 7.3.
*/
Expand Down
19 changes: 19 additions & 0 deletions src/Enum/UserStatus.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
<?php

declare(strict_types=1);

namespace App\Enum;

/**
* Identity-lifecycle state for a {@see \App\Entity\User}.
*
* Decided in ADR 006. The single source of truth for "may this credential
* sign in?". Authorisation (roles, voters) stays orthogonal — those answer
* "what may a signed-in user do", not "may this person sign in at all".
*/
enum UserStatus: string
{
case Pending = 'pending';
case Approved = 'approved';
case Blocked = 'blocked';
}
21 changes: 18 additions & 3 deletions src/Security/UserManager.php
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
namespace App\Security;

use App\Entity\User;
use App\Enum\UserStatus;
use App\Repository\UserRepository;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Component\PasswordHasher\Hasher\UserPasswordHasherInterface;
Expand Down Expand Up @@ -33,17 +34,29 @@ public function __construct(
/**
* Create a new persisted user with a hashed password.
*
* Defaults to `UserStatus::Approved` so the console / fixture paths
* land a usable account immediately. The registration flow (#62)
* passes `UserStatus::Pending` explicitly so a domain manager has
* to approve the user before they can sign in.
*
* @param string $email user e-mail; must be unique
* @param string $name display name; required, may be any non-null string
* @param string $plainPassword clear-text password, hashed before persistence
* @param list<string> $roles additional roles beyond the implicit `ROLE_USER`
* @param UserStatus $status identity-lifecycle status; defaults to {@see UserStatus::Approved}
*
* @return User the persisted user with an assigned id
*
* @throws \DomainException when a user with the same e-mail already exists
* @throws \InvalidArgumentException when `$plainPassword` is empty
*/
public function createUser(string $email, string $plainPassword, array $roles = []): User
{
public function createUser(
string $email,
string $name,
string $plainPassword,
array $roles = [],
UserStatus $status = UserStatus::Approved,
): User {
if ('' === $plainPassword) {
throw new \InvalidArgumentException('Password must not be empty.');
}
Expand All @@ -54,7 +67,9 @@ public function createUser(string $email, string $plainPassword, array $roles =

$user = (new User())
->setEmail($email)
->setRoles($roles);
->setName($name)
->setRoles($roles)
->setStatus($status);
$user->setPassword($this->passwordHasher->hashPassword($user, $plainPassword));

$this->entityManager->persist($user);
Expand Down
2 changes: 2 additions & 0 deletions tests/Integration/Command/UserCreateCommandTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ public function testCreatesUser(): void
{
$exit = $this->tester->execute([
'email' => 'charlie@example.test',
'name' => 'Charlie',
'password' => 'secret',
]);

Expand All @@ -44,6 +45,7 @@ public function testReportsFailureWhenEmailAlreadyExists(): void
// alice@example.test is in the baseline fixtures.
$exit = $this->tester->execute([
'email' => 'alice@example.test',
'name' => 'Alice',
'password' => 'second',
]);

Expand Down
24 changes: 24 additions & 0 deletions tests/Integration/Repository/UserRepositoryTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,10 @@

namespace App\Tests\Integration\Repository;

use App\Entity\User;
use App\Enum\UserStatus;
use App\Repository\UserRepository;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Bundle\FrameworkBundle\Test\KernelTestCase;
use Symfony\Component\Security\Core\Exception\UnsupportedUserException;
use Symfony\Component\Security\Core\User\PasswordAuthenticatedUserInterface;
Expand Down Expand Up @@ -64,4 +67,25 @@ public function getPassword(): ?string

$this->repository->upgradePassword($foreignUser, 'irrelevant');
}

Comment thread
martinyde marked this conversation as resolved.
// Verifies the UserStatus enum mapping round-trips: persisted then reloaded keeps the same enum case.
public function testStatusEnumRoundTripsThroughThePersistedRow(): void
{
$em = self::getContainer()->get(EntityManagerInterface::class);

$user = (new User())
->setEmail('eve@example.test')
->setName('Eve')
->setPassword('hash')
->setStatus(UserStatus::Blocked);
$em->persist($user);
$em->flush();
$em->clear();

$reloaded = $this->repository->find($user->getId());

self::assertNotNull($reloaded);
self::assertSame('Eve', $reloaded->getName());
self::assertSame(UserStatus::Blocked, $reloaded->getStatus());
}
}
13 changes: 8 additions & 5 deletions tests/Integration/Security/UserManagerTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@

namespace App\Tests\Integration\Security;

use App\Enum\UserStatus;
use App\Repository\UserRepository;
use App\Security\UserManager;
use Symfony\Bundle\FrameworkBundle\Test\KernelTestCase;
Expand Down Expand Up @@ -33,13 +34,15 @@ protected function setUp(): void
$this->passwordHasher = $container->get(UserPasswordHasherInterface::class);
}

// Tests that createUser() persists a new user with the password hashed and verifiable against the original plaintext.
// Tests the happy path: createUser persists a user with hashed password, name, default Approved status, and ROLE_USER.
public function testCreatesAndPersistsUserWithHashedPassword(): void
Comment thread
martinyde marked this conversation as resolved.
{
$user = $this->userManager->createUser('charlie@example.test', 'secret');
$user = $this->userManager->createUser('charlie@example.test', 'Charlie', 'secret');

self::assertNotNull($user->getId());
self::assertSame('charlie@example.test', $user->getEmail());
self::assertSame('Charlie', $user->getName());
self::assertSame(UserStatus::Approved, $user->getStatus());
self::assertSame(['ROLE_USER'], $user->getRoles());
self::assertNotSame('secret', $user->getPassword(), 'Password must be hashed.');
self::assertTrue(
Expand All @@ -52,7 +55,7 @@ public function testCreatesAndPersistsUserWithHashedPassword(): void
// Verifies that extra roles passed to createUser() are stored alongside the implicit ROLE_USER.
public function testCreateUserStoresExtraRoles(): void
{
$user = $this->userManager->createUser('admin@example.test', 'secret', ['ROLE_ADMIN']);
$user = $this->userManager->createUser('admin@example.test', 'Admin', 'secret', ['ROLE_ADMIN']);

self::assertSame(['ROLE_ADMIN', 'ROLE_USER'], $user->getRoles());
}
Expand All @@ -64,7 +67,7 @@ public function testCreateUserRejectsDuplicateEmail(): void
$this->expectException(\DomainException::class);
$this->expectExceptionMessage('alice@example.test');

$this->userManager->createUser('alice@example.test', 'other');
$this->userManager->createUser('alice@example.test', 'Alice', 'other');
}

// Ensures createUser() throws InvalidArgumentException when the password is empty.
Expand All @@ -73,7 +76,7 @@ public function testCreateUserRejectsEmptyPassword(): void
$this->expectException(\InvalidArgumentException::class);
$this->expectExceptionMessage('Password must not be empty.');

$this->userManager->createUser('charlie@example.test', '');
$this->userManager->createUser('charlie@example.test', 'Charlie', '');
}

// Tests that changePassword() replaces the stored hash and the new password verifies (while the old one no longer does).
Expand Down
15 changes: 11 additions & 4 deletions tests/Unit/DataFixtures/UserFixturesTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@

use App\DataFixtures\UserFixtures;
use App\Entity\User;
use App\Enum\UserStatus;
use App\Repository\UserRepository;
use App\Security\UserManager;
use Doctrine\ORM\EntityManagerInterface;
Expand All @@ -31,12 +32,12 @@ public function testLoadPersistsAliceAndBobWithFixturePassword(): void
$userRepository->method('findOneBy')->willReturn(null);
$passwordHasher->method('hashPassword')->willReturn('hashed');

$persistedEmails = [];
$persisted = [];
$entityManager->expects(self::exactly(2))
->method('persist')
->willReturnCallback(function (object $entity) use (&$persistedEmails): void {
->willReturnCallback(function (object $entity) use (&$persisted): void {
\assert($entity instanceof User);
$persistedEmails[] = $entity->getEmail();
$persisted[] = $entity;
});
$entityManager->expects(self::exactly(2))->method('flush');

Expand All @@ -45,6 +46,12 @@ public function testLoadPersistsAliceAndBobWithFixturePassword(): void

$fixture->load($this->createMock(ObjectManager::class));

self::assertSame(['alice@example.test', 'bob@example.test'], $persistedEmails);
$emails = array_map(static fn (User $u): ?string => $u->getEmail(), $persisted);
$names = array_map(static fn (User $u): string => $u->getName(), $persisted);
$statuses = array_map(static fn (User $u): UserStatus => $u->getStatus(), $persisted);

self::assertSame(['alice@example.test', 'bob@example.test'], $emails);
self::assertSame(['Alice', 'Bob'], $names);
self::assertSame([UserStatus::Approved, UserStatus::Approved], $statuses);
}
}
Loading
Loading