Skip to content
Closed
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
111 changes: 111 additions & 0 deletions tests/Concurrency/GenerationRaceTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,111 @@
<?php
declare(strict_types=1);

namespace Tests\Concurrency;

use PDO;
use PHPUnit\Framework\TestCase;

class GenerationRaceTest extends TestCase
{
private string $host;
private string $port;
private string $db;
private string $user;
private string $pass;
private ?PDO $pdo = null;

protected function setUp(): void
{
$this->host = getenv('DB_HOST') ?: '127.0.0.1';
$this->port = getenv('DB_PORT') ?: '3306';
$this->db = getenv('DB_DATABASE') ?: 'testing';
$this->user = getenv('DB_USERNAME') ?: 'root';
$this->pass = getenv('DB_PASSWORD') ?: '';

try {
$this->pdo = new PDO("mysql:host={$this->host};port={$this->port}", $this->user, $this->pass);
$this->pdo->exec("CREATE DATABASE IF NOT EXISTS `{$this->db}`");
$this->pdo->exec("USE `{$this->db}`");

// Clear tables instead of recreating
$this->pdo->exec("DELETE FROM `verification_codes`");
$this->pdo->exec("DELETE FROM `verification_generation_locks`");
} catch (\PDOException $e) {
$this->markTestSkipped("Database connection failed: " . $e->getMessage());
}
}

public function testGenerationRaceCondition(): void
{
if (!function_exists('pcntl_fork')) {
$this->markTestSkipped('pcntl extension is required for concurrency tests');
}

$numWorkers = 20;
$identityId = 'race_user_' . uniqid();
$pids = [];

// Close PDO before forking
$this->pdo = null;

for ($i = 0; $i < $numWorkers; $i++) {
$pid = pcntl_fork();
if ($pid === -1) {
$this->fail('Failed to fork process');
} elseif ($pid === 0) {
// Child process
try {
// Reconnect to DB
$pdo = new PDO("mysql:host={$this->host};port={$this->port};dbname={$this->db}", $this->user, $this->pass, [
PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION, PDO::ATTR_EMULATE_PREPARES => true,
]);

require_once __DIR__ . '/../../vendor/autoload.php';

$clock = new \Tests\MockClock();
$repo = new \Maatify\Verification\Infrastructure\Repository\PdoVerificationCodeRepository($pdo, $clock);
$policyResolver = new \Maatify\Verification\Domain\Service\VerificationCodePolicyResolver();
$transactionManager = new \Maatify\Verification\Infrastructure\Transaction\PdoTransactionManager($pdo);
$generator = new \Maatify\Verification\Domain\Service\VerificationCodeGenerator(
$repo, $policyResolver, $clock, $transactionManager, 'secret'
);

// Attempt to generate
$generator->generate(
\Maatify\Verification\Domain\Enum\IdentityTypeEnum::User,
$identityId,
\Maatify\Verification\Domain\Enum\VerificationPurposeEnum::EmailVerification
);
exit(0); // Success
} catch (\Exception $e) {
exit(1); // Failure
}
} else {
$pids[] = $pid;
}
}

// Wait for all children to finish
$successes = 0;
foreach ($pids as $pid) {
pcntl_waitpid($pid, $status);
if (pcntl_wexitstatus($status) === 0) {
$successes++;
}
}

// Connect again to verify
$pdo = new PDO("mysql:host={$this->host};port={$this->port};dbname={$this->db}", $this->user, $this->pass);

$stmt = $pdo->prepare("SELECT COUNT(*) FROM verification_codes WHERE identity_id = ?");
$stmt->execute([$identityId]);
$this->assertEquals(1, $stmt->fetchColumn(), "Only one code should be in the database");

$stmt = $pdo->prepare("SELECT COUNT(*) FROM verification_generation_locks WHERE identity_id = ?");
$stmt->execute([$identityId]);
$this->assertEquals(1, $stmt->fetchColumn(), "One lock should exist");

$this->assertEquals(1, $successes, "Only one concurrent generation should succeed");
}
}
124 changes: 124 additions & 0 deletions tests/Concurrency/ValidationRaceTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,124 @@
<?php
declare(strict_types=1);

namespace Tests\Concurrency;

use PDO;
use PHPUnit\Framework\TestCase;

class ValidationRaceTest extends TestCase
{
private string $host;
private string $port;
private string $db;
private string $user;
private string $pass;
private ?PDO $pdo = null;

protected function setUp(): void
{
$this->host = getenv('DB_HOST') ?: '127.0.0.1';
$this->port = getenv('DB_PORT') ?: '3306';
$this->db = getenv('DB_DATABASE') ?: 'testing';
$this->user = getenv('DB_USERNAME') ?: 'root';
$this->pass = getenv('DB_PASSWORD') ?: '';

try {
$this->pdo = new PDO("mysql:host={$this->host};port={$this->port}", $this->user, $this->pass);
$this->pdo->exec("CREATE DATABASE IF NOT EXISTS `{$this->db}`");
$this->pdo->exec("USE `{$this->db}`");

// Clear tables instead of recreating
$this->pdo->exec("DELETE FROM `verification_codes`");
$this->pdo->exec("DELETE FROM `verification_generation_locks`");
} catch (\PDOException $e) {
$this->markTestSkipped("Database connection failed: " . $e->getMessage());
}
}

public function testValidationRaceCondition(): void
{
if (!function_exists('pcntl_fork')) {
$this->markTestSkipped('pcntl extension is required for concurrency tests');
}

if ($this->pdo === null) {
$this->markTestSkipped('PDO is null');
}

$numWorkers = 50;
$identityId = 'race_user_val_' . uniqid();
$plainCode = '123456';
$secret = 'secret';
$hash = hash_hmac('sha256', $plainCode, $secret);

// Pre-insert one code
$stmt = $this->pdo->prepare("
INSERT INTO verification_codes (
identity_type, identity_id, purpose, code_hash, status, attempts, max_attempts, expires_at, created_at
) VALUES (
'user', :id, 'email_verification', :hash, 'active', 0, 3, DATE_ADD(NOW(), INTERVAL 15 MINUTE), NOW()
)
");
$stmt->execute(['id' => $identityId, 'hash' => $hash]);

$pids = [];
$this->pdo = null; // Close parent connection

for ($i = 0; $i < $numWorkers; $i++) {
$pid = pcntl_fork();
if ($pid === -1) {
$this->fail('Failed to fork process');
} elseif ($pid === 0) {
// Child process
try {
// Reconnect to DB
$pdo = new PDO("mysql:host={$this->host};port={$this->port};dbname={$this->db}", $this->user, $this->pass, [
PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION, PDO::ATTR_EMULATE_PREPARES => true,
]);

require_once __DIR__ . '/../../vendor/autoload.php';

$clock = new \Tests\MockClock();
$repo = new \Maatify\Verification\Infrastructure\Repository\PdoVerificationCodeRepository($pdo, $clock);
$validator = new \Maatify\Verification\Domain\Service\VerificationCodeValidator($repo, $secret);

// Attempt to validate
$result = $validator->validate(
\Maatify\Verification\Domain\Enum\IdentityTypeEnum::User,
$identityId,
\Maatify\Verification\Domain\Enum\VerificationPurposeEnum::EmailVerification,
$plainCode
);

exit($result->success ? 0 : 1);
} catch (\Exception $e) {
exit(2);
}
} else {
// Parent process
$pids[] = $pid;
}
}

// Wait for all children to finish
$successes = 0;
$failures = 0;
$errors = 0;
foreach ($pids as $pid) {
pcntl_waitpid($pid, $status);
$exitCode = pcntl_wexitstatus($status);
if ($exitCode === 0) {
$successes++;
} elseif ($exitCode === 1) {
$failures++;
} else {
$errors++;
}
}

$this->assertEquals(0, $errors, "No errors should occur during concurrent execution");
$this->assertEquals(1, $successes, "Only 1 validation should succeed");
$this->assertEquals($numWorkers - 1, $failures, ($numWorkers - 1) . " validations should fail");
}
}
66 changes: 66 additions & 0 deletions tests/DatabaseTestCase.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
<?php
declare(strict_types=1);

namespace Tests;

use PDO;
use PHPUnit\Framework\TestCase;

abstract class DatabaseTestCase extends TestCase
{
private static ?PDO $sharedPdo = null;

protected function setUp(): void
{
$pdo = $this->getPdo();
// Since PDOTransactionManager doesn't support nested transactions well,
// and we want isolated tests, we should just truncate tables before each test
// rather than using BEGIN/ROLLBACK transactions.

$pdo->exec("DELETE FROM `verification_codes`");
$pdo->exec("DELETE FROM `verification_generation_locks`");
}

protected function getPdo(): PDO
{
if (self::$sharedPdo === null) {
$host = getenv('DB_HOST') ?: '127.0.0.1';
$port = getenv('DB_PORT') ?: '3306';
$db = getenv('DB_DATABASE') ?: 'testing';
$user = getenv('DB_USERNAME') ?: 'root';
$pass = getenv('DB_PASSWORD') ?: '';

$dsn = "mysql:host=$host;port=$port;dbname=$db;charset=utf8mb4";

try {
// Try to create the DB first if it doesn't exist
$tempPdo = new PDO("mysql:host=$host;port=$port", $user, $pass);
$tempPdo->exec("CREATE DATABASE IF NOT EXISTS `$db`");

self::$sharedPdo = new PDO($dsn, $user, $pass, [
PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
PDO::ATTR_EMULATE_PREPARES => true,
]);
} catch (\PDOException $e) {
$this->markTestSkipped("Database connection failed: " . $e->getMessage());
}

// Apply schema
self::$sharedPdo->exec("DROP TABLE IF EXISTS `verification_codes`");
self::$sharedPdo->exec("DROP TABLE IF EXISTS `verification_generation_locks`");

$codesSql = file_get_contents(__DIR__ . '/../database/verification_codes.sql');
if ($codesSql !== false) {
self::$sharedPdo->exec($codesSql);
}

$locksSql = file_get_contents(__DIR__ . '/../database/verification_generation_locks.sql');
if ($locksSql !== false) {
self::$sharedPdo->exec($locksSql);
}
}

return self::$sharedPdo;
}
}
49 changes: 49 additions & 0 deletions tests/Integration/Generator/GenerateSuccessTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
<?php
declare(strict_types=1);

namespace Tests\Integration\Generator;

use Maatify\Verification\Domain\Enum\IdentityTypeEnum;
use Maatify\Verification\Domain\Enum\VerificationCodeStatus;
use Maatify\Verification\Domain\Enum\VerificationPurposeEnum;
use Maatify\Verification\Domain\Service\VerificationCodeGenerator;
use Maatify\Verification\Domain\Service\VerificationCodePolicyResolver;
use Maatify\Verification\Infrastructure\Repository\PdoVerificationCodeRepository;
use Maatify\Verification\Infrastructure\Transaction\PdoTransactionManager;
use PDOStatement;
use Tests\DatabaseTestCase;
use Tests\MockClock;

class GenerateSuccessTest extends DatabaseTestCase
{
public function testGenerateStoresCodeCorrectly(): void
{
$clock = new MockClock('2025-01-01 12:00:00');
$repository = new PdoVerificationCodeRepository($this->getPdo(), $clock);
$generator = new VerificationCodeGenerator(
$repository,
new VerificationCodePolicyResolver(),
$clock,
new PdoTransactionManager($this->getPdo()),
'test_secret'
);

$result = $generator->generate(
IdentityTypeEnum::User,
'user1',
VerificationPurposeEnum::EmailVerification
);

$this->assertNotEmpty($result->plainCode);
$this->assertEquals(IdentityTypeEnum::User, $result->entity->identityType);
$this->assertEquals('user1', $result->entity->identityId);
$this->assertEquals(VerificationCodeStatus::ACTIVE, $result->entity->status);
$this->assertEquals(0, $result->entity->attempts);

$stmt = $this->getPdo()->query("SELECT * FROM verification_codes");
$this->assertInstanceOf(PDOStatement::class, $stmt);
$rows = $stmt->fetchAll();
$this->assertCount(1, $rows);
$this->assertEquals('user1', $rows[0]['identity_id']);
}
}
40 changes: 40 additions & 0 deletions tests/Integration/Generator/GenerationLockTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
<?php
declare(strict_types=1);

namespace Tests\Integration\Generator;

use Maatify\Verification\Domain\Enum\IdentityTypeEnum;
use Maatify\Verification\Domain\Enum\VerificationPurposeEnum;
use Maatify\Verification\Domain\Service\VerificationCodeGenerator;
use Maatify\Verification\Domain\Service\VerificationCodePolicyResolver;
use Maatify\Verification\Infrastructure\Repository\PdoVerificationCodeRepository;
use Maatify\Verification\Infrastructure\Transaction\PdoTransactionManager;
use PDOStatement;
use Tests\DatabaseTestCase;
use Tests\MockClock;

class GenerationLockTest extends DatabaseTestCase
{
public function testVerifyRowExistsInVerificationGenerationLocks(): void
{
$clock = new MockClock('2025-01-01 12:00:00');
$repository = new PdoVerificationCodeRepository($this->getPdo(), $clock);
$generator = new VerificationCodeGenerator(
$repository,
new VerificationCodePolicyResolver(),
$clock,
new PdoTransactionManager($this->getPdo()),
'test_secret'
);

$generator->generate(IdentityTypeEnum::User, 'user_lock', VerificationPurposeEnum::EmailVerification);

$stmt = $this->getPdo()->query("SELECT * FROM verification_generation_locks");
$this->assertInstanceOf(PDOStatement::class, $stmt);
$rows = $stmt->fetchAll();

$this->assertCount(1, $rows);
$this->assertEquals('user', $rows[0]['identity_type']);
$this->assertEquals('user_lock', $rows[0]['identity_id']);
}
}
Loading
Loading