<?php

declare(strict_types=1);

/*
 * This file is part of the Ferienpass package.
 *
 * (c) Richard Henkenjohann <richard@ferienpass.online>
 *
 * For more information visit the project website <https://ferienpass.online>
 * or the documentation under <https://docs.ferienpass.online>.
 */

namespace Ferienpass\CmsBundle\Tests\Security;

use Ferienpass\CmsBundle\Security\PendingRegistrationUserEntityGuesser;
use Ferienpass\CoreBundle\Entity\User;
use Ferienpass\CoreBundle\Repository\UserRepository;
use Ferienpass\CoreBundle\Repository\WebauthnUserEntityRepository;
use PHPUnit\Framework\TestCase;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\RequestStack;
use Symfony\Component\HttpFoundation\Session\SessionInterface;
use Webauthn\Bundle\Exception\MissingUserEntityException;
use Webauthn\PublicKeyCredentialUserEntity;

/**
 * The pending-registration guesser drives the *public* `/register/passkey/*`
 * creation endpoint. It must only ever resolve to the freshly created,
 * still-disabled account from the caller's own sign-up — never to an arbitrary
 * account picked by an attacker.
 *
 * Regression for the account-takeover finding: previously the guesser resolved
 * the account from a session e-mail that {@see Registration::submit()} also set
 * for *existing* accounts, so an attacker could submit a victim's e-mail and
 * then bind their own authenticator to the victim's account.
 */
final class PendingRegistrationUserEntityGuesserTest extends TestCase
{
    private const UUID = '11111111-1111-4111-8111-111111111111';

    public function testReturnsUserEntityForPendingDisabledAccount(): void
    {
        $expected = new PublicKeyCredentialUserEntity('member@example.com', self::UUID, 'Member', null);

        $user = $this->createMock(User::class);
        $user->method('isDisabled')->willReturn(true);

        $users = $this->createMock(UserRepository::class);
        $users->method('findOneBy')->willReturn($user);

        $userEntities = $this->createMock(WebauthnUserEntityRepository::class);
        $userEntities->method('findOneByUserHandle')->with(self::UUID)->willReturn($expected);

        $guesser = $this->guesser($this->session([
            'registration.passkey' => true,
            'registration.passkey_uuid' => self::UUID,
        ]), $userEntities, $users);

        self::assertSame($expected, $guesser->findUserEntity(new Request()));
    }

    public function testRejectsWhenIntentFlagMissing(): void
    {
        // The "account already exists" branch removes registration.passkey; the
        // attacker's session never carries the intent flag for a victim account.
        $this->expectException(MissingUserEntityException::class);

        $this->guesser($this->session([
            'registration.passkey_uuid' => self::UUID,
        ]))->findUserEntity(new Request());
    }

    public function testRejectsWhenBoundUuidMissing(): void
    {
        $this->expectException(MissingUserEntityException::class);

        $this->guesser($this->session([
            'registration.passkey' => true,
        ]))->findUserEntity(new Request());
    }

    public function testRejectsWhenBoundUuidIsNotAValidUuid(): void
    {
        $this->expectException(MissingUserEntityException::class);

        $this->guesser($this->session([
            'registration.passkey' => true,
            'registration.passkey_uuid' => 'not-a-uuid',
        ]))->findUserEntity(new Request());
    }

    public function testRejectsWhenAccountDoesNotExist(): void
    {
        $users = $this->createMock(UserRepository::class);
        $users->method('findOneBy')->willReturn(null);

        $this->expectException(MissingUserEntityException::class);

        $this->guesser($this->session([
            'registration.passkey' => true,
            'registration.passkey_uuid' => self::UUID,
        ]), null, $users)->findUserEntity(new Request());
    }

    public function testRejectsWhenAccountIsAlreadyEnabled(): void
    {
        // An already-activated account must not be attachable through this
        // public endpoint — that path is exactly the takeover vector. Active
        // accounts add passkeys via the authenticated /passkey/* endpoints.
        $user = $this->createMock(User::class);
        $user->method('isDisabled')->willReturn(false);

        $users = $this->createMock(UserRepository::class);
        $users->method('findOneBy')->willReturn($user);

        $userEntities = $this->createMock(WebauthnUserEntityRepository::class);
        $userEntities->expects(self::never())->method('findOneByUserHandle');

        $this->expectException(MissingUserEntityException::class);

        $this->guesser($this->session([
            'registration.passkey' => true,
            'registration.passkey_uuid' => self::UUID,
        ]), $userEntities, $users)->findUserEntity(new Request());
    }

    /**
     * @param array<string, mixed> $values
     */
    private function session(array $values): SessionInterface
    {
        $session = $this->createMock(SessionInterface::class);
        $session->method('get')->willReturnCallback(
            static fn (string $name, mixed $default = null): mixed => $values[$name] ?? $default
        );

        return $session;
    }

    private function guesser(SessionInterface $session, ?WebauthnUserEntityRepository $userEntities = null, ?UserRepository $users = null): PendingRegistrationUserEntityGuesser
    {
        $requestStack = $this->createMock(RequestStack::class);
        $requestStack->method('getSession')->willReturn($session);

        return new PendingRegistrationUserEntityGuesser(
            $requestStack,
            $userEntities ?? $this->createMock(WebauthnUserEntityRepository::class),
            $users ?? $this->createMock(UserRepository::class),
        );
    }
}
