├── .gitignore
├── phpstan.neon
├── tests
├── bootstrap.php
├── Unit
│ ├── Utilities
│ │ ├── TimeTest.php
│ │ ├── RandomizerTest.php
│ │ └── RuntimeCacheTest.php
│ ├── Features
│ │ ├── SetFactoryTest.php
│ │ ├── FactoryTest.php
│ │ ├── FeatureTest.php
│ │ ├── SetTest.php
│ │ └── SetRepositoryTest.php
│ ├── Rules
│ │ ├── StringHashTest.php
│ │ ├── IpAddressTest.php
│ │ ├── RandomTest.php
│ │ ├── AbstractTest.php
│ │ ├── PipedCompositeTest.php
│ │ ├── HttpHeaderTest.php
│ │ ├── UserIdTest.php
│ │ ├── RuntimeCallableTest.php
│ │ ├── PercentageTest.php
│ │ ├── TimeAfterTest.php
│ │ ├── TimeBeforeTest.php
│ │ ├── EnvironmentTest.php
│ │ ├── FactoryTest.php
│ │ └── TypeMapperTest.php
│ ├── Identification
│ │ ├── IntegerIdTest.php
│ │ ├── StringHashTest.php
│ │ ├── PipedCompositeTest.php
│ │ ├── IpAddressTest.php
│ │ ├── UserIdTest.php
│ │ ├── EnvironmentTest.php
│ │ ├── HttpHeaderTest.php
│ │ └── CollectionTest.php
│ ├── Logger
│ │ └── ProcessorTest.php
│ ├── DoorKeeperTest.php
│ └── RequestorTest.php
└── Stubs
│ └── AbstractRule.php
├── src
├── Features
│ ├── SetProviderInterface.php
│ ├── SetFactory.php
│ ├── Factory.php
│ ├── SetRepository.php
│ ├── Set.php
│ └── Feature.php
├── Utilities
│ ├── Time.php
│ ├── Randomizer.php
│ └── RuntimeCache.php
├── DoorkeeperInterface.php
├── Identification
│ ├── Environment.php
│ ├── StringHash.php
│ ├── IdentificationInterface.php
│ ├── IntegerId.php
│ ├── PipedComposite.php
│ ├── HttpHeader.php
│ ├── IpAddress.php
│ ├── UserId.php
│ ├── AbstractIdentification.php
│ └── Collection.php
├── RequestorInterface.php
├── Rules
│ ├── RuntimeCallable.php
│ ├── RuleInterface.php
│ ├── Random.php
│ ├── StringHash.php
│ ├── IpAddress.php
│ ├── Environment.php
│ ├── UserId.php
│ ├── HttpHeader.php
│ ├── PipedComposite.php
│ ├── TimeAfter.php
│ ├── TimeBefore.php
│ ├── Percentage.php
│ ├── Factory.php
│ ├── AbstractRule.php
│ └── TypeMapper.php
├── Logger
│ └── Processor.php
├── Requestor.php
└── Doorkeeper.php
├── psalm.xml
├── .scrutinizer.yml
├── Makefile
├── phpunit.xml.dist
├── composer.json
├── LICENSE
└── README.md
/.gitignore:
--------------------------------------------------------------------------------
1 | composer.phar
2 | /vendor/
3 | /coverage/
4 | .idea
5 |
6 |
--------------------------------------------------------------------------------
/phpstan.neon:
--------------------------------------------------------------------------------
1 | parameters:
2 | level: max
3 | paths:
4 | - src
5 | checkMissingIterableValueType: false
6 | treatPhpDocTypesAsCertain: false
7 | checkGenericClassInNonGenericObjectType: false
--------------------------------------------------------------------------------
/tests/bootstrap.php:
--------------------------------------------------------------------------------
1 | mtRand($lower, $upper);
12 | }
13 |
14 | protected function mtRand(int $lower, int $upper): int
15 | {
16 | return mt_rand($lower, $upper);
17 | }
18 | }
19 |
--------------------------------------------------------------------------------
/src/Identification/PipedComposite.php:
--------------------------------------------------------------------------------
1 | assertEquals(
15 | new \DateTimeImmutable('2012-12-12'),
16 | (new Utilities\Time())->getImmutableDateTime('2012-12-12')
17 | );
18 | }
19 | }
20 |
--------------------------------------------------------------------------------
/src/Identification/HttpHeader.php:
--------------------------------------------------------------------------------
1 | getHeaderLine(Rules\HttpHeader::HEADER_KEY));
19 | }
20 | }
21 |
--------------------------------------------------------------------------------
/psalm.xml:
--------------------------------------------------------------------------------
1 |
2 |
10 |
11 |
12 |
13 |
14 |
15 |
16 |
--------------------------------------------------------------------------------
/src/Identification/IpAddress.php:
--------------------------------------------------------------------------------
1 | generateRangedRandomInt(1, 100);
16 |
17 | $this->assertGreaterThanOrEqual(1, $result);
18 | $this->assertLessThanOrEqual(100, $result);
19 | }
20 | }
21 |
--------------------------------------------------------------------------------
/.scrutinizer.yml:
--------------------------------------------------------------------------------
1 | build:
2 | environment:
3 | php:
4 | version: '7.4.5'
5 | nodes:
6 | my-tests:
7 | environment:
8 | php:
9 | version: '7.4.5'
10 | cache:
11 | disabled: true
12 |
13 | dependencies:
14 | override:
15 | - composer install --ignore-platform-reqs --no-interaction
16 |
17 | build_failure_conditions:
18 | - 'elements.rating(<= B).new.exists' # No new classes/methods with a rating of C or worse allowed
19 | - 'project.metric_change("scrutinizer.test_coverage", < 0)' # Code Coverage decreased from previous inspection
--------------------------------------------------------------------------------
/src/Identification/UserId.php:
--------------------------------------------------------------------------------
1 | featureFactory = $factory ?? new Factory();
14 | }
15 |
16 | public function createFromArray(array $featureSet): Set
17 | {
18 | $features = [];
19 |
20 | foreach ($featureSet as $feature) {
21 | $features[] = $this->featureFactory->createFromArray($feature);
22 | }
23 |
24 | return new Set($features);
25 | }
26 | }
27 |
--------------------------------------------------------------------------------
/src/Rules/RuntimeCallable.php:
--------------------------------------------------------------------------------
1 | runtimeCallable = $runtimeCallable;
19 | }
20 |
21 | protected function childCanBeSatisfied(RequestorInterface $requestor = null): bool
22 | {
23 | $localFn = $this->runtimeCallable;
24 | return (bool) $localFn($requestor);
25 | }
26 | }
27 |
--------------------------------------------------------------------------------
/src/Rules/RuleInterface.php:
--------------------------------------------------------------------------------
1 | randomizer = $randomizer ?? new Utilities\Randomizer();
17 | }
18 |
19 | protected function childCanBeSatisfied(RequestorInterface $requestor = null): bool
20 | {
21 | return ($this->randomizer->generateRangedRandomInt(1, 100)
22 | === $this->randomizer->generateRangedRandomInt(1, 100));
23 | }
24 | }
25 |
--------------------------------------------------------------------------------
/src/Rules/StringHash.php:
--------------------------------------------------------------------------------
1 | hash = new Identification\StringHash($hash);
17 | }
18 |
19 | public function getValue()
20 | {
21 | return $this->hash->getIdentifier();
22 | }
23 |
24 | protected function childCanBeSatisfied(RequestorInterface $requestor = null): bool
25 | {
26 | return $this->requestorHasMatchingId($requestor, $this->hash);
27 | }
28 | }
29 |
--------------------------------------------------------------------------------
/src/Rules/IpAddress.php:
--------------------------------------------------------------------------------
1 | ipAddress = new Identification\IpAddress($ipAddress);
17 | }
18 |
19 | public function getValue()
20 | {
21 | return $this->ipAddress->getIdentifier();
22 | }
23 |
24 | protected function childCanBeSatisfied(RequestorInterface $requestor = null): bool
25 | {
26 | return $this->requestorHasMatchingId($requestor, $this->ipAddress);
27 | }
28 | }
29 |
--------------------------------------------------------------------------------
/src/Rules/Environment.php:
--------------------------------------------------------------------------------
1 | environment = new Identification\Environment($environment);
17 | }
18 |
19 | public function getValue()
20 | {
21 | return $this->environment->getIdentifier();
22 | }
23 |
24 | protected function childCanBeSatisfied(RequestorInterface $requestor = null): bool
25 | {
26 | return $this->requestorHasMatchingId($requestor, $this->environment);
27 | }
28 | }
29 |
--------------------------------------------------------------------------------
/src/Rules/UserId.php:
--------------------------------------------------------------------------------
1 | userId = new Identification\UserId($userId);
20 | }
21 |
22 | public function getValue()
23 | {
24 | return $this->userId->getIdentifier();
25 | }
26 |
27 | protected function childCanBeSatisfied(RequestorInterface $requestor = null): bool
28 | {
29 | return $this->requestorHasMatchingId($requestor, $this->userId);
30 | }
31 | }
32 |
--------------------------------------------------------------------------------
/src/Features/Factory.php:
--------------------------------------------------------------------------------
1 | ruleFactory = $rulesFactory ?? new Rules\Factory();
16 | }
17 |
18 | public function createFromArray(array $feature): Feature
19 | {
20 | $rules = [];
21 |
22 | if (isset($feature['rules'])) {
23 | foreach ($feature['rules'] as $rule) {
24 | $rules[] = $this->ruleFactory->createFromArray($rule);
25 | }
26 | }
27 |
28 | return new Feature($feature['name'], $feature['enabled'], $rules);
29 | }
30 | }
31 |
--------------------------------------------------------------------------------
/src/Rules/HttpHeader.php:
--------------------------------------------------------------------------------
1 | header = new Identification\HttpHeader($headerValue);
19 | }
20 |
21 | public function getValue()
22 | {
23 | return $this->header->getIdentifier();
24 | }
25 |
26 | protected function childCanBeSatisfied(RequestorInterface $requestor = null): bool
27 | {
28 | return $this->requestorHasMatchingId($requestor, $this->header);
29 | }
30 | }
31 |
--------------------------------------------------------------------------------
/src/Rules/PipedComposite.php:
--------------------------------------------------------------------------------
1 | pipedComposite = new Identification\PipedComposite($pipedComposite);
17 | }
18 |
19 | public function getValue()
20 | {
21 | return $this->pipedComposite->getIdentifier();
22 | }
23 |
24 | protected function childCanBeSatisfied(RequestorInterface $requestor = null): bool
25 | {
26 | return $this->requestorHasMatchingId($requestor, $this->pipedComposite);
27 | }
28 | }
29 |
--------------------------------------------------------------------------------
/tests/Unit/Features/SetFactoryTest.php:
--------------------------------------------------------------------------------
1 | createFromArray([
20 | ['name' => 'feature1', 'enabled' => false],
21 | ['name' => 'feature2', 'enabled' => true],
22 | ]);
23 |
24 | $this->assertEquals($expected, $actual);
25 | }
26 | }
27 |
--------------------------------------------------------------------------------
/tests/Unit/Rules/StringHashTest.php:
--------------------------------------------------------------------------------
1 | assertFalse($rule->canBeSatisfied());
19 | $this->assertFalse($rule->canBeSatisfied($requestor));
20 | $this->assertTrue($rule->canBeSatisfied($requestor->withStringHash('1lk2j34lk')));
21 | }
22 |
23 | public function testGetValue(): void
24 | {
25 | $this->assertEquals('#hash', (new Rules\StringHash('#hash'))->getValue());
26 | }
27 | }
28 |
--------------------------------------------------------------------------------
/tests/Unit/Rules/IpAddressTest.php:
--------------------------------------------------------------------------------
1 | assertFalse($rule->canBeSatisfied());
19 | $this->assertFalse($rule->canBeSatisfied($requestor));
20 |
21 | $this->assertTrue($rule->canBeSatisfied($requestor->withIpAddress('127.0.0.1')));
22 | }
23 |
24 | public function testGetValue(): void
25 | {
26 | $this->assertEquals('0.0.0.0', (new Rules\IpAddress('0.0.0.0'))->getValue());
27 | }
28 | }
29 |
--------------------------------------------------------------------------------
/tests/Unit/Rules/RandomTest.php:
--------------------------------------------------------------------------------
1 | getMockBuilder(Utilities\Randomizer::class)
16 | ->onlyMethods(['mtRand'])
17 | ->getMock();
18 |
19 | $rule = new Rules\Random($randomizer);
20 |
21 | $randomizer->method('mtRand')
22 | ->with(1, 100)
23 | ->willReturn(10);
24 |
25 | $this->assertTrue($rule->canBeSatisfied());
26 | }
27 |
28 | public function testGetValue(): void
29 | {
30 | $this->assertNull((new Rules\Random())->getValue());
31 | }
32 | }
33 |
--------------------------------------------------------------------------------
/src/Rules/TimeAfter.php:
--------------------------------------------------------------------------------
1 | timeUtility = $timeUtility ?? new Utilities\Time();
19 | $this->timeAfter = $this->timeUtility->getImmutableDateTime($timeAfter);
20 | }
21 |
22 | public function getValue()
23 | {
24 | return $this->timeAfter->format('Y-m-d H:i:s');
25 | }
26 |
27 | protected function childCanBeSatisfied(RequestorInterface $requestor = null): bool
28 | {
29 | return $this->timeAfter < $this->timeUtility->getImmutableDateTime('now');
30 | }
31 | }
32 |
--------------------------------------------------------------------------------
/src/Rules/TimeBefore.php:
--------------------------------------------------------------------------------
1 | timeUtility = $timeUtility ?? new Utilities\Time();
19 | $this->timeBefore = $this->timeUtility->getImmutableDateTime($timeBefore);
20 | }
21 |
22 | public function getValue()
23 | {
24 | return $this->timeBefore->format('Y-m-d H:i:s');
25 | }
26 |
27 | protected function childCanBeSatisfied(RequestorInterface $requestor = null): bool
28 | {
29 | return $this->timeBefore > $this->timeUtility->getImmutableDateTime('now');
30 | }
31 | }
32 |
--------------------------------------------------------------------------------
/tests/Unit/Rules/AbstractTest.php:
--------------------------------------------------------------------------------
1 | abstractRule = new Stubs\AbstractRule();
18 | }
19 |
20 | public function testJsonSerializes(): void
21 | {
22 | $this->abstractRule->addPrerequisite(new Rules\Random());
23 | // phpcs:disable
24 | $expected = '{"type":"RemotelyLiving\\\Doorkeeper\\\Tests\\\Stubs\\\AbstractRule","value":"mockValue","prerequisites":[{"type":"RemotelyLiving\\\Doorkeeper\\\Rules\\\Random","value":null,"prerequisites":[]}]}';
25 | // phpcs:enable
26 |
27 | $this->assertEquals($expected, json_encode($this->abstractRule));
28 | }
29 | }
30 |
--------------------------------------------------------------------------------
/tests/Unit/Identification/IntegerIdTest.php:
--------------------------------------------------------------------------------
1 | expectException(\InvalidArgumentException::class);
18 | new Identification\IntegerId($invalidId);
19 | }
20 |
21 | public function testValidateValid(): void
22 | {
23 | $this->assertInstanceOf(Identification\IntegerId::class, (new Identification\IntegerId(1)));
24 | }
25 |
26 | public function invalidIntegerIdProvider(): array
27 | {
28 | return [
29 | ['1'],
30 | ['boop'],
31 | [(object)[]],
32 | [[]],
33 | [-1]
34 | ];
35 | }
36 | }
37 |
--------------------------------------------------------------------------------
/tests/Unit/Rules/PipedCompositeTest.php:
--------------------------------------------------------------------------------
1 | assertFalse($rule->canBeSatisfied());
21 | $this->assertFalse($rule->canBeSatisfied($requestor));
22 | $requestor->registerIdentification($identification);
23 |
24 | $this->assertTrue($rule->canBeSatisfied($requestor));
25 | }
26 |
27 | public function testGetValue(): void
28 | {
29 | $this->assertEquals('A|B', (new Rules\PipedComposite('A|B'))->getValue());
30 | }
31 | }
32 |
--------------------------------------------------------------------------------
/tests/Unit/Identification/StringHashTest.php:
--------------------------------------------------------------------------------
1 | expectException(\InvalidArgumentException::class);
18 | new Identification\StringHash($invalidId);
19 | }
20 |
21 | public function testValidateValid(): void
22 | {
23 | $this->assertInstanceOf(
24 | Identification\StringHash::class,
25 | (new Identification\StringHash(md5('herpderp')))
26 | );
27 | }
28 |
29 | public function invalidStringHashProvider(): array
30 | {
31 | return [
32 | [(object)[]],
33 | [[]],
34 | [-1],
35 | [1.1],
36 | ];
37 | }
38 | }
39 |
--------------------------------------------------------------------------------
/phpunit.xml.dist:
--------------------------------------------------------------------------------
1 |
2 |
16 |
17 |
18 |
19 | ./tests/Unit
20 |
21 |
22 |
23 |
24 |
25 |
26 |
27 |
28 |
29 | ./src
30 |
31 |
32 |
33 |
--------------------------------------------------------------------------------
/composer.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "remotelyliving/doorkeeper",
3 | "description": "A Feature Toggle",
4 | "minimum-stability": "stable",
5 | "license": "MIT",
6 | "keywords": ["Doorkeeper", "Feature Flag"],
7 | "authors": [
8 | {
9 | "name": "Christian Thomas",
10 | "email": "christian.h.thomas@me.com"
11 | }
12 | ],
13 | "require": {
14 | "php": ">=7.4",
15 | "psr/cache": "^1.0",
16 | "psr/log": "^1.0",
17 | "psr/http-message": "^1.0",
18 | "ramsey/uuid": "^3.8",
19 | "ext-json": "*",
20 | "ext-filter": "*",
21 | "ext-mbstring": "*"
22 | },
23 | "require-dev": {
24 | "phpunit/phpunit": "^9.0",
25 | "squizlabs/php_codesniffer": "^3.0",
26 | "php-coveralls/php-coveralls": "^2.0",
27 | "phpstan/phpstan": "^0.12.19",
28 | "maglnet/composer-require-checker": "^2.1"
29 | },
30 | "autoload": {
31 | "psr-4": {
32 | "RemotelyLiving\\Doorkeeper\\" : "src/"
33 | }
34 | },
35 | "autoload-dev": {
36 | "psr-4": {
37 | "RemotelyLiving\\Doorkeeper\\Tests\\" : "tests/"
38 | }
39 | }
40 | }
41 |
--------------------------------------------------------------------------------
/tests/Unit/Identification/PipedCompositeTest.php:
--------------------------------------------------------------------------------
1 | expectException(\InvalidArgumentException::class);
18 | new Identification\PipedComposite($invalidId);
19 | }
20 |
21 | public function testValidateValid(): void
22 | {
23 | $this->assertInstanceOf(
24 | Identification\PipedComposite::class,
25 | (new Identification\PipedComposite('boop|beep'))
26 | )
27 | ;
28 | }
29 |
30 | public function invalidIdProvider(): array
31 | {
32 | return [
33 | ['1'],
34 | ['boop'],
35 | [(object)[]],
36 | [[]],
37 | [-1]
38 | ];
39 | }
40 | }
41 |
--------------------------------------------------------------------------------
/tests/Unit/Rules/HttpHeaderTest.php:
--------------------------------------------------------------------------------
1 | createMock(Http\Message\RequestInterface::class);
17 | $request->method('getHeaderLine')
18 | ->with(Rules\HttpHeader::HEADER_KEY)
19 | ->willReturn('the header');
20 |
21 | $rule = new Rules\HttpHeader('the header');
22 |
23 | $requestor = new Requestor();
24 |
25 | $this->assertFalse($rule->canBeSatisfied());
26 | $this->assertFalse($rule->canBeSatisfied($requestor));
27 | $this->assertTrue($rule->canBeSatisfied($requestor->withRequest($request)));
28 | }
29 |
30 | public function testGetValue(): void
31 | {
32 | $this->assertEquals('header', (new Rules\HttpHeader('header'))->getValue());
33 | }
34 | }
35 |
--------------------------------------------------------------------------------
/tests/Unit/Features/FactoryTest.php:
--------------------------------------------------------------------------------
1 | assertEquals($factory->createFromArray($array), $expected);
20 | }
21 |
22 | public function createFromArrayDataProvider(): array
23 | {
24 | return [
25 | 'feature 1' => [
26 | ['name' => 'boop', 'enabled' => true, 'rules' => [['type' => Random::class]]],
27 | new Features\Feature('boop', true, [new Rules\Random()]),
28 | ],
29 | 'feature 2 no rules' => [
30 | ['name' => 'boop', 'enabled' => true],
31 | new Features\Feature('boop', true),
32 | ],
33 | ];
34 | }
35 | }
36 |
--------------------------------------------------------------------------------
/tests/Unit/Rules/UserIdTest.php:
--------------------------------------------------------------------------------
1 | assertFalse($rule->canBeSatisfied());
22 | $this->assertFalse($rule->canBeSatisfied($requestor));
23 |
24 | $this->assertTrue($rule->canBeSatisfied($requestor->withUserId($id)));
25 | }
26 |
27 | public function testGetValue(): void
28 | {
29 | $this->assertEquals(234, (new Rules\UserId(234))->getValue());
30 | }
31 |
32 | public function idProvider(): array
33 | {
34 | return [
35 | 'uuid' => ['4dd8ab53-162c-4681-930a-62879d9e4b5f'],
36 | 'integer id' => [234],
37 | 'string' => ['#hi'],
38 | ];
39 | }
40 | }
41 |
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | MIT License
2 |
3 | Copyright (c) 2017 Christian Thomas
4 |
5 | Permission is hereby granted, free of charge, to any person obtaining
6 | a copy of this software and associated documentation files (the
7 | "Software"), to deal in the Software without restriction, including
8 | without limitation the rights to use, copy, modify, merge, publish,
9 | distribute, sublicense, and/or sell copies of the Software, and to
10 | permit persons to whom the Software is furnished to do so, subject to
11 | the following conditions:
12 |
13 | The above copyright notice and this permission notice shall be
14 | included in all copies or substantial portions of the Software.
15 |
16 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
17 | EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
18 | MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
19 | NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE
20 | LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
21 | OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
22 | WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
--------------------------------------------------------------------------------
/src/Rules/Percentage.php:
--------------------------------------------------------------------------------
1 | 100) {
19 | throw new \InvalidArgumentException("Percentage must be represented as a value from 1 to 100");
20 | }
21 |
22 | $this->chances = $percentage;
23 | $this->randomizer = $randomizer ?? new Utilities\Randomizer();
24 | }
25 |
26 | public function getValue()
27 | {
28 | return $this->chances;
29 | }
30 |
31 | protected function childCanBeSatisfied(RequestorInterface $requestor = null): bool
32 | {
33 | $lotteryNumber = $this->randomizer->generateRangedRandomInt(1, 100);
34 |
35 | if ($this->chances === 100) {
36 | return true;
37 | }
38 |
39 | return $lotteryNumber <= $this->chances;
40 | }
41 | }
42 |
--------------------------------------------------------------------------------
/tests/Unit/Identification/IpAddressTest.php:
--------------------------------------------------------------------------------
1 | expectException(\InvalidArgumentException::class);
18 | new Identification\IpAddress($invalidId);
19 | }
20 |
21 | public function testValidateValid(): void
22 | {
23 | $this->assertInstanceOf(
24 | Identification\IpAddress::class,
25 | (new Identification\IpAddress('127.0.0.1'))
26 | );
27 |
28 | $this->assertInstanceOf(
29 | Identification\IpAddress::class,
30 | (new Identification\IpAddress('2001:0db8:85a3:0000:0000:8a2e:0370:7334'))
31 | );
32 | }
33 |
34 | public function invalidIpAddressProvider(): array
35 | {
36 | return [
37 | ['1'],
38 | ['boop'],
39 | [(object)[]],
40 | [[]],
41 | [-1]
42 | ];
43 | }
44 | }
45 |
--------------------------------------------------------------------------------
/tests/Unit/Rules/RuntimeCallableTest.php:
--------------------------------------------------------------------------------
1 | assertTrue($callableTrue->canBeSatisfied());
28 | $this->assertFalse($callableFalse->canBeSatisfied());
29 | $this->assertTrue($callableWithRequestor->canBeSatisfied(new Requestor()));
30 | $this->assertFalse($callableWithRequestor->canBeSatisfied());
31 | }
32 |
33 | public function testGetValue(): void
34 | {
35 | $fn = fn() => null;
36 | $this->assertNull((new Rules\RuntimeCallable($fn))->getValue());
37 | }
38 | }
39 |
--------------------------------------------------------------------------------
/src/Features/SetRepository.php:
--------------------------------------------------------------------------------
1 | cache = $cache;
16 | }
17 |
18 | public function saveFeatureSet(Set $set): void
19 | {
20 | $cacheItem = $this->cache->getItem(self::generateFeatureSetCacheKey());
21 | $cacheItem->set($set);
22 |
23 | $this->cache->save($cacheItem);
24 | }
25 |
26 | public function getFeatureSet(SetProviderInterface $fallback = null): Set
27 | {
28 | $result = $this->cache->getItem(self::generateFeatureSetCacheKey())->get();
29 |
30 | if (!$result && $fallback) {
31 | $result = $fallback->getFeatureSet();
32 | $this->saveFeatureSet($fallback->getFeatureSet());
33 | }
34 |
35 | return ($result) ?? new Set();
36 | }
37 |
38 | public function deleteFeatureSet(): void
39 | {
40 | $this->cache->deleteItem(self::generateFeatureSetCacheKey());
41 | }
42 |
43 | public static function generateFeatureSetCacheKey(): string
44 | {
45 | return md5(self::class);
46 | }
47 | }
48 |
--------------------------------------------------------------------------------
/tests/Unit/Identification/UserIdTest.php:
--------------------------------------------------------------------------------
1 | expectException(\InvalidArgumentException::class);
18 | new Identification\UserId($invalidId);
19 | }
20 |
21 | public function testValidateValid(): void
22 | {
23 | $this->assertInstanceOf(
24 | Identification\UserId::class,
25 | (new Identification\UserId('#string_id#'))
26 | )
27 | ;
28 | $this->assertInstanceOf(Identification\UserId::class, (new Identification\UserId(4)));
29 | $this->assertInstanceOf(
30 | Identification\UserId::class,
31 | (new Identification\UserId('4dd8ab53-162c-4681-930a-62879d9e4b5f'))
32 | );
33 | }
34 |
35 | public function invalidStringHashProvider(): array
36 | {
37 | return [
38 | [(object)[]],
39 | [[]],
40 | [true],
41 | [false],
42 | [1.123]
43 | ];
44 | }
45 | }
46 |
--------------------------------------------------------------------------------
/tests/Unit/Identification/EnvironmentTest.php:
--------------------------------------------------------------------------------
1 | expectException(\InvalidArgumentException::class);
18 | new Identification\Environment($invalidEnv);
19 | }
20 |
21 | public function testValidateValid(): void
22 | {
23 | $this->assertInstanceOf(Identification\Environment::class, new Identification\Environment('DEV'));
24 | }
25 |
26 | public function testEquals(): void
27 | {
28 | $devEnv = new Identification\Environment('dev');
29 | $prodEnv = new Identification\Environment('prod');
30 | $otherDev = new Identification\Environment('dev');
31 |
32 | $this->assertTrue($devEnv->equals($otherDev));
33 | $this->assertFalse($devEnv->equals($prodEnv));
34 | }
35 |
36 | public function invalidEnvProvider(): array
37 | {
38 | return [
39 | [(object)[]],
40 | [1],
41 | [true],
42 | [false]
43 | ];
44 | }
45 | }
46 |
--------------------------------------------------------------------------------
/tests/Unit/Features/FeatureTest.php:
--------------------------------------------------------------------------------
1 | assertTrue($enabled->isEnabled());
19 | $this->assertFalse($disabled->isEnabled());
20 | }
21 |
22 | public function testGetsId(): void
23 | {
24 | $feature = new Features\Feature('someId', false);
25 |
26 | $this->assertSame('someId', $feature->getName());
27 | }
28 |
29 | public function testGetsRules(): void
30 | {
31 | $feature = new Features\Feature('someId', true);
32 | $rule = new Rules\Random();
33 |
34 | $this->assertEmpty($feature->getRules());
35 |
36 | $feature->addRule($rule);
37 |
38 | $rules = $feature->getRules();
39 |
40 | $this->assertSame($rule, array_shift($rules));
41 | }
42 |
43 | public function testJsonSerializes(): void
44 | {
45 | $feature = new Features\Feature('someId', true);
46 | $this->assertEquals('{"name":"someId","enabled":true,"rules":[]}', json_encode($feature));
47 | }
48 | }
49 |
--------------------------------------------------------------------------------
/tests/Unit/Identification/HttpHeaderTest.php:
--------------------------------------------------------------------------------
1 | expectException(\InvalidArgumentException::class);
20 | new Identification\HttpHeader($invalidId);
21 | }
22 |
23 | public function validateValid(): void
24 | {
25 | $this->assertInstanceOf(
26 | Identification\HttpHeader::class,
27 | (new Identification\HttpHeader('herpderp'))
28 | );
29 | }
30 |
31 | public function testCreateFromRequest(): void
32 | {
33 | $request = $this->createMock(Http\Message\RequestInterface::class);
34 | $request->method('getHeaderLine')
35 | ->with(Rules\HttpHeader::HEADER_KEY)
36 | ->willReturn('boop');
37 |
38 | $this->assertInstanceOf(
39 | Identification\HttpHeader::class,
40 | Identification\HttpHeader::createFromRequest($request)
41 | );
42 | }
43 |
44 | public function invalidStringHashProvider(): array
45 | {
46 | return [
47 | [(object)[]],
48 | ];
49 | }
50 | }
51 |
--------------------------------------------------------------------------------
/src/Utilities/RuntimeCache.php:
--------------------------------------------------------------------------------
1 | maxCacheItems = $maxCacheItems;
19 | }
20 |
21 | /**
22 | * @return mixed|null
23 | */
24 | public function get(string $key, callable $fallback = null)
25 | {
26 | if ($this->has($key)) {
27 | return $this->cache[$key];
28 | }
29 |
30 | if (!$fallback) {
31 | return null;
32 | }
33 |
34 | $result = $fallback();
35 |
36 | $this->set($key, $result);
37 |
38 | return $result;
39 | }
40 |
41 | public function has(string $key): bool
42 | {
43 | return isset($this->cache[$key]);
44 | }
45 |
46 | /**
47 | * @param mixed $value
48 | */
49 | public function set(string $key, $value): void
50 | {
51 | if ($this->maxCacheItems !== null && count($this->cache) >= $this->maxCacheItems) {
52 | array_shift($this->cache);
53 | }
54 |
55 | $this->cache[$key] = $value;
56 | }
57 |
58 | public function destroy(string $key): void
59 | {
60 | unset($this->cache[$key]);
61 | }
62 |
63 | public function flush(): void
64 | {
65 | $this->cache = [];
66 | }
67 | }
68 |
--------------------------------------------------------------------------------
/src/Features/Set.php:
--------------------------------------------------------------------------------
1 | pushFeature($feature);
21 | }
22 | }
23 |
24 | public function pushFeature(Feature $feature): void
25 | {
26 | $this->features[$feature->getName()] = $feature;
27 | }
28 |
29 | public function offsetExists(string $featureName): bool
30 | {
31 | return isset($this->features[$featureName]);
32 | }
33 |
34 | /**
35 | * @return \RemotelyLiving\Doorkeeper\Features\Feature[]
36 | */
37 | public function getFeatures(): array
38 | {
39 | return $this->features;
40 | }
41 |
42 | public function getFeatureByName(string $featureName): Feature
43 | {
44 | if (!$this->offsetExists($featureName)) {
45 | throw new \OutOfBoundsException("Feature {$featureName} does not exist");
46 | }
47 |
48 | return $this->features[$featureName];
49 | }
50 |
51 | /**
52 | * @return array
53 | */
54 | public function jsonSerialize(): array
55 | {
56 | return ['features' => $this->features];
57 | }
58 | }
59 |
--------------------------------------------------------------------------------
/tests/Unit/Rules/PercentageTest.php:
--------------------------------------------------------------------------------
1 | getMockBuilder(Utilities\Randomizer::class)
16 | ->onlyMethods(['mtRand'])
17 | ->getMock();
18 |
19 | $rule = new Rules\Percentage(5, $randomizer);
20 |
21 | $randomizer->method('mtRand')
22 | ->willReturnMap([
23 | [1, 100, 3],
24 | [1, 5, 3]
25 | ]);
26 |
27 | $this->assertTrue($rule->canBeSatisfied());
28 | }
29 |
30 | public function testCanBeSatisfied100PercentChance(): void
31 | {
32 | $rule = new Rules\Percentage(100);
33 |
34 | $this->assertTrue($rule->canBeSatisfied());
35 | }
36 |
37 | public function testCanBeSatisfied0PercentChance(): void
38 | {
39 | $rule = new Rules\Percentage(0);
40 |
41 | $this->assertFalse($rule->canBeSatisfied());
42 | }
43 |
44 | public function testInvalidPercentageOverOneHundred(): void
45 | {
46 | $this->expectException(\InvalidArgumentException::class);
47 | new Rules\Percentage(101);
48 | }
49 |
50 | public function testGetValue(): void
51 | {
52 | $this->assertEquals(4, (new Rules\Percentage(4))->getValue());
53 | }
54 | }
55 |
--------------------------------------------------------------------------------
/tests/Unit/Rules/TimeAfterTest.php:
--------------------------------------------------------------------------------
1 | getMockBuilder(Utilities\Time::class)
16 | ->onlyMethods(['getImmutableDateTime'])
17 | ->getMock();
18 |
19 | $time->method('getImmutableDateTime')
20 | ->willReturnMap([
21 | ['boop', new \DateTimeImmutable('2011-11-12')],
22 | ['now', new \DateTimeImmutable('2011-12-12')],
23 | ]);
24 |
25 | $rule = new Rules\TimeAfter('boop', $time);
26 |
27 | $this->assertTrue($rule->canBeSatisfied());
28 | }
29 |
30 | public function testCannotBeSatisfied(): void
31 | {
32 | $time = $this->getMockBuilder(Utilities\Time::class)
33 | ->onlyMethods(['getImmutableDateTime'])
34 | ->getMock();
35 |
36 | $time->method('getImmutableDateTime')
37 | ->willReturnMap([
38 | ['boop', new \DateTimeImmutable('2011-12-12')],
39 | ['now', new \DateTimeImmutable('2011-11-12')],
40 | ]);
41 |
42 | $rule = new Rules\TimeAfter('boop', $time);
43 |
44 | $this->assertFalse($rule->canBeSatisfied());
45 | }
46 |
47 | public function testGetValue(): void
48 | {
49 | $this->assertEquals('2011-12-12 00:00:00', (new Rules\TimeAfter('2011-12-12'))->getValue());
50 | }
51 | }
52 |
--------------------------------------------------------------------------------
/tests/Unit/Rules/TimeBeforeTest.php:
--------------------------------------------------------------------------------
1 | getMockBuilder(Utilities\Time::class)
16 | ->onlyMethods(['getImmutableDateTime'])
17 | ->getMock();
18 |
19 | $time->method('getImmutableDateTime')
20 | ->willReturnMap([
21 | ['boop', new \DateTimeImmutable('2011-11-12')],
22 | ['now', new \DateTimeImmutable('2011-12-12')],
23 | ]);
24 |
25 | $rule = new Rules\TimeBefore('boop', $time);
26 |
27 | $this->assertFalse($rule->canBeSatisfied());
28 | }
29 |
30 | public function testCanBeSatisfied(): void
31 | {
32 | $time = $this->getMockBuilder(Utilities\Time::class)
33 | ->onlyMethods(['getImmutableDateTime'])
34 | ->getMock();
35 |
36 | $time->method('getImmutableDateTime')
37 | ->willReturnMap([
38 | ['boop', new \DateTimeImmutable('2011-12-12')],
39 | ['now', new \DateTimeImmutable('2011-11-12')],
40 | ]);
41 |
42 | $rule = new Rules\TimeBefore('boop', $time);
43 |
44 | $this->assertTrue($rule->canBeSatisfied());
45 | }
46 |
47 | public function testGetValue(): void
48 | {
49 | $this->assertEquals('2011-12-12 00:00:00', (new Rules\TimeBefore('2011-12-12'))->getValue());
50 | }
51 | }
52 |
--------------------------------------------------------------------------------
/src/Identification/AbstractIdentification.php:
--------------------------------------------------------------------------------
1 | validate($identifier);
28 |
29 | $this->identifier = $identifier;
30 | $this->type = get_class($this);
31 | $this->identityHash = self::createIdentityHash($this);
32 | }
33 |
34 | final public function getIdentifier()
35 | {
36 | return $this->identifier;
37 | }
38 |
39 | final public function getType(): string
40 | {
41 | return $this->type;
42 | }
43 |
44 | final public function equals(IdentificationInterface $identity): bool
45 | {
46 | return $this->identityHash === self::createIdentityHash($identity);
47 | }
48 |
49 | /**
50 | * @param string|int|float $value
51 | */
52 | abstract protected function validate($value): void;
53 |
54 | private static function createIdentityHash(IdentificationInterface $identification): string
55 | {
56 | return md5($identification->getType() . (string)$identification->getIdentifier());
57 | }
58 | }
59 |
--------------------------------------------------------------------------------
/tests/Unit/Features/SetTest.php:
--------------------------------------------------------------------------------
1 | assertEquals(['feature1Id' => $feature1, 'feature2Id' => $feature2], $set->getFeatures());
20 | $this->assertEquals($feature1, $set->getFeatureByName($feature1->getName()));
21 | $this->assertFalse($set->offsetExists('boop'));
22 | $this->assertTrue($set->offsetExists($feature2->getName()));
23 | }
24 |
25 | public function testJsonSerializesFeatures(): void
26 | {
27 | $feature_1 = new Features\Feature('feature1Id', false);
28 | $feature_2 = new Features\Feature('feature2Id', true);
29 |
30 | $set = new Features\Set([$feature_1, $feature_2]);
31 | // phpcs:disable
32 | $expected_json = '{"features":{"feature1Id":{"name":"feature1Id","enabled":false,"rules":[]},"feature2Id":{"name":"feature2Id","enabled":true,"rules":[]}}}';
33 | // phpcs:enable
34 |
35 | $this->assertEquals($expected_json, json_encode($set));
36 | }
37 |
38 | public function testThrowsOutOfBoundsExceptionWhenFeatureNotFound(): void
39 | {
40 | $this->expectException(\OutOfBoundsException::class);
41 | (new Features\Set())->getFeatureByName('slkf');
42 | }
43 | }
44 |
--------------------------------------------------------------------------------
/src/Features/Feature.php:
--------------------------------------------------------------------------------
1 | name = $name;
28 | $this->enabled = $enabled;
29 |
30 | foreach ($rules as $rule) {
31 | $this->addRule($rule);
32 | }
33 | }
34 |
35 | public function getName(): string
36 | {
37 | return $this->name;
38 | }
39 |
40 | public function isEnabled(): bool
41 | {
42 | return $this->enabled;
43 | }
44 |
45 | /**
46 | * @throws \DomainException
47 | */
48 | public function addRule(Rules\RuleInterface $rule): void
49 | {
50 | $this->ruleSet[] = $rule;
51 | }
52 |
53 | /**
54 | * @return \RemotelyLiving\Doorkeeper\Rules\RuleInterface[]
55 | */
56 | public function getRules(): array
57 | {
58 | return $this->ruleSet;
59 | }
60 |
61 | public function jsonSerialize(): array
62 | {
63 | return [
64 | 'name' => $this->name,
65 | 'enabled' => $this->enabled,
66 | 'rules' => $this->ruleSet,
67 | ];
68 | }
69 | }
70 |
--------------------------------------------------------------------------------
/tests/Unit/Identification/CollectionTest.php:
--------------------------------------------------------------------------------
1 | expectException(\InvalidArgumentException::class);
15 | $collection = new Identification\Collection(
16 | Identification\Environment::class,
17 | [new Identification\Environment('dev')]
18 | )
19 | ;
20 | $collection->add(new Identification\HttpHeader('boop'));
21 | }
22 |
23 | public function testAddsAndGetsRemovesCountsAndHasAndIsIterableLol(): void
24 | {
25 | $prod = new Identification\Environment('prod');
26 | $dev = new Identification\Environment('dev');
27 | $collection = new Identification\Collection(Identification\Environment::class, [$dev]);
28 | $collection->add($prod);
29 | $collection->add($prod); // overwrites
30 |
31 | $this->assertEquals($dev, $collection->get('dev'));
32 | $this->assertEquals($prod, $collection->get('prod'));
33 |
34 | $this->assertTrue($collection->has($dev));
35 | $this->assertTrue($collection->has($prod));
36 | $this->assertFalse($collection->has(new Identification\Environment('derp')));
37 |
38 | $this->assertEquals(2, $collection->count());
39 | $collection->remove($dev);
40 |
41 | $this->assertFalse($collection->has($dev));
42 | $this->assertEquals(1, $collection->count());
43 |
44 | foreach ($collection as $id) {
45 | $this->assertEquals($prod, $id);
46 | }
47 | }
48 | }
49 |
--------------------------------------------------------------------------------
/src/Identification/Collection.php:
--------------------------------------------------------------------------------
1 | classType = $classType;
19 |
20 | foreach ($identifications as $identification) {
21 | $this->add($identification);
22 | }
23 | }
24 |
25 | public function getIterator(): \ArrayIterator
26 | {
27 | return new \ArrayIterator($this->identifications);
28 | }
29 |
30 | public function count(): int
31 | {
32 | return count($this->identifications);
33 | }
34 |
35 | public function add(IdentificationInterface $identification): void
36 | {
37 | if (get_class($identification) !== $this->classType) {
38 | throw new \InvalidArgumentException("Identification must be a {$this->classType}");
39 | }
40 |
41 | $this->identifications[$identification->getIdentifier()] = $identification;
42 | }
43 |
44 | public function remove(IdentificationInterface $identification): void
45 | {
46 | if ($this->has($identification)) {
47 | unset($this->identifications[$identification->getIdentifier()]);
48 | }
49 | }
50 |
51 | public function get(string $id): ?IdentificationInterface
52 | {
53 | return $this->identifications[$id] ?? null;
54 | }
55 |
56 | public function has(IdentificationInterface $identification): bool
57 | {
58 | return isset($this->identifications[$identification->getIdentifier()]);
59 | }
60 | }
61 |
--------------------------------------------------------------------------------
/src/Rules/Factory.php:
--------------------------------------------------------------------------------
1 | ruleTypeMapper = $typeMapper ?? new TypeMapper();
14 | }
15 |
16 | public function createFromArray(array $fields): RuleInterface
17 | {
18 | $ruleType = $this->normalizeRuleType($fields['type']);
19 |
20 | /** @var \RemotelyLiving\Doorkeeper\Rules\RuleInterface $rule */
21 | $rule = isset($fields['value']) ? new $ruleType($fields['value']) : new $ruleType();
22 |
23 | return isset($fields['prerequisites']) ? $this->addPrerequisites($rule, $fields['prerequisites']) : $rule;
24 | }
25 |
26 | private function addPrerequisites(RuleInterface $rule, array $prequisites): RuleInterface
27 | {
28 | foreach ($prequisites as $prequisite) {
29 | $preReqType = $this->normalizeRuleType($prequisite['type']);
30 | $preReq = isset($prequisite['value']) ? new $preReqType($prequisite['value']) : new $preReqType();
31 |
32 | $rule->addPrerequisite($preReq);
33 | }
34 |
35 | return $rule;
36 | }
37 |
38 | /**
39 | * @param string|int $type
40 | */
41 | private function normalizeRuleType($type): string
42 | {
43 | if (is_numeric($type)) {
44 | return $this->ruleTypeMapper->getClassNameById((int) $type);
45 | }
46 |
47 | $fqcnSegments = explode('\\', $type);
48 | $className = array_pop($fqcnSegments);
49 |
50 | if (class_exists(__NAMESPACE__ . "\\{$className}")) {
51 | return __NAMESPACE__ . "\\{$className}";
52 | }
53 |
54 | throw new \InvalidArgumentException("{$type} is not a valid rule type");
55 | }
56 | }
57 |
--------------------------------------------------------------------------------
/tests/Unit/Utilities/RuntimeCacheTest.php:
--------------------------------------------------------------------------------
1 | cache = new Utilities\RuntimeCache();
19 | }
20 |
21 | public function testGetNotCached(): void
22 | {
23 | $this->assertNull($this->cache->get('boop'));
24 | }
25 |
26 | public function testGetNotCachedUsesCallback(): void
27 | {
28 | $fallback = function () {
29 | return 'beep';
30 | };
31 |
32 | $this->assertEquals('beep', $this->cache->get('boop', $fallback));
33 | }
34 |
35 | public function testSetWorksAndGetIgnoresFallback(): void
36 | {
37 | $this->cache->set('herp', ['derp']);
38 |
39 | $this->assertEquals(['derp'], $this->cache->get(
40 | 'herp',
41 | function () {
42 | return 'wrong';
43 | }
44 | ));
45 | }
46 |
47 | public function testDestroy(): void
48 | {
49 | $this->cache->set('boop', 'beep');
50 | $this->cache->set('herp', 'derp');
51 | $this->cache->destroy('boop');
52 |
53 | $this->assertEquals('derp', $this->cache->get('herp'));
54 | $this->assertNull($this->cache->get('boop'));
55 | }
56 |
57 | public function testSetsCacheLimit(): void
58 | {
59 | $cache = new Utilities\RuntimeCache(2);
60 |
61 | $cache->set('first', 1);
62 |
63 | $this->assertEquals(1, $cache->get('first'));
64 |
65 | $cache->set('second', 2);
66 | $cache->set('third', 3);
67 |
68 | $this->assertNull($cache->get('first'));
69 | $this->assertEquals(3, $cache->get('third'));
70 | $this->assertEquals(2, $cache->get('second'));
71 | }
72 | }
73 |
--------------------------------------------------------------------------------
/src/Rules/AbstractRule.php:
--------------------------------------------------------------------------------
1 | prerequisites[] = $rule;
20 | }
21 |
22 | final public function hasPrerequisites(): bool
23 | {
24 | return (bool) $this->prerequisites;
25 | }
26 |
27 | final public function getPrerequisites(): iterable
28 | {
29 | return $this->prerequisites;
30 | }
31 |
32 | public function getValue()
33 | {
34 | return null;
35 | }
36 |
37 | final public function canBeSatisfied(RequestorInterface $requestor = null): bool
38 | {
39 | if ($this->hasPrerequisites()) {
40 | foreach ($this->prerequisites as $prerequisite) {
41 | if (!$prerequisite->canBeSatisfied($requestor)) {
42 | return false;
43 | }
44 | }
45 | }
46 |
47 | return $this->childCanBeSatisfied($requestor);
48 | }
49 |
50 | public function jsonSerialize(): array
51 | {
52 | return [
53 | 'type' => static::class,
54 | 'value' => $this->getValue(),
55 | 'prerequisites' => $this->prerequisites,
56 | ];
57 | }
58 |
59 | protected function requestorHasMatchingId(
60 | RequestorInterface $requestor = null,
61 | Identification\IdentificationInterface $identification
62 | ): bool {
63 | if (!$requestor) {
64 | return false;
65 | }
66 |
67 | return $requestor->hasIdentification($identification);
68 | }
69 |
70 | abstract protected function childCanBeSatisfied(RequestorInterface $requestor = null): bool;
71 | }
72 |
--------------------------------------------------------------------------------
/tests/Unit/Rules/EnvironmentTest.php:
--------------------------------------------------------------------------------
1 | assertFalse($rule->canBeSatisfied());
19 | $this->assertFalse($rule->canBeSatisfied($requestor));
20 | $this->assertTrue($rule->canBeSatisfied($requestor->withEnvironment('DEV')));
21 | $this->assertFalse($rule->canBeSatisfied($requestor->withEnvironment('PROD')));
22 | }
23 |
24 | public function testCannotBeSatisfiedWithFalseyPrereq()
25 | {
26 | $rule = new Rules\Environment('DEV');
27 | $prereq = new Rules\HttpHeader('headerValue');
28 |
29 | $rule->addPrerequisite($prereq);
30 |
31 | $this->assertTrue($rule->hasPrerequisites());
32 |
33 | $requestor = new Requestor();
34 |
35 | $this->assertFalse($rule->canBeSatisfied());
36 | $this->assertFalse($rule->canBeSatisfied($requestor));
37 | $this->assertFalse($rule->canBeSatisfied($requestor->withEnvironment('DEV')));
38 | }
39 |
40 | public function testCanBeSatisfiedWithTruthyPrereq(): void
41 | {
42 | $rule = new Rules\Environment('DEV');
43 | $prereq = new Rules\UserId(321);
44 |
45 | $rule->addPrerequisite($prereq);
46 |
47 | $this->assertTrue($rule->hasPrerequisites());
48 |
49 | $requestor = new Requestor();
50 |
51 | $this->assertFalse($rule->canBeSatisfied());
52 | $this->assertFalse($rule->canBeSatisfied($requestor));
53 | $this->assertTrue($rule->canBeSatisfied($requestor->withEnvironment('DEV')->withUserId(321)));
54 | $this->assertEquals([$prereq], $rule->getPrerequisites());
55 | }
56 |
57 | public function testGetValue()
58 | {
59 | $this->assertEquals('dev', (new Rules\Environment('dev'))->getValue());
60 | }
61 | }
62 |
--------------------------------------------------------------------------------
/src/Rules/TypeMapper.php:
--------------------------------------------------------------------------------
1 | HttpHeader::class,
25 | self::RULE_TYPE_IP_ADDRESS => IpAddress::class,
26 | self::RULE_TYPE_PERCENTAGE => Percentage::class,
27 | self::RULE_TYPE_RANDOM => Random::class,
28 | self::RULE_TYPE_STRING_HASH => StringHash::class,
29 | self::RULE_TYPE_USER_ID => UserId::class,
30 | self::RULE_TYPE_ENVIRONMENT => Environment::class,
31 | self::RULE_TYPE_BEFORE => TimeBefore::class,
32 | self::RULE_TYPE_AFTER => TimeAfter::class,
33 | self::RULE_TYPE_PIPED_COMPOSITE => PipedComposite::class,
34 | ];
35 |
36 | public function __construct(array $extraTypes = [])
37 | {
38 | foreach ($extraTypes as $id => $name) {
39 | $this->pushExtraType($id, $name);
40 | }
41 | }
42 |
43 | public function getClassNameById(int $integerId): string
44 | {
45 | return $this->typeMap[$integerId];
46 | }
47 |
48 | public function getIdForClassName(string $className): int
49 | {
50 | return array_flip($this->typeMap)[$className];
51 | }
52 |
53 | public function pushExtraType(int $id, string $className): void
54 | {
55 |
56 | if (isset($this->typeMap[$id])) {
57 | throw new \DomainException("Type {$id} already set by parent");
58 | }
59 |
60 | if (!class_exists($className)) {
61 | throw new \InvalidArgumentException("{$className} is not a loaded class");
62 | }
63 |
64 | $this->typeMap[$id] = $className;
65 | }
66 | }
67 |
--------------------------------------------------------------------------------
/src/Logger/Processor.php:
--------------------------------------------------------------------------------
1 | getIdentificationCollections() as $identificationCollection) {
35 | foreach ($identificationCollection as $id) {
36 | if (isset($this->filteredIdentities[get_class($id)])) {
37 | continue;
38 | }
39 | $key = $this->getKeyFromIdentification($id);
40 | $requestorContext[self::CONTEXT_KEY_IDENTIFIERS][$key][] = $id->getIdentifier();
41 | }
42 | }
43 |
44 | $requestorContext = $this->flattenIdCollections($requestorContext);
45 | $record['context'][static::CONTEXT_KEY_REQUESTOR] = $requestorContext;
46 |
47 | return $record;
48 | }
49 |
50 | public function setFilteredIdentityTypes(array $idTypes): void
51 | {
52 | $this->filteredIdentities = array_flip($idTypes);
53 | }
54 |
55 | private function getKeyFromIdentification(Identification\IdentificationInterface $identity): string
56 | {
57 | $classPaths = explode('\\', $identity->getType());
58 |
59 | return (string) array_pop($classPaths);
60 | }
61 |
62 | private function flattenIdCollections(array $requestorContext): array
63 | {
64 | if (!isset($requestorContext[self::CONTEXT_KEY_IDENTIFIERS])) {
65 | return $requestorContext;
66 | }
67 |
68 | $flattened = [];
69 |
70 | foreach ($requestorContext[self::CONTEXT_KEY_IDENTIFIERS] as $idName => $identifiers) {
71 | $flattened[$idName] = $identifiers;
72 | }
73 |
74 | $requestorContext[self::CONTEXT_KEY_IDENTIFIERS] = json_encode($flattened);
75 |
76 | return $requestorContext;
77 | }
78 | }
79 |
--------------------------------------------------------------------------------
/tests/Unit/Features/SetRepositoryTest.php:
--------------------------------------------------------------------------------
1 | cache = $this->createMock(PSRCache\CacheItemPoolInterface::class);
23 | $this->sut = new Features\SetRepository($this->cache);
24 | }
25 |
26 | public function testSaveFeatureSet(): void
27 | {
28 | $cacheItem = $this->createMock(PSRCache\CacheItemInterface::class);
29 | $featureSet = new Features\Set();
30 |
31 | $cacheItem->expects($this->once())
32 | ->method('set')
33 | ->with($featureSet);
34 |
35 | $this->cache->method('getItem')
36 | ->with(Features\SetRepository::generateFeatureSetCacheKey())
37 | ->willReturn($cacheItem);
38 |
39 | $this->cache->expects($this->once())
40 | ->method('save')
41 | ->with($cacheItem);
42 |
43 | $this->sut->saveFeatureSet($featureSet);
44 | }
45 |
46 | public function testGetFeatureSet(): void
47 | {
48 | $cacheItem = $this->createMock(PSRCache\CacheItemInterface::class);
49 | $featureSet = new Features\Set();
50 |
51 | $cacheItem->method('get')
52 | ->willReturn($featureSet);
53 |
54 | $this->cache->method('getItem')
55 | ->with(Features\SetRepository::generateFeatureSetCacheKey())
56 | ->willReturn($cacheItem);
57 |
58 | $this->assertEquals($featureSet, $this->sut->getFeatureSet());
59 | }
60 |
61 | public function testGetFeatureSetWithCallback(): void
62 | {
63 | $cacheItem = $this->createMock(PSRCache\CacheItemInterface::class);
64 |
65 | $cacheItem->method('get')
66 | ->willReturn(null);
67 |
68 | $this->cache->method('getItem')
69 | ->with(Features\SetRepository::generateFeatureSetCacheKey())
70 | ->willReturn($cacheItem);
71 |
72 | $otherProvider = new class implements Features\SetProviderInterface {
73 | public function getFeatureSet(): Features\Set
74 | {
75 | return new Features\Set([new Features\Feature('lioi', true)]);
76 | }
77 | };
78 |
79 | $this->assertEquals(
80 | new Features\Set([new Features\Feature('lioi', true)]),
81 | $this->sut->getFeatureSet($otherProvider)
82 | );
83 | }
84 |
85 | public function testDeleteFeatureSet(): void
86 | {
87 | $this->cache->expects($this->once())
88 | ->method('deleteItem')
89 | ->with(Features\SetRepository::generateFeatureSetCacheKey());
90 |
91 | $this->sut->deleteFeatureSet();
92 | }
93 | }
94 |
--------------------------------------------------------------------------------
/tests/Unit/Logger/ProcessorTest.php:
--------------------------------------------------------------------------------
1 | [Logger\Processor::FEATURE_ID => 'oye', Logger\Processor::CONTEXT_KEY_REQUESTOR => null]
19 | ];
20 |
21 | $record2 = ['context' => [Logger\Processor::FEATURE_ID => 'oye']];
22 |
23 | $this->assertSame($record1, $processor($record1));
24 | $this->assertSame($record2, $processor($record2));
25 | }
26 |
27 | public function testInvokeWithRequestor(): void
28 | {
29 | $requestor = (new Requestor())->withIpAddress('127.0.0.1')
30 | ->withUserId(123)
31 | ->withStringHash('hashymcgee')
32 | ->withStringHash('otherhashymgee');
33 |
34 | $processor = new Logger\Processor();
35 | $record = [
36 | 'context' => [Logger\Processor::FEATURE_ID => 'oye', Logger\Processor::CONTEXT_KEY_REQUESTOR => $requestor]
37 | ];
38 |
39 | $expected_ids = '{"IpAddress":["127.0.0.1"],"UserId":[123],"StringHash":["hashymcgee","otherhashymgee"]}';
40 | $expectedContext = [
41 | 'context' => [
42 | Logger\Processor::FEATURE_ID => 'oye',
43 | Logger\Processor::CONTEXT_KEY_REQUESTOR => [
44 | 'identifiers' => $expected_ids,
45 | ],
46 | ]
47 | ];
48 |
49 | $this->assertEquals($expectedContext, $processor($record));
50 | }
51 |
52 |
53 | public function testInvokeWithNoIdCollection(): void
54 | {
55 | $requestor = new Requestor();
56 | $processor = new Logger\Processor();
57 | $record = [
58 | 'context' => [Logger\Processor::FEATURE_ID => 'oye', Logger\Processor::CONTEXT_KEY_REQUESTOR => $requestor]
59 | ];
60 |
61 | $expectedContext = [
62 | 'context' => [
63 | Logger\Processor::FEATURE_ID => 'oye',
64 | Logger\Processor::CONTEXT_KEY_REQUESTOR => [],
65 | ]
66 | ];
67 |
68 | $this->assertEquals($expectedContext, $processor($record));
69 | }
70 |
71 | public function testInvokeWithRequestorAndFilteredFields(): void
72 | {
73 | $requestor = (new Requestor())->withIpAddress('127.0.0.1')
74 | ->withUserId(123)
75 | ->withStringHash('hashymcgee');
76 |
77 | $processor = new Logger\Processor();
78 | $processor->setFilteredIdentityTypes(
79 | [Identification\IpAddress::class, 'arbitrary', Identification\UserId::class]
80 | );
81 |
82 | $record = ['context' =>
83 | [Logger\Processor::FEATURE_ID => 'oye', Logger\Processor::CONTEXT_KEY_REQUESTOR => $requestor]
84 | ];
85 |
86 | $expectedContext = [
87 | 'context' => [
88 | Logger\Processor::FEATURE_ID => 'oye',
89 | Logger\Processor::CONTEXT_KEY_REQUESTOR => [
90 | 'identifiers' => '{"StringHash":["hashymcgee"]}',
91 | ],
92 | ]
93 | ];
94 |
95 | $this->assertEquals($expectedContext, $processor($record));
96 | }
97 | }
98 |
--------------------------------------------------------------------------------
/src/Requestor.php:
--------------------------------------------------------------------------------
1 | registerIdentification($identification);
21 | }
22 | }
23 |
24 | public function getIdentityHash(): string
25 | {
26 | return md5(serialize($this->idCollections));
27 | }
28 |
29 | public function registerIdentification(Identification\IdentificationInterface $identification): void
30 | {
31 | $type = $identification->getType();
32 |
33 | if (!isset($this->idCollections[$type])) {
34 | $this->idCollections[$type] = new Identification\Collection($type, [$identification]);
35 | return;
36 | }
37 |
38 | $this->idCollections[$type]->add($identification);
39 | }
40 |
41 | public function getIdentificationCollections(): array
42 | {
43 | return $this->idCollections;
44 | }
45 |
46 | public function hasIdentification(Identification\IdentificationInterface $identification): bool
47 | {
48 | if (!isset($this->idCollections[$identification->getType()])) {
49 | return false;
50 | }
51 |
52 | return $this->idCollections[$identification->getType()]->has($identification);
53 | }
54 |
55 | /**
56 | * @param string|int $id
57 | */
58 | public function withUserId($id): self
59 | {
60 | $mutee = clone $this;
61 | $mutee->registerIdentification(new Identification\UserId($id));
62 |
63 | return $mutee;
64 | }
65 |
66 | public function withIpAddress(string $ipAddress): self
67 | {
68 | $mutee = clone $this;
69 | $mutee->registerIdentification(new Identification\IpAddress($ipAddress));
70 |
71 | return $mutee;
72 | }
73 |
74 | public function withStringHash(string $hash): self
75 | {
76 | $mutee = clone $this;
77 | $mutee->registerIdentification(new Identification\StringHash($hash));
78 |
79 | return $mutee;
80 | }
81 |
82 | public function withRequest(Http\Message\RequestInterface $request): self
83 | {
84 | $mutee = clone $this;
85 | $mutee->registerIdentification(Identification\HttpHeader::createFromRequest($request));
86 |
87 | return $mutee;
88 | }
89 |
90 | public function withEnvironment(string $environment): self
91 | {
92 | $mutee = clone $this;
93 | $mutee->registerIdentification(new Identification\Environment($environment));
94 |
95 | return $mutee;
96 | }
97 |
98 | public function withPipedComposite(string $pipedComposite): self
99 | {
100 | $mutee = clone $this;
101 | $mutee->registerIdentification(new Identification\PipedComposite($pipedComposite));
102 |
103 | return $mutee;
104 | }
105 |
106 | public function withIntegerId(int $integerId): self
107 | {
108 | $mutee = clone $this;
109 | $mutee->registerIdentification(new Identification\IntegerId($integerId));
110 |
111 | return $mutee;
112 | }
113 | }
114 |
--------------------------------------------------------------------------------
/src/Doorkeeper.php:
--------------------------------------------------------------------------------
1 | featureSet = $featureSet;
28 | $this->auditLog = $auditLog ?? new PSRLog\NullLogger();
29 | $this->runtimeCache = $cache ?? new Utilities\RuntimeCache();
30 | }
31 |
32 | /**
33 | * @throws \DomainException
34 | */
35 | public function setRequestor(RequestorInterface $requestor): void
36 | {
37 | if ($this->requestor) {
38 | throw new \DomainException('Requestor already set');
39 | }
40 |
41 | $this->requestor = $requestor;
42 | }
43 |
44 | public function getRequestor(): ?RequestorInterface
45 | {
46 | return $this->requestor;
47 | }
48 |
49 | public function grantsAccessTo(string $featureName): bool
50 | {
51 | return $this->grantsAccessToRequestor($featureName, $this->requestor);
52 | }
53 |
54 | public function grantsAccessToRequestor(string $featureName, RequestorInterface $requestor = null): bool
55 | {
56 | $logContext = [
57 | Logger\Processor::CONTEXT_KEY_REQUESTOR => $requestor,
58 | Logger\Processor::FEATURE_ID => $featureName,
59 | ];
60 |
61 | if (!$this->featureSet->offsetExists($featureName)) {
62 | $this->logAttempt('Access denied because feature does not exist.', $logContext);
63 | return false;
64 | }
65 |
66 | $cache_key = md5(sprintf('%s:%s', $featureName, ($requestor) ? $requestor->getIdentityHash() : ''));
67 | $fallback = function () use ($featureName, $requestor, $logContext): bool {
68 | $feature = $this->featureSet->getFeatureByName($featureName);
69 |
70 | if (!$feature->isEnabled()) {
71 | $this->logAttempt('Access denied because feature is disabled.', $logContext);
72 | return false;
73 | }
74 |
75 | if ($feature->isEnabled() && !$feature->getRules()) {
76 | return true;
77 | }
78 |
79 | foreach ($feature->getRules() as $rule) {
80 | if ($rule->canBeSatisfied($requestor)) {
81 | $this->logAttempt('Access granted to feature', $logContext);
82 | return true;
83 | }
84 | }
85 |
86 | $this->logAttempt('Access denied to feature', $logContext);
87 | return false;
88 | };
89 |
90 | return (bool) $this->runtimeCache->get($cache_key, $fallback);
91 | }
92 |
93 | public function flushRuntimeCache(): void
94 | {
95 | $this->runtimeCache->flush();
96 | }
97 |
98 | private function logAttempt(string $message, array $context): void
99 | {
100 | $this->auditLog->info($message, $context);
101 | }
102 | }
103 |
--------------------------------------------------------------------------------
/tests/Unit/Rules/FactoryTest.php:
--------------------------------------------------------------------------------
1 | factory = new Rules\Factory();
17 | }
18 |
19 | public function testCreateFromArray(): void
20 | {
21 | $random = new Rules\Random();
22 | $percent = new Rules\Percentage(1);
23 | $stringHash = new Rules\StringHash('some hash');
24 | $userId = new Rules\UserId(123);
25 | $ipAddress = new Rules\IpAddress('127.0.0.1');
26 | $header = new Rules\HttpHeader('customHeader');
27 | $env = new Rules\Environment('DEV');
28 | $envWithPreReq = new Rules\Environment('DEV');
29 | $envWithPreReq->addPrerequisite($header);
30 | $envWithPreReq->addPrerequisite($userId);
31 |
32 | $time_before = new Rules\TimeBefore('2013-12-12');
33 | $time_after = new Rules\TimeAfter('2012-12-12');
34 | $factory = new Rules\Factory();
35 |
36 | $this->assertEquals($random, $factory->createFromArray([
37 | 'type' => Rules\Random::class
38 | ]));
39 |
40 | $this->assertEquals($percent, $factory->createFromArray([
41 | 'type' => Rules\Percentage::class, 'value' => 1,
42 | ]));
43 |
44 | $this->assertEquals($stringHash, $factory->createFromArray([
45 | 'type' => Rules\StringHash::class, 'value' => 'some hash',
46 | ]));
47 |
48 | $this->assertEquals($userId, $factory->createFromArray([
49 | 'type' => Rules\UserId::class, 'value' => 123,
50 | ]));
51 |
52 | $this->assertEquals($ipAddress, $factory->createFromArray([
53 | 'type' => Rules\IpAddress::class, 'value' => '127.0.0.1',
54 | ]));
55 |
56 | $this->assertEquals($header, $factory->createFromArray([
57 | 'type' => 'HttpHeader', 'value' => 'customHeader',
58 | ]));
59 |
60 | $this->assertEquals($env, $factory->createFromArray([
61 | 'type' => Rules\TypeMapper::RULE_TYPE_ENVIRONMENT, 'value' => 'DEV',
62 | ]));
63 |
64 | $this->assertEquals($time_after, $factory->createFromArray([
65 | 'type' => Rules\TypeMapper::RULE_TYPE_AFTER, 'value' => '2012-12-12',
66 | ]));
67 |
68 | $this->assertEquals($time_before, $factory->createFromArray([
69 | 'type' => Rules\TypeMapper::RULE_TYPE_BEFORE, 'value' => '2013-12-12',
70 | ]));
71 |
72 | $this->assertEquals($envWithPreReq, $factory->createFromArray([
73 | 'type' => Rules\TypeMapper::RULE_TYPE_ENVIRONMENT,
74 | 'value' => 'DEV',
75 | 'prerequisites' => [
76 | ['type' => Rules\HttpHeader::class, 'value' => 'customHeader'],
77 | ['type' => 'UserId', 'value' => 123],
78 | ],
79 | ]));
80 | }
81 |
82 | /**
83 | * @dataProvider invalidRuleTypeProvider
84 | */
85 | public function testThrowsOnInvalidRuleType($invalidType): void
86 | {
87 | $this->expectException(\InvalidArgumentException::class);
88 | $this->expectExceptionMessage("{$invalidType} is not a valid rule type");
89 | $this->factory->createFromArray(['type' => $invalidType, 'value' => 123]);
90 | }
91 |
92 | public function invalidRuleTypeProvider(): array
93 | {
94 | return [
95 | 'existing non lib class' => [\stdClass::class],
96 | 'prefixed existing non lib class' => ['\\stdClass'],
97 | 'non existent class' => ['boop'],
98 | ];
99 | }
100 | }
101 |
--------------------------------------------------------------------------------
/tests/Unit/DoorKeeperTest.php:
--------------------------------------------------------------------------------
1 | featureSet = new Features\Set([$feature1, $feature2, $feature3, $feature4, $feature5]);
46 | $this->logger = new Log\Test\TestLogger();
47 | $this->doorkeeper = new Doorkeeper($this->featureSet, null, $this->logger);
48 | }
49 |
50 | public function testGetsAndSetsRequestor(): void
51 | {
52 | $this->assertNull($this->doorkeeper->getRequestor());
53 |
54 | $this->doorkeeper->setRequestor(new Requestor());
55 |
56 | $this->assertEquals(new Requestor(), $this->doorkeeper->getRequestor());
57 | }
58 |
59 | public function testProtectsAgainstResettingInstanceRequestor(): void
60 | {
61 | $this->doorkeeper->setRequestor(new Requestor());
62 | $this->expectException(\DomainException::class);
63 | $this->doorkeeper->setRequestor(new Requestor());
64 | }
65 |
66 | public function testGrantsAccessToFeatureWithoutRules(): void
67 | {
68 | $this->assertTrue($this->doorkeeper->grantsAccessTo('no rules'));
69 | $this->assertFalse($this->doorkeeper->grantsAccessTo('no'));
70 | }
71 |
72 | public function testRequestor()
73 | {
74 | $requestor = (new Requestor())
75 | ->withEnvironment('DEV')
76 | ->withUserId(9);
77 |
78 | $this->doorkeeper->setRequestor($requestor);
79 | $this->assertTrue($this->doorkeeper->grantsAccessTo('new shiny'));
80 | $this->assertFalse($this->doorkeeper->grantsAccessTo('disabled app'));
81 | $this->assertTrue($this->doorkeeper->grantsAccessTo('killer app'));
82 | $this->assertFalse($this->doorkeeper->grantsAccessTo('no'));
83 | $this->assertFalse($this->doorkeeper->grantsAccessTo('does not exist'));
84 | $this->assertTrue($this->doorkeeper->grantsAccessTo('new shiny'));
85 | }
86 |
87 | public function testFlushRuntimeCache(): void
88 | {
89 | $doorkeeper = new Doorkeeper($this->featureSet, null, $this->logger);
90 |
91 | $this->assertEquals($this->doorkeeper, $doorkeeper);
92 |
93 | $this->doorkeeper->grantsAccessTo('no');
94 |
95 | $this->assertNotEquals($this->doorkeeper, $doorkeeper);
96 |
97 | $this->doorkeeper->flushRuntimeCache();
98 |
99 | $this->assertEquals($this->doorkeeper, $doorkeeper);
100 | }
101 |
102 | public function testOperatesWithoutLogger(): void
103 | {
104 | $doorkeeper = new Doorkeeper($this->featureSet);
105 | $this->assertFalse($doorkeeper->grantsAccessTo('no'));
106 | }
107 | }
108 |
--------------------------------------------------------------------------------
/tests/Unit/Rules/TypeMapperTest.php:
--------------------------------------------------------------------------------
1 | $extraTypeClass]);
24 |
25 | $this->assertEquals(
26 | Rules\TypeMapper::RULE_TYPE_ENVIRONMENT,
27 | $mapper->getIdForClassName(Rules\Environment::class)
28 | );
29 |
30 | $this->assertEquals(
31 | Rules\TypeMapper::RULE_TYPE_USER_ID,
32 | $mapper->getIdForClassName(Rules\UserId::class)
33 | )
34 | ;
35 | $this->assertEquals(
36 | Rules\TypeMapper::RULE_TYPE_STRING_HASH,
37 | $mapper->getIdForClassName(Rules\StringHash::class)
38 | );
39 |
40 | $this->assertEquals(
41 | Rules\TypeMapper::RULE_TYPE_RANDOM,
42 | $mapper->getIdForClassName(Rules\Random::class)
43 | );
44 |
45 | $this->assertEquals(
46 | Rules\TypeMapper::RULE_TYPE_PERCENTAGE,
47 | $mapper->getIdForClassName(Rules\Percentage::class)
48 | );
49 |
50 | $this->assertEquals(
51 | Rules\TypeMapper::RULE_TYPE_IP_ADDRESS,
52 | $mapper->getIdForClassName(Rules\IpAddress::class)
53 | );
54 |
55 | $this->assertEquals(
56 | Rules\TypeMapper::RULE_TYPE_HEADER,
57 | $mapper->getIdForClassName(Rules\HttpHeader::class)
58 | );
59 |
60 | $this->assertEquals(
61 | 1001,
62 | $mapper->getIdForClassName($extraTypeClass)
63 | );
64 |
65 | $this->assertEquals(
66 | Rules\Environment::class,
67 | $mapper->getClassNameById(Rules\TypeMapper::RULE_TYPE_ENVIRONMENT)
68 | )
69 | ;
70 | $this->assertEquals(
71 | Rules\UserId::class,
72 | $mapper->getClassNameById(Rules\TypeMapper::RULE_TYPE_USER_ID)
73 | );
74 |
75 | $this->assertEquals(
76 | Rules\StringHash::class,
77 | $mapper->getClassNameById(Rules\TypeMapper::RULE_TYPE_STRING_HASH)
78 | );
79 |
80 | $this->assertEquals(
81 | Rules\Random::class,
82 | $mapper->getClassNameById(Rules\TypeMapper::RULE_TYPE_RANDOM)
83 | );
84 |
85 | $this->assertEquals(
86 | Rules\Percentage::class,
87 | $mapper->getClassNameById(Rules\TypeMapper::RULE_TYPE_PERCENTAGE)
88 | );
89 |
90 | $this->assertEquals(
91 | Rules\IpAddress::class,
92 | $mapper->getClassNameById(Rules\TypeMapper::RULE_TYPE_IP_ADDRESS)
93 | );
94 |
95 | $this->assertEquals(
96 | Rules\HttpHeader::class,
97 | $mapper->getClassNameById(Rules\TypeMapper::RULE_TYPE_HEADER)
98 | );
99 |
100 | $this->assertEquals(
101 | $extraTypeClass,
102 | $mapper->getClassNameById(1001)
103 | );
104 | }
105 |
106 | public function testPushesCustomIdClassValues(): void
107 | {
108 | $mapper = new Rules\TypeMapper();
109 |
110 | $mapper->pushExtraType(20002, \stdClass::class);
111 |
112 | $this->assertEquals(20002, $mapper->getIdForClassName(\stdClass::class));
113 | }
114 |
115 | public function testDoesNotAllowOverridesForIds(): void
116 | {
117 | $this->expectException(\DomainException::class);
118 | $mapper = new Rules\TypeMapper();
119 |
120 | $mapper->pushExtraType(1, \stdClass::class);
121 | }
122 |
123 | public function testDoesNotAllowForNonClassesToBePushed(): void
124 | {
125 | $this->expectException(\InvalidArgumentException::class);
126 | $mapper = new Rules\TypeMapper();
127 |
128 | $mapper->pushExtraType(10002, 'boopClass');
129 | }
130 | }
131 |
--------------------------------------------------------------------------------
/tests/Unit/RequestorTest.php:
--------------------------------------------------------------------------------
1 | createMock(Http\Message\RequestInterface::class);
33 | $request->method('getHeaderLine')
34 | ->with(Rules\HttpHeader::HEADER_KEY)
35 | ->willReturn($header);
36 |
37 | // phpcs:disable
38 | $identifications_stored = [
39 | Identification\UserId::class => new Identification\Collection(Identification\UserId::class, [$idIdentification]),
40 | Identification\StringHash::class => new Identification\Collection(Identification\StringHash::class, [$hashIdentification]),
41 | Identification\Environment::class => new Identification\Collection(Identification\Environment::class, [$envIdentification]),
42 | Identification\IpAddress::class => new Identification\Collection(Identification\IpAddress::class, [$ipIdentification]),
43 | Identification\HttpHeader::class => new Identification\Collection(Identification\HttpHeader::class, [$headerIdentification]),
44 | Identification\PipedComposite::class => new Identification\Collection(Identification\PipedComposite::class, [$pcIdentification]),
45 | Identification\IntegerId::class => new Identification\Collection(Identification\IntegerId::class, [$intIdentification])
46 | ];
47 | // phpcs:enabled
48 |
49 | $identificationsArgs = [
50 | $idIdentification,
51 | $hashIdentification,
52 | $envIdentification,
53 | $ipIdentification,
54 | $headerIdentification,
55 | $pcIdentification,
56 | $intIdentification
57 | ];
58 |
59 | $this->assertEquals(
60 | new Requestor($identificationsArgs),
61 | (new Requestor())
62 | ->withStringHash($hash)
63 | ->withUserId($userId)
64 | ->withEnvironment($env)
65 | ->withIpAddress($ip)
66 | ->withRequest($request)
67 | ->withPipedComposite($pipedComposite)
68 | ->withPipedComposite($pipedComposite) // duplicate protection
69 | ->withIntegerId($intId)
70 | );
71 |
72 | $this->assertEquals(
73 | (new Requestor($identificationsArgs))->getIdentificationCollections(),
74 | $identifications_stored
75 | );
76 | }
77 |
78 | public function testGetIdentityHash(): void
79 | {
80 | $id = new Identification\IntegerId(434);
81 | $collection = new Identification\Collection(Identification\IntegerId::class, [$id]);
82 | $expected_hash = md5(serialize([Identification\IntegerId::class => $collection]));
83 | $requestor = new Requestor([$id]);
84 |
85 | $this->assertEquals($expected_hash, $requestor->getIdentityHash());
86 | }
87 |
88 | public function testHasIdentification(): void
89 | {
90 | $identification1 = new Identification\PipedComposite('Bliz|2');
91 | $identification2 = new Identification\PipedComposite('Blaz|1');
92 | $requestor = new Requestor([$identification1, $identification2]);
93 |
94 | $this->assertTrue($requestor->hasIdentification($identification1));
95 | $this->assertTrue($requestor->hasIdentification($identification2));
96 | $this->assertFalse($requestor->hasIdentification(new Identification\HttpHeader('thing')));
97 | }
98 | }
99 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | [](https://packagist.org/packages/remotelyliving/doorkeeper)
2 | [](https://coveralls.io/github/remotelyliving/doorkeeper?branch=master)
3 | [](https://packagist.org/packages/remotelyliving/doorkeeper)
4 | [](https://scrutinizer-ci.com/g/remotelyliving/doorkeeper/?branch=master)
5 |
6 | # Doorkeeper: a dynamic feature toggle
7 |
8 | ### The Birth of a Feature Toggle
9 | >Picture the scene. You're on one of several teams working on a sophisticated town planning simulation game. Your team is responsible for the core simulation engine. You have been tasked with increasing the efficiency of the Spline Reticulation algorithm. You know this will require a fairly large overhaul of the implementation which will take several weeks. Meanwhile other members of your team will need to continue some ongoing work on related areas of the codebase.
10 | You want to avoid branching for this work if at all possible, based on previous painful experiences of merging long-lived branches in the past. Instead, you decide that the entire team will continue to work on trunk, but the developers working on the Spline Reticulation improvements will use a Feature Toggle to prevent their work from impacting the rest of the team or destabilizing the codebase.
11 |
12 | https://martinfowler.com/articles/feature-toggles.html
13 |
14 | ### Enter Doorkeeper (if you can)
15 |
16 | There are a few feature toggle frameworks and libraries out there already. And many of them are fine.
17 | Doorkeeper was born our of a previous experience with one and wishing what it could be.
18 |
19 | ### Dynamic Usage
20 |
21 | Doorkeeper is storage agnostic, and has a few helpers to help translate what you decide to persist
22 | and how you want to load it. But however you choose to setup its Feature Set (features + rules), you can toggle your feature on and off
23 | by changing that configuration.
24 |
25 | ### Installation
26 |
27 | `composer require remotelyliving/doorkeeper`
28 |
29 | ### Wiring it up
30 |
31 | ```php
32 | // Requestors are the actor requesting access to a new feature
33 | // They have several forms of identity you can initialize them
34 | $requestor = ( new Requestor() )
35 | ->withUserId($user->getId())
36 | ->withRequest($request)
37 | ->withIpAddress('127.0.0.1')
38 | ->withEnvironment('STAGE')
39 | ->withStringHash('someArbitraryThingMaybeFromTheQueryString');
40 |
41 | // A Feature Set has Features that have Rules
42 | $featureSet = $reatureSetRepository->getSet();
43 |
44 | // Doorkeper takes in a Feature Set and an audit log if you want to log access results
45 | $doorkeeper = new Doorkeeper($featureSet, $logger);
46 |
47 | // Set an app instance bound requestor here or pass one to Doorkeeper::grantsAccessToRequestor('feature', $requestor) later
48 | $doorkeeper->setRequestor($requestor);
49 | ```
50 |
51 | ### Usage
52 |
53 | ```php
54 | if ($doorkeeper->grantsAccessTo('some.new.feature')) {
55 | return $this->doNewFeatureStuff();
56 | }
57 |
58 | // If you want to bypass the instance Requestor that was set and create another use Doorkeeper::grantsAccessToRequestor()
59 | // This is useful for more stateful applications
60 |
61 | $otherRequestor = (new Requestor()))->withUserId(123);
62 |
63 | if ($doorkeeper->grantsAccessToRequestor('some.new.feature', $otherRequestor)) {
64 | return $this->doNewFeatureStuff();
65 | }
66 | ```
67 |
68 | ***Setting a Requestor is not neccessary. It is only needed if you want to use rules that are evaluated
69 | against a specific requestor***
70 |
71 | ### Requestor
72 |
73 | A Requestor is the one asking to access the feature. They must pass the Doorkeeper's strict house rules to enter.
74 | To see if a requestor is allowed access, they must present Identifications.
75 |
76 | `RemotelyLiving\Doorkeeper\Identifications`
77 |
78 | - HttpHeader - based on a `doorkeeper` header present in a request.
79 | - IntegerId (user id) - Based on the logged in user id
80 | - IpAddress - based on the Requestor's ip address
81 | - StringHash - Some flexibility here. Set it to whatever you want.
82 | - Environment - Based on the app environment the Requestor is in.
83 | - PipedComposite = A string of pipe delimited values used to make a composite key id
84 |
85 | The Requestor is immutable. It should not be changed anywhere in the call stack.
86 | That would produce less than consistent results depending on where the query takes place.
87 |
88 | The Requestor is best wired up and set in a service container. There are several convenience methods
89 | to set identities.
90 |
91 | ### Rules
92 |
93 | `RemotelyLiving\Doorkeeper\Rules`
94 |
95 | There are several types of Rules to use when defining access to a feature
96 |
97 | - Environment: can be set with the environment name that the feature is available in
98 |
99 | - HttpHeader: matches a specific value from a custom `doorkeeper` header you can choose to send.
100 | *The request identification on a Requestor must be registered to have a positive match
101 |
102 | - IpAddress: this is a specific IP address the feature is available to. Helpful for in-office access.
103 | *It only works if the Requestor has an IpAddress identification registered
104 |
105 | - Percentage: a percentage of requests to allow through to the feature
106 |
107 | - Random: chaos monkey. This rule randomly allows access.
108 |
109 | - StringHash: this is an arbitrary string. It works well for request query params, username, etc.
110 | *This only works if a StringHash identification is set on the Requestor
111 |
112 | - TimeAfter: allows for access to a feature only *after* the set time on the rule.
113 |
114 | - TimeBefore: allows for access to a feature only *before* the set time of the rule.
115 |
116 | - UserId: this rule allows for specific user access to a feature.
117 | *User Id only works if the Request has a user id identification registered to them
118 |
119 | - PipedComposite: allows for a pipe delimited composite key value
120 |
121 | - RuntimeCallable: pass in a callable that returns true/false. You'll have access to the requestor as a parameter, but be forewarned that storing this one is not possible (i.e. to a database)
122 |
123 | ### Prerequisistes
124 |
125 | Rules can be dependant on other rules for any other feature.
126 |
127 | ```php
128 | // create a time range
129 | $timeBeforeRule->addPrerequisite($timeAfterRule);
130 |
131 | // create a user id rule only for prod
132 | $userIdRule->addPrerequisite($prodEnvironmentRule);
133 |
134 | // add another prereq
135 | $userIdRule->addPrerequisite($ipAddressRule);
136 |
137 | // etc.
138 | ```
139 |
140 | That prerequisite must be satisfied before the other rule is evaluated.
141 |
142 | ### Feature
143 |
144 | `RemotelyLiving\Doorkeeper\Features`
145 |
146 | A Feature is what a Requestor is asking for by name. It can have 0-n Rules around it.
147 | A Feature has a top level on/off switch called `enabled` that can bypass any rules.
148 |
149 | ```php
150 | // $enabled (true/false), $rules (\RemotelyLiving\Doorkeeper\Rules\RuleInterface[])
151 | $feature = new Feature('some.new.feature', $enabled, $rules);
152 | ```
153 |
154 | Doorkeeper gets the rules from a feature and evaluates them. If they require a specific Identification
155 | Doorkeeper looks into the Requestor to see if they have the right Identifications required by a rule
156 |
157 | If nothing satisfies the feature rules the default is to deny access.
158 |
159 | ***The first rule to be satisfied grants access***
160 |
161 | Keep that in mind when setting rules up on a feature.
162 |
163 | If you say "only this ip address is allowed, but also the DEV environment."
164 |
165 | All requests with that ip address OR in the DEV environment will be allowed.
166 |
167 | But that means that in ANY environment, that ip address will be allowed.
168 |
169 | The proper way to setup exclusions would be to set the ip address rule as a prerequisite rule to the environment one.
170 | That would then stipulate that only this ip address in this environment can access.
171 |
172 | A Feature with no rules simply relies on the `enabled` field to tell Doorkeeper if it's on or off
173 |
174 | ### Feature Set
175 |
176 | A feature set object is the bread and butter of Doorkeeper. It's a collection of Features and rules and makes for easy
177 | caching. It is the complete set of what defines access to features.
178 |
179 | ### Feature Set Repository and Caching
180 |
181 | `Remotelyliving\Doorkeeper\Features`
182 |
183 | Obviously something that fires up for every request that uses dynamic config data can be costly.
184 |
185 | If you're using memcached or redis and a PSR6 cache library. you can cache and retrieve the Feature Set in the `Features\SetRepository`.
186 |
187 | You can build the Feature Set from arrays using the `Features\Set::createFromArray()`
188 |
189 | That array can come from anywhere: relational database, cache, config, xml, whatever.
190 |
191 | Checkout that factory method to see the schema of the array that needs to be passed in.
192 |
193 | How you choose to persist Features is up to you. But there are two things you're responsible for if using the `Features\SetRepository`
194 |
195 | 1. Clearing the cache when any member of a Feature Set is changed via `SetRepository::deleteFeatureSet()`
196 | 2. Providing a service that can provide a hydrated Feature Set to the `get('some.new.feature', $featureSetProvider)` method
197 |
198 | Doorkeeper also has a runtime cache that caches answers in memory to help as well.
199 | For persistent applications you'll need to call `Doorkeeper::flushRuntimeCache()`
200 | any time a Rule or Feature is updated.
201 |
202 | ### Logging
203 |
204 | Doorkeeper comes with a friendly log processor that can pass on filtered or unfiltered info about a Requestor.
205 | This is incredibly helpful when debugging.
206 | When paired with a Request-Id (or something like that) in the log context debugging a user's code patch can be very easy.
207 |
208 |
209 |
--------------------------------------------------------------------------------