diff --git a/src/RuleFactory.php b/src/RuleFactory.php new file mode 100644 index 0000000..35732bc --- /dev/null +++ b/src/RuleFactory.php @@ -0,0 +1,121 @@ + $payload + */ + public static function fromArray(array $payload): Rule + { + $action = \array_key_exists('action', $payload) ? $payload['action'] : ''; + if (!\is_string($action)) { + throw new \InvalidArgumentException('Invalid rule action definition.'); + } + + $conditions = \array_key_exists('conditions', $payload) ? $payload['conditions'] : []; + if (!\is_array($conditions)) { + throw new \InvalidArgumentException('Invalid rule conditions definition.'); + } + + $config = \array_key_exists('config', $payload) ? $payload['config'] : []; + if (!\is_array($config)) { + throw new \InvalidArgumentException('Invalid rule config definition.'); + } + + return self::create($action, $conditions, $config); + } + + /** + * @param array> $conditions + * @param array $config + */ + public static function create(string $action, array $conditions = [], array $config = []): Rule + { + self::validateConditions($conditions); + + return match ($action) { + Rule::ACTION_BYPASS => new Bypass($conditions), + Rule::ACTION_DENY => new Deny($conditions), + Rule::ACTION_CHALLENGE => new Challenge( + $conditions, + self::stringConfig($config, 'type', Challenge::TYPE_CAPTCHA) + ), + Rule::ACTION_RATE_LIMIT => new RateLimit( + $conditions, + self::intConfig($config, 'limit', 100), + self::intConfig($config, 'interval', 3600) + ), + Rule::ACTION_REDIRECT => new Redirect( + $conditions, + self::stringConfig($config, 'location', '/'), + self::intConfig($config, 'statusCode', 302) + ), + default => throw new \InvalidArgumentException('Unsupported rule action: ' . $action), + }; + } + + /** + * @param array> $conditions + */ + private static function validateConditions(array $conditions): void + { + foreach ($conditions as $condition) { + if ($condition instanceof Condition) { + continue; + } + + if (!\is_array($condition)) { + throw new \InvalidArgumentException('Invalid rule condition definition.'); + } + + try { + Condition::fromArray($condition); + } catch (ConditionException $exception) { + throw new \InvalidArgumentException('Invalid rule condition definition.', previous: $exception); + } + } + } + + /** + * @param array $config + */ + private static function stringConfig(array $config, string $key, string $default): string + { + if (!\array_key_exists($key, $config)) { + return $default; + } + + $value = $config[$key]; + if (!\is_string($value)) { + throw new \InvalidArgumentException("Invalid rule config value for {$key}."); + } + + return $value; + } + + /** + * @param array $config + */ + private static function intConfig(array $config, string $key, int $default): int + { + if (!\array_key_exists($key, $config)) { + return $default; + } + + $value = $config[$key]; + if (!\is_int($value)) { + throw new \InvalidArgumentException("Invalid rule config value for {$key}."); + } + + return $value; + } +} diff --git a/tests/RuleFactoryTest.php b/tests/RuleFactoryTest.php new file mode 100644 index 0000000..0b8a1db --- /dev/null +++ b/tests/RuleFactoryTest.php @@ -0,0 +1,238 @@ + Rule::ACTION_BYPASS, + 'conditions' => [ + Condition::equal('ip', ['127.0.0.1'])->toArray(), + ], + ]); + + $this->assertInstanceOf(Bypass::class, $rule); + $this->assertSame(Rule::ACTION_BYPASS, $rule->getAction()); + $this->assertCount(1, $rule->getConditions()); + } + + public function testCreatesDenyRule(): void + { + $rule = RuleFactory::fromArray([ + 'action' => Rule::ACTION_DENY, + 'conditions' => [ + Condition::contains('path', ['/admin'])->toArray(), + ], + ]); + + $this->assertInstanceOf(Deny::class, $rule); + $this->assertSame(Rule::ACTION_DENY, $rule->getAction()); + } + + public function testCreatesChallengeRuleWithConfig(): void + { + $rule = RuleFactory::fromArray([ + 'action' => Rule::ACTION_CHALLENGE, + 'conditions' => [], + 'config' => [ + 'type' => Challenge::TYPE_CUSTOM, + ], + ]); + + $this->assertInstanceOf(Challenge::class, $rule); + $this->assertSame(Challenge::TYPE_CUSTOM, $rule->getType()); + } + + public function testCreatesRateLimitRuleWithConfig(): void + { + $rule = RuleFactory::fromArray([ + 'action' => Rule::ACTION_RATE_LIMIT, + 'conditions' => [], + 'config' => [ + 'limit' => 10, + 'interval' => 60, + ], + ]); + + $this->assertInstanceOf(RateLimit::class, $rule); + $this->assertSame(10, $rule->getLimit()); + $this->assertSame(60, $rule->getInterval()); + } + + public function testCreatesRedirectRuleWithConfig(): void + { + $rule = RuleFactory::fromArray([ + 'action' => Rule::ACTION_REDIRECT, + 'conditions' => [], + 'config' => [ + 'location' => '/blocked', + 'statusCode' => 307, + ], + ]); + + $this->assertInstanceOf(Redirect::class, $rule); + $this->assertSame('/blocked', $rule->getLocation()); + $this->assertSame(307, $rule->getStatusCode()); + } + + public function testCreatesRuleFromParameters(): void + { + $rule = RuleFactory::create( + Rule::ACTION_RATE_LIMIT, + [ + Condition::equal('ip', ['127.0.0.1']), + ], + [ + 'limit' => 25, + 'interval' => 120, + ] + ); + + $this->assertInstanceOf(RateLimit::class, $rule); + $this->assertSame(25, $rule->getLimit()); + $this->assertSame(120, $rule->getInterval()); + $this->assertCount(1, $rule->getConditions()); + } + + public function testUsesRuleDefaults(): void + { + $challenge = RuleFactory::fromArray([ + 'action' => Rule::ACTION_CHALLENGE, + ]); + $rateLimit = RuleFactory::fromArray([ + 'action' => Rule::ACTION_RATE_LIMIT, + ]); + $redirect = RuleFactory::fromArray([ + 'action' => Rule::ACTION_REDIRECT, + ]); + + $this->assertInstanceOf(Challenge::class, $challenge); + $this->assertSame(Challenge::TYPE_CAPTCHA, $challenge->getType()); + + $this->assertInstanceOf(RateLimit::class, $rateLimit); + $this->assertSame(100, $rateLimit->getLimit()); + $this->assertSame(3600, $rateLimit->getInterval()); + + $this->assertInstanceOf(Redirect::class, $redirect); + $this->assertSame('/', $redirect->getLocation()); + $this->assertSame(302, $redirect->getStatusCode()); + } + + public function testRejectsUnsupportedAction(): void + { + $this->expectException(\InvalidArgumentException::class); + + RuleFactory::fromArray([ + 'action' => 'unknown', + ]); + } + + public function testRejectsInvalidActionPayload(): void + { + $this->expectException(\InvalidArgumentException::class); + + RuleFactory::fromArray([ + 'action' => 123, + ]); + } + + public function testRejectsNullActionPayload(): void + { + $this->expectException(\InvalidArgumentException::class); + + RuleFactory::fromArray([ + 'action' => null, + ]); + } + + public function testRejectsInvalidConditionsPayload(): void + { + $this->expectException(\InvalidArgumentException::class); + + RuleFactory::fromArray([ + 'action' => Rule::ACTION_DENY, + 'conditions' => 'invalid', + ]); + } + + public function testRejectsNullConditionsPayload(): void + { + $this->expectException(\InvalidArgumentException::class); + + RuleFactory::fromArray([ + 'action' => Rule::ACTION_DENY, + 'conditions' => null, + ]); + } + + public function testRejectsAssociativeConditionsPayload(): void + { + $this->expectException(\InvalidArgumentException::class); + + RuleFactory::fromArray([ + 'action' => Rule::ACTION_DENY, + 'conditions' => [ + 'method' => 'equal', + ], + ]); + } + + public function testRejectsInvalidConditionDefinition(): void + { + $this->expectException(\InvalidArgumentException::class); + + RuleFactory::fromArray([ + 'action' => Rule::ACTION_DENY, + 'conditions' => [ + [ + 'method' => 'unknown', + 'values' => [], + ], + ], + ]); + } + + public function testRejectsInvalidConfigPayload(): void + { + $this->expectException(\InvalidArgumentException::class); + + RuleFactory::fromArray([ + 'action' => Rule::ACTION_REDIRECT, + 'config' => 'invalid', + ]); + } + + public function testRejectsNullConfigPayload(): void + { + $this->expectException(\InvalidArgumentException::class); + + RuleFactory::fromArray([ + 'action' => Rule::ACTION_REDIRECT, + 'config' => null, + ]); + } + + public function testRejectsInvalidConfigValueTypes(): void + { + $this->expectException(\InvalidArgumentException::class); + + RuleFactory::fromArray([ + 'action' => Rule::ACTION_REDIRECT, + 'config' => [ + 'statusCode' => '307', + ], + ]); + } +}