From 000a6d7d1dc184cb36f99fd2bc418af9b3564cdc Mon Sep 17 00:00:00 2001 From: Nikola Posa Date: Sat, 16 Jul 2016 13:42:19 +0200 Subject: [PATCH] Expression assertion. CS fixes. Testing ExpressionAssertion. 'regex' operator instead of 'contains'. Exception in case that context object property cannot be resolved. Exception in case that context object property cannot be resolved. Exception in case that context object property cannot be resolved. --- src/Assertion/ExpressionAssertion.php | 267 +++++++++++++++ test/Assertion/ExpressionAssertionTest.php | 380 +++++++++++++++++++++ test/TestAsset/UseCase2/BlogPost.php | 44 +++ test/TestAsset/UseCase2/User.php | 37 ++ 4 files changed, 728 insertions(+) create mode 100644 src/Assertion/ExpressionAssertion.php create mode 100644 test/Assertion/ExpressionAssertionTest.php create mode 100644 test/TestAsset/UseCase2/BlogPost.php create mode 100644 test/TestAsset/UseCase2/User.php diff --git a/src/Assertion/ExpressionAssertion.php b/src/Assertion/ExpressionAssertion.php new file mode 100644 index 0000000..0ad18c3 --- /dev/null +++ b/src/Assertion/ExpressionAssertion.php @@ -0,0 +1,267 @@ + + */ +final class ExpressionAssertion implements AssertionInterface +{ + const OPERAND_CONTEXT_PROPERTY = '__context'; + + const OPERATOR_EQ = '='; + const OPERATOR_NEQ = '!='; + const OPERATOR_LT = '<'; + const OPERATOR_LTE = '<='; + const OPERATOR_GT = '>'; + const OPERATOR_GTE = '>='; + const OPERATOR_IN = 'in'; + const OPERATOR_NIN = 'nin'; + const OPERATOR_REGEX = 'regex'; + + /** + * @var mixed + */ + private $left; + + /** + * @var string + */ + private $operator; + + /** + * @var mixed + */ + private $right; + + /** + * @var array + */ + private $assertContext = []; + + /** + * @var array + */ + private static $validOperators = [ + self::OPERATOR_EQ, + self::OPERATOR_NEQ, + self::OPERATOR_LT, + self::OPERATOR_LTE, + self::OPERATOR_GT, + self::OPERATOR_GTE, + self::OPERATOR_IN, + self::OPERATOR_NIN, + self::OPERATOR_REGEX, + ]; + + private function __construct($left, $operator, $right) + { + $this->left = $left; + $this->operator = $operator; + $this->right = $right; + } + + /** + * @param mixed $left + * @param string $operator + * @param mixed $right + * @return self + */ + public static function fromProperties($left, $operator, $right) + { + $operator = strtolower($operator); + + self::validateOperand($left); + self::validateOperator($operator); + self::validateOperand($right); + + return new self($left, $operator, $right); + } + + /** + * @param array $expression + * @throws InvalidAssertionException + * @return self + */ + public static function fromArray(array $expression) + { + $required = ['left', 'operator', 'right']; + + if (count(array_intersect_key($expression, array_flip($required))) < count($required)) { + throw new InvalidAssertionException( + "Expression assertion requires 'left', 'operator' and 'right' to be supplied" + ); + } + + return self::fromProperties( + $expression['left'], + $expression['operator'], + $expression['right'] + ); + } + + private static function validateOperand($operand) + { + if (is_array($operand) && isset($operand[self::OPERAND_CONTEXT_PROPERTY])) { + if (!is_string($operand[self::OPERAND_CONTEXT_PROPERTY])) { + throw new InvalidAssertionException('Expression assertion context operand must be string'); + } + } + } + + private static function validateOperator($operator) + { + if (!in_array($operator, self::$validOperators)) { + throw new InvalidAssertionException('Provided expression assertion operator is not supported'); + } + } + + /** + * {@inheritDoc} + */ + public function assert(Acl $acl, RoleInterface $role = null, ResourceInterface $resource = null, $privilege = null) + { + $this->assertContext = [ + 'acl' => $acl, + 'role' => $role, + 'resource' => $resource, + 'privilege' => $privilege, + ]; + + return $this->evaluate(); + } + + private function evaluate() + { + $left = $this->getLeftValue(); + $right = $this->getRightValue(); + + return static::evaluateExpression($left, $this->operator, $right); + } + + private function getLeftValue() + { + return $this->resolveOperandValue($this->left); + } + + private function getRightValue() + { + return $this->resolveOperandValue($this->right); + } + + private function resolveOperandValue($operand) + { + if (is_array($operand) && isset($operand[self::OPERAND_CONTEXT_PROPERTY])) { + $contextProperty = $operand[self::OPERAND_CONTEXT_PROPERTY]; + + if (strpos($contextProperty, '.') !== false) { //property path? + list($objectName, $objectField) = explode('.', $contextProperty, 2); + + if (!isset($this->assertContext[$objectName])) { + throw new RuntimeException(sprintf( + "'%s' is not available in the assertion context", + $objectName + )); + } + + try { + return $this->getObjectFieldValue($this->assertContext[$objectName], $objectField); + } catch (\RuntimeException $ex) { + throw new RuntimeException(sprintf( + "'%s' property cannot be resolved on the '%s' object", + $objectField, + $objectName + )); + } + } + + if (!isset($this->assertContext[$contextProperty])) { + throw new RuntimeException(sprintf( + "'%s' is not available in the assertion context", + $contextProperty + )); + } + + return $this->assertContext[$contextProperty]; + } + + return $operand; + } + + private function getObjectFieldValue($object, $field) + { + $accessors = ['get', 'is']; + + $fieldAccessor = $field; + + if (false !== strpos($field, '_')) { + $fieldAccessor = str_replace(' ', '', ucwords(str_replace('_', ' ', $field))); + } + + foreach ($accessors as $accessor) { + $accessor .= $fieldAccessor; + + if (!method_exists($object, $accessor)) { + continue; + } + + return $object->$accessor(); + } + + if (!property_exists($object, $field)) { + throw new \RuntimeException('Object property cannot be resolved'); + } + + return $object->$field; + } + + private static function evaluateExpression($left, $operator, $right) + { + switch ($operator) { + case self::OPERATOR_EQ : + return $left == $right; + case self::OPERATOR_NEQ : + return $left != $right; + case self::OPERATOR_LT : + return $left < $right; + case self::OPERATOR_LTE : + return $left <= $right; + case self::OPERATOR_GT : + return $left > $right; + case self::OPERATOR_GTE : + return $left >= $right; + case self::OPERATOR_IN : + return in_array($left, $right); + case self::OPERATOR_NIN : + return !in_array($left, $right); + case self::OPERATOR_REGEX : + return (bool) preg_match($right, $left); + default : + throw new RuntimeException(sprintf( + 'Unsupported expression assertion operator: %s', + $operator + )); + } + } + + public function __sleep() + { + return [ + 'left', + 'operator', + 'right', + ]; + } +} diff --git a/test/Assertion/ExpressionAssertionTest.php b/test/Assertion/ExpressionAssertionTest.php new file mode 100644 index 0000000..5d5cf93 --- /dev/null +++ b/test/Assertion/ExpressionAssertionTest.php @@ -0,0 +1,380 @@ + + */ +class ExpressionAssertionTest extends PHPUnit_Framework_TestCase +{ + public function testFromPropertiesCreation() + { + $assertion = ExpressionAssertion::fromProperties( + 'foo', + '=', + 'bar' + ); + + $this->assertInstanceOf(ExpressionAssertion::class, $assertion); + } + + public function testFromArrayCreation() + { + $assertion = ExpressionAssertion::fromArray([ + 'left' => 'foo', + 'operator' => '=', + 'right' => 'bar' + ]); + + $this->assertInstanceOf(ExpressionAssertion::class, $assertion); + } + + public function testExceptionIsRaisedInCaseOfInvalidExpressionArray() + { + $this->setExpectedException( + InvalidAssertionException::class, + "Expression assertion requires 'left', 'operator' and 'right' to be supplied" + ); + + ExpressionAssertion::fromArray(['left' => 'test', 'foo' => 'bar']); + } + + public function testExceptionIsRaisedInCaseOfInvalidExpressionContextOperandType() + { + $this->setExpectedException( + InvalidAssertionException::class, + 'Expression assertion context operand must be string' + ); + + ExpressionAssertion::fromProperties( + [ExpressionAssertion::OPERAND_CONTEXT_PROPERTY => 123], + 'in', + 'test' + ); + } + + public function testExceptionIsRaisedInCaseOfInvalidExpressionOperator() + { + $this->setExpectedException( + InvalidAssertionException::class, + 'Provided expression assertion operator is not supported' + ); + + ExpressionAssertion::fromProperties( + 'test', + 'invalid', + 'test' + ); + } + + /** + * @dataProvider getExpressions + */ + public function testExpressionsEvaluation(array $expression, $role, $resource, $privilege, $expectedAssert) + { + $assertion = ExpressionAssertion::fromArray($expression); + + $this->assertThat( + $assertion->assert(new Acl(), $role, $resource, $privilege), + $expectedAssert ? $this->isTrue() : $this->isFalse() + ); + } + + public function getExpressions() + { + $author3 = new User([ + 'username' => 'author3', + ]); + $post3 = new BlogPost([ + 'author' => $author3, + ]); + + return [ + [ + 'expression' => [ + 'left' => [ExpressionAssertion::OPERAND_CONTEXT_PROPERTY => 'role.username'], + 'operator' => '=', + 'right' => 'test', + ], + 'role' => new User([ + 'username' => 'test', + ]), + 'resource' => new BlogPost(), + 'privilege' => 'read', + 'assert' => true, + ], + [ + 'expression' => [ + 'left' => [ExpressionAssertion::OPERAND_CONTEXT_PROPERTY => 'role.username'], + 'operator' => '!=', + 'right' => 'test', + ], + 'role' => new User([ + 'username' => 'foobar', + ]), + 'resource' => new BlogPost(), + 'privilege' => 'read', + 'assert' => true, + ], + [ + 'expression' => [ + 'left' => [ExpressionAssertion::OPERAND_CONTEXT_PROPERTY => 'role.username'], + 'operator' => '=', + 'right' => true, + ], + 'role' => $author3, + 'resource' => $post3, + 'privilege' => 'read', + 'assert' => true, + ], + [ + 'expression' => [ + 'left' => [ExpressionAssertion::OPERAND_CONTEXT_PROPERTY => 'role.age'], + 'operator' => '>', + 'right' => 20, + ], + 'role' => new User([ + 'username' => 'foobar', + 'age' => 15, + ]), + 'resource' => new BlogPost(), + 'privilege' => 'read', + 'assert' => false, + ], + [ + 'expression' => [ + 'left' => [ExpressionAssertion::OPERAND_CONTEXT_PROPERTY => 'role.age'], + 'operator' => '>=', + 'right' => 20, + ], + 'role' => new User([ + 'username' => 'foobar', + 'age' => 20, + ]), + 'resource' => new BlogPost(), + 'privilege' => 'read', + 'assert' => true, + ], + [ + 'expression' => [ + 'left' => [ExpressionAssertion::OPERAND_CONTEXT_PROPERTY => 'role.age'], + 'operator' => '<', + 'right' => 30, + ], + 'role' => new User([ + 'username' => 'foobar', + 'age' => 20, + ]), + 'resource' => new BlogPost(), + 'privilege' => 'read', + 'assert' => true, + ], + [ + 'expression' => [ + 'left' => [ExpressionAssertion::OPERAND_CONTEXT_PROPERTY => 'role.age'], + 'operator' => '<=', + 'right' => 30, + ], + 'role' => new User([ + 'username' => 'foobar', + 'age' => 30, + ]), + 'resource' => new BlogPost(), + 'privilege' => 'read', + 'assert' => true, + ], + [ + 'expression' => [ + 'left' => [ExpressionAssertion::OPERAND_CONTEXT_PROPERTY => 'role.username'], + 'operator' => 'in', + 'right' => ['foo', 'bar'], + ], + 'role' => new User([ + 'username' => 'test', + ]), + 'resource' => new BlogPost(), + 'privilege' => 'read', + 'assert' => false, + ], + [ + 'expression' => [ + 'left' => [ExpressionAssertion::OPERAND_CONTEXT_PROPERTY => 'role.username'], + 'operator' => 'nin', + 'right' => ['foo', 'bar'], + ], + 'role' => new User([ + 'username' => 'test', + ]), + 'resource' => new BlogPost(), + 'privilege' => 'read', + 'assert' => true, + ], + [ + 'expression' => [ + 'left' => [ExpressionAssertion::OPERAND_CONTEXT_PROPERTY => 'role.username'], + 'operator' => 'regex', + 'right' => '/foobar/', + ], + 'role' => new User([ + 'username' => 'test', + ]), + 'resource' => new BlogPost(), + 'privilege' => 'read', + 'assert' => false, + ], + [ + 'expression' => [ + 'left' => [ExpressionAssertion::OPERAND_CONTEXT_PROPERTY => 'resource.short_description'], + 'operator' => 'REGEX', + 'right' => '/ipsum/', + ], + 'role' => new User([ + 'username' => 'test', + ]), + 'resource' => new BlogPost([ + 'title' => 'Test', + 'content' => 'lorem ipsum dolor sit amet', + 'short_description' => 'lorem ipsum' + ]), + 'privilege' => 'read', + 'assert' => true, + ], + [ + 'expression' => [ + 'left' => [ExpressionAssertion::OPERAND_CONTEXT_PROPERTY => 'role.adult'], + 'operator' => '=', + 'right' => true, + ], + 'role' => new User([ + 'username' => 'test', + 'age' => 30, + ]), + 'resource' => new BlogPost(), + 'privilege' => 'read', + 'assert' => true, + ], + [ + 'expression' => [ + 'left' => [ExpressionAssertion::OPERAND_CONTEXT_PROPERTY => 'privilege'], + 'operator' => '=', + 'right' => 'read', + ], + 'role' => new User([ + 'username' => 'test', + ]), + 'resource' => new BlogPost(), + 'privilege' => 'update', + 'assert' => false, + ], + ]; + } + + public function testExceptionIsRaisedInCaseOfUnknownContextOperand() + { + $this->setExpectedException( + RuntimeException::class, + "'foobar' is not available in the assertion context" + ); + + $assertion = ExpressionAssertion::fromProperties( + [ExpressionAssertion::OPERAND_CONTEXT_PROPERTY => 'foobar'], + '=', + 'test' + ); + + $assertion->assert(new Acl(), new User(), new BlogPost(), 'read'); + } + + public function testExceptionIsRaisedInCaseOfUnknownContextOperandContainingPropertyPath() + { + $this->setExpectedException( + RuntimeException::class, + "'foo' is not available in the assertion context" + ); + + $assertion = ExpressionAssertion::fromProperties( + [ExpressionAssertion::OPERAND_CONTEXT_PROPERTY => 'foo.bar'], + '=', + 'test' + ); + + $assertion->assert(new Acl(), new User(), new BlogPost(), 'read'); + } + + public function testExceptionIsRaisedIfContextObjectPropertyCannotBeResolved() + { + $this->setExpectedException( + RuntimeException::class, + "'age123' property cannot be resolved on the 'role' object" + ); + + $assertion = ExpressionAssertion::fromProperties( + [ExpressionAssertion::OPERAND_CONTEXT_PROPERTY => 'role.age123'], + '=', + 30 + ); + + $assertion->assert(new Acl(), new User(), new BlogPost(), 'read'); + } + + public function testExceptionIsRaisedInCaseThatAssertHasBeenInvokedWithoutPassingContext() + { + $this->setExpectedException( + RuntimeException::class, + "'role' is not available in the assertion context" + ); + + $assertion = ExpressionAssertion::fromProperties( + [ExpressionAssertion::OPERAND_CONTEXT_PROPERTY => 'role.username'], + '=', + 'test' + ); + + $assertion->assert(new Acl()); + } + + public function testSerialization() + { + $assertion = ExpressionAssertion::fromProperties( + 'foo', + '=', + 'bar' + ); + + $serializedAssertion = serialize($assertion); + + $this->assertContains('left', $serializedAssertion); + $this->assertContains('foo', $serializedAssertion); + $this->assertContains('operator', $serializedAssertion); + $this->assertContains('=', $serializedAssertion); + $this->assertContains('right', $serializedAssertion); + $this->assertContains('bar', $serializedAssertion); + } + + public function testSerializationShouldNotSerializeAssertContext() + { + $assertion = ExpressionAssertion::fromProperties( + 'foo', + '=', + 'bar' + ); + + $serializedAssertion = serialize($assertion); + + $this->assertNotContains('assertContext', $serializedAssertion); + } +} diff --git a/test/TestAsset/UseCase2/BlogPost.php b/test/TestAsset/UseCase2/BlogPost.php new file mode 100644 index 0000000..fdfecd2 --- /dev/null +++ b/test/TestAsset/UseCase2/BlogPost.php @@ -0,0 +1,44 @@ + $value) { + $this->$property = $value; + } + } + + public function getResourceId() + { + return 'blogPost'; + } + + public function getShortDescription() + { + return $this->short_description; + } + + public function getAuthorName() + { + return $this->author ? $this->author->username : ''; + } +} diff --git a/test/TestAsset/UseCase2/User.php b/test/TestAsset/UseCase2/User.php new file mode 100644 index 0000000..77511eb --- /dev/null +++ b/test/TestAsset/UseCase2/User.php @@ -0,0 +1,37 @@ + $value) { + $this->$property = $value; + } + } + + public function getRoleId() + { + return $this->role; + } + + public function isAdult() + { + return $this->age >= 18; + } +}