diff --git a/src/FreeDSx/Ldap/Server/Backend/Storage/Adapter/InMemoryStorage.php b/src/FreeDSx/Ldap/Server/Backend/Storage/Adapter/InMemoryStorage.php index a598aaf7..1b3800e9 100644 --- a/src/FreeDSx/Ldap/Server/Backend/Storage/Adapter/InMemoryStorage.php +++ b/src/FreeDSx/Ldap/Server/Backend/Storage/Adapter/InMemoryStorage.php @@ -18,10 +18,10 @@ use FreeDSx\Ldap\Server\Backend\Storage\Adapter\Support\ArrayEntryStorageTrait; use FreeDSx\Ldap\Server\Backend\Storage\EntryStream; use FreeDSx\Ldap\Server\Backend\Storage\EntryStorageInterface; +use FreeDSx\Ldap\Server\Backend\Storage\Journal\Capture\ChangeJournalingInterface; +use FreeDSx\Ldap\Server\Backend\Storage\Journal\Change\PendingChange; use FreeDSx\Ldap\Server\Backend\Storage\Journal\ChangeJournalInterface; -use FreeDSx\Ldap\Server\Backend\Storage\Journal\ChangeJournalingInterface; use FreeDSx\Ldap\Server\Backend\Storage\Journal\InMemoryChangeJournal; -use FreeDSx\Ldap\Server\Backend\Storage\Journal\PendingChange; use FreeDSx\Ldap\Server\Backend\Storage\StorageListOptions; /** diff --git a/src/FreeDSx/Ldap/Server/Backend/Storage/Journal/ChangeJournalingInterface.php b/src/FreeDSx/Ldap/Server/Backend/Storage/Journal/Capture/ChangeJournalingInterface.php similarity index 81% rename from src/FreeDSx/Ldap/Server/Backend/Storage/Journal/ChangeJournalingInterface.php rename to src/FreeDSx/Ldap/Server/Backend/Storage/Journal/Capture/ChangeJournalingInterface.php index 9f2863de..680afb59 100644 --- a/src/FreeDSx/Ldap/Server/Backend/Storage/Journal/ChangeJournalingInterface.php +++ b/src/FreeDSx/Ldap/Server/Backend/Storage/Journal/Capture/ChangeJournalingInterface.php @@ -11,7 +11,9 @@ * file that was distributed with this source code. */ -namespace FreeDSx\Ldap\Server\Backend\Storage\Journal; +namespace FreeDSx\Ldap\Server\Backend\Storage\Journal\Capture; + +use FreeDSx\Ldap\Server\Backend\Storage\Journal\Change\PendingChange; /** * Append a change within the active write boundary. diff --git a/src/FreeDSx/Ldap/Server/Backend/Storage/Journal/ChangeRecorder.php b/src/FreeDSx/Ldap/Server/Backend/Storage/Journal/Capture/ChangeRecorder.php similarity index 94% rename from src/FreeDSx/Ldap/Server/Backend/Storage/Journal/ChangeRecorder.php rename to src/FreeDSx/Ldap/Server/Backend/Storage/Journal/Capture/ChangeRecorder.php index f9370a99..0004ebe4 100644 --- a/src/FreeDSx/Ldap/Server/Backend/Storage/Journal/ChangeRecorder.php +++ b/src/FreeDSx/Ldap/Server/Backend/Storage/Journal/Capture/ChangeRecorder.php @@ -11,12 +11,14 @@ * file that was distributed with this source code. */ -namespace FreeDSx\Ldap\Server\Backend\Storage\Journal; +namespace FreeDSx\Ldap\Server\Backend\Storage\Journal\Capture; use FreeDSx\Ldap\Entry\Dn; use FreeDSx\Ldap\Entry\Entry; use FreeDSx\Ldap\Schema\Definition\AttributeTypeOid; use FreeDSx\Ldap\Server\Backend\Storage\EntryStorageInterface; +use FreeDSx\Ldap\Server\Backend\Storage\Journal\Change\ChangeType; +use FreeDSx\Ldap\Server\Backend\Storage\Journal\Change\PendingChange; use FreeDSx\Ldap\Server\Backend\Write\WriteContext; use Psr\Log\LoggerInterface; use Psr\Log\NullLogger; diff --git a/src/FreeDSx/Ldap/Server/Backend/Storage/Journal/ChangeRecord.php b/src/FreeDSx/Ldap/Server/Backend/Storage/Journal/Change/ChangeRecord.php similarity index 83% rename from src/FreeDSx/Ldap/Server/Backend/Storage/Journal/ChangeRecord.php rename to src/FreeDSx/Ldap/Server/Backend/Storage/Journal/Change/ChangeRecord.php index 233391e0..a1fb35d5 100644 --- a/src/FreeDSx/Ldap/Server/Backend/Storage/Journal/ChangeRecord.php +++ b/src/FreeDSx/Ldap/Server/Backend/Storage/Journal/Change/ChangeRecord.php @@ -11,9 +11,10 @@ * file that was distributed with this source code. */ -namespace FreeDSx\Ldap\Server\Backend\Storage\Journal; +namespace FreeDSx\Ldap\Server\Backend\Storage\Journal\Change; use DateTimeImmutable; +use FreeDSx\Ldap\Server\Backend\Storage\Journal\ReplicaId; /** * A PendingChange stamped by the journal. diff --git a/src/FreeDSx/Ldap/Server/Backend/Storage/Journal/ChangeType.php b/src/FreeDSx/Ldap/Server/Backend/Storage/Journal/Change/ChangeType.php similarity index 89% rename from src/FreeDSx/Ldap/Server/Backend/Storage/Journal/ChangeType.php rename to src/FreeDSx/Ldap/Server/Backend/Storage/Journal/Change/ChangeType.php index d592a904..385a2351 100644 --- a/src/FreeDSx/Ldap/Server/Backend/Storage/Journal/ChangeType.php +++ b/src/FreeDSx/Ldap/Server/Backend/Storage/Journal/Change/ChangeType.php @@ -11,7 +11,7 @@ * file that was distributed with this source code. */ -namespace FreeDSx\Ldap\Server\Backend\Storage\Journal; +namespace FreeDSx\Ldap\Server\Backend\Storage\Journal\Change; /** * The kind of write a change-journal record captures. diff --git a/src/FreeDSx/Ldap/Server/Backend/Storage/Journal/PendingChange.php b/src/FreeDSx/Ldap/Server/Backend/Storage/Journal/Change/PendingChange.php similarity index 93% rename from src/FreeDSx/Ldap/Server/Backend/Storage/Journal/PendingChange.php rename to src/FreeDSx/Ldap/Server/Backend/Storage/Journal/Change/PendingChange.php index da513570..30bb920a 100644 --- a/src/FreeDSx/Ldap/Server/Backend/Storage/Journal/PendingChange.php +++ b/src/FreeDSx/Ldap/Server/Backend/Storage/Journal/Change/PendingChange.php @@ -11,7 +11,7 @@ * file that was distributed with this source code. */ -namespace FreeDSx\Ldap\Server\Backend\Storage\Journal; +namespace FreeDSx\Ldap\Server\Backend\Storage\Journal\Change; use FreeDSx\Ldap\Entry\Dn; use FreeDSx\Ldap\Entry\Entry; diff --git a/src/FreeDSx/Ldap/Server/Backend/Storage/Journal/ChangeJournalInterface.php b/src/FreeDSx/Ldap/Server/Backend/Storage/Journal/ChangeJournalInterface.php index a31701c9..18af579a 100644 --- a/src/FreeDSx/Ldap/Server/Backend/Storage/Journal/ChangeJournalInterface.php +++ b/src/FreeDSx/Ldap/Server/Backend/Storage/Journal/ChangeJournalInterface.php @@ -13,6 +13,9 @@ namespace FreeDSx\Ldap\Server\Backend\Storage\Journal; +use FreeDSx\Ldap\Server\Backend\Storage\Journal\Change\ChangeRecord; +use FreeDSx\Ldap\Server\Backend\Storage\Journal\Change\PendingChange; + /** * Append-only log of committed writes. * @@ -40,4 +43,11 @@ public function read(int $afterSeq = 0): iterable; * @api */ public function latestSeq(): int; + + /** + * Drop records that fall outside the policy; returns how many were removed. seq keeps climbing. + * + * @api + */ + public function prune(RetentionPolicy $policy): int; } diff --git a/src/FreeDSx/Ldap/Server/Backend/Storage/Journal/InMemoryChangeJournal.php b/src/FreeDSx/Ldap/Server/Backend/Storage/Journal/InMemoryChangeJournal.php index 9096e3fa..5f74989d 100644 --- a/src/FreeDSx/Ldap/Server/Backend/Storage/Journal/InMemoryChangeJournal.php +++ b/src/FreeDSx/Ldap/Server/Backend/Storage/Journal/InMemoryChangeJournal.php @@ -13,6 +13,8 @@ namespace FreeDSx\Ldap\Server\Backend\Storage\Journal; +use FreeDSx\Ldap\Server\Backend\Storage\Journal\Change\ChangeRecord; +use FreeDSx\Ldap\Server\Backend\Storage\Journal\Change\PendingChange; use FreeDSx\Ldap\Server\Clock\ClockInterface; use FreeDSx\Ldap\Server\Clock\SystemClock; @@ -61,4 +63,29 @@ public function latestSeq(): int { return $this->seq; } + + public function prune(RetentionPolicy $policy): int + { + $before = count($this->records); + $records = $this->records; + + if ($policy->maxRecords !== null && count($records) > $policy->maxRecords) { + $records = array_slice( + $records, + count($records) - $policy->maxRecords, + ); + } + + if ($policy->maxAgeSeconds !== null) { + $oldest = $this->clock->now()->getTimestamp() - $policy->maxAgeSeconds; + $records = array_filter( + $records, + static fn(ChangeRecord $record): bool => $record->createdAt->getTimestamp() >= $oldest, + ); + } + + $this->records = array_values($records); + + return $before - count($this->records); + } } diff --git a/src/FreeDSx/Ldap/Server/Backend/Storage/Journal/Read/ChangeScope.php b/src/FreeDSx/Ldap/Server/Backend/Storage/Journal/Read/ChangeScope.php new file mode 100644 index 00000000..dc214b22 --- /dev/null +++ b/src/FreeDSx/Ldap/Server/Backend/Storage/Journal/Read/ChangeScope.php @@ -0,0 +1,62 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace FreeDSx\Ldap\Server\Backend\Storage\Journal\Read; + +use FreeDSx\Ldap\Entry\Dn; + +/** + * A base DN plus extent that decides whether a change falls within a consumer's view. + * + * @author Chad Sikorra + */ +final readonly class ChangeScope +{ + private function __construct( + private Dn $baseDn, + private ScopeType $type, + ) {} + + public static function baseObject(Dn $baseDn): self + { + return new self( + $baseDn, + ScopeType::BaseObject, + ); + } + + public static function oneLevel(Dn $baseDn): self + { + return new self( + $baseDn, + ScopeType::OneLevel, + ); + } + + public static function wholeSubtree(Dn $baseDn): self + { + return new self( + $baseDn, + ScopeType::WholeSubtree, + ); + } + + public function contains(Dn $dn): bool + { + return match ($this->type) { + ScopeType::BaseObject => $dn->normalize()->toString() === $this->baseDn->normalize()->toString(), + ScopeType::OneLevel => $dn->isChildOf($this->baseDn), + ScopeType::WholeSubtree => $dn->isDescendantOf($this->baseDn), + }; + } +} diff --git a/src/FreeDSx/Ldap/Server/Backend/Storage/Journal/Read/ChangeStream.php b/src/FreeDSx/Ldap/Server/Backend/Storage/Journal/Read/ChangeStream.php new file mode 100644 index 00000000..5c8be05b --- /dev/null +++ b/src/FreeDSx/Ldap/Server/Backend/Storage/Journal/Read/ChangeStream.php @@ -0,0 +1,57 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace FreeDSx\Ldap\Server\Backend\Storage\Journal\Read; + +use FreeDSx\Ldap\Server\Backend\Storage\Journal\Change\ChangeRecord; +use FreeDSx\Ldap\Server\Backend\Storage\Journal\ChangeJournalInterface; + +/** + * Read-only view over the journal: the seam the audit sink and RFC 4533 provider consume. + * + * @author Chad Sikorra + */ +final readonly class ChangeStream +{ + public function __construct( + private ChangeJournalInterface $journal, + ) {} + + /** + * Records with seq greater than $afterSeq, optionally narrowed to a scope, in ascending seq order. + * + * @api + * + * @return iterable + */ + public function since( + int $afterSeq = 0, + ?ChangeScope $scope = null, + ): iterable { + foreach ($this->journal->read($afterSeq) as $record) { + if ($scope === null || $scope->contains($record->change->dn)) { + yield $record; + } + } + } + + /** + * The highest seq currently in the journal; the high-water mark a consumer cookie advances to. + * + * @api + */ + public function latestSeq(): int + { + return $this->journal->latestSeq(); + } +} diff --git a/src/FreeDSx/Ldap/Server/Backend/Storage/Journal/Read/ScopeType.php b/src/FreeDSx/Ldap/Server/Backend/Storage/Journal/Read/ScopeType.php new file mode 100644 index 00000000..ab1f67ad --- /dev/null +++ b/src/FreeDSx/Ldap/Server/Backend/Storage/Journal/Read/ScopeType.php @@ -0,0 +1,26 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace FreeDSx\Ldap\Server\Backend\Storage\Journal\Read; + +/** + * The DIT extent a change stream covers. + * + * @author Chad Sikorra + */ +enum ScopeType +{ + case BaseObject; + case OneLevel; + case WholeSubtree; +} diff --git a/src/FreeDSx/Ldap/Server/Backend/Storage/Journal/RetentionPolicy.php b/src/FreeDSx/Ldap/Server/Backend/Storage/Journal/RetentionPolicy.php new file mode 100644 index 00000000..fabf4695 --- /dev/null +++ b/src/FreeDSx/Ldap/Server/Backend/Storage/Journal/RetentionPolicy.php @@ -0,0 +1,41 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace FreeDSx\Ldap\Server\Backend\Storage\Journal; + +use FreeDSx\Ldap\Exception\InvalidArgumentException; + +/** + * Bounds journal growth: a record is purge-eligible once it fails either limit (whichever is tighter). + * + * @author Chad Sikorra + */ +final readonly class RetentionPolicy +{ + /** + * @param ?int $maxRecords hard ceiling on retained records, or null for no count limit + * @param ?int $maxAgeSeconds age horizon in seconds, or null for no time limit + */ + public function __construct( + public ?int $maxRecords = null, + public ?int $maxAgeSeconds = null, + ) { + if ($maxRecords !== null && $maxRecords < 1) { + throw new InvalidArgumentException('maxRecords must be at least 1 when set.'); + } + + if ($maxAgeSeconds !== null && $maxAgeSeconds < 1) { + throw new InvalidArgumentException('maxAgeSeconds must be at least 1 when set.'); + } + } +} diff --git a/src/FreeDSx/Ldap/Server/Backend/Storage/WritableStorageBackend.php b/src/FreeDSx/Ldap/Server/Backend/Storage/WritableStorageBackend.php index 106beec5..9c0b5811 100644 --- a/src/FreeDSx/Ldap/Server/Backend/Storage/WritableStorageBackend.php +++ b/src/FreeDSx/Ldap/Server/Backend/Storage/WritableStorageBackend.php @@ -24,7 +24,7 @@ use FreeDSx\Ldap\Operation\Request\SearchRequest; use FreeDSx\Ldap\Operation\ResultCode; use FreeDSx\Ldap\Server\Backend\Storage\Adapter\Operation\WriteEntryOperationHandler; -use FreeDSx\Ldap\Server\Backend\Storage\Journal\ChangeRecorder; +use FreeDSx\Ldap\Server\Backend\Storage\Journal\Capture\ChangeRecorder; use FreeDSx\Ldap\Server\Backend\Write\Command\AddCommand; use FreeDSx\Ldap\Server\Backend\Write\Command\DeleteCommand; use FreeDSx\Ldap\Server\Backend\Write\Command\MoveCommand; diff --git a/tests/unit/Server/Backend/Storage/Adapter/WritableStorageBackendTest.php b/tests/unit/Server/Backend/Storage/Adapter/WritableStorageBackendTest.php index 6d0fa925..13f68fe0 100644 --- a/tests/unit/Server/Backend/Storage/Adapter/WritableStorageBackendTest.php +++ b/tests/unit/Server/Backend/Storage/Adapter/WritableStorageBackendTest.php @@ -29,11 +29,11 @@ use FreeDSx\Ldap\Server\Backend\Storage\Adapter\InMemoryStorage; use FreeDSx\Ldap\Server\Backend\Storage\EntryStorageInterface; use FreeDSx\Ldap\Server\Backend\Storage\EntryStream; -use FreeDSx\Ldap\Server\Backend\Storage\Journal\ChangeRecord; -use FreeDSx\Ldap\Server\Backend\Storage\Journal\ChangeRecorder; -use FreeDSx\Ldap\Server\Backend\Storage\Journal\ChangeType; +use FreeDSx\Ldap\Server\Backend\Storage\Journal\Capture\ChangeRecorder; +use FreeDSx\Ldap\Server\Backend\Storage\Journal\Change\ChangeRecord; +use FreeDSx\Ldap\Server\Backend\Storage\Journal\Change\ChangeType; +use FreeDSx\Ldap\Server\Backend\Storage\Journal\Change\PendingChange; use FreeDSx\Ldap\Server\Backend\Storage\Journal\InMemoryChangeJournal; -use FreeDSx\Ldap\Server\Backend\Storage\Journal\PendingChange; use FreeDSx\Ldap\Server\Backend\Storage\Exception\InvalidAttributeException; use FreeDSx\Ldap\Server\Backend\Storage\Exception\StorageIoException; use FreeDSx\Ldap\Server\Backend\Storage\Exception\TimeLimitExceededException; diff --git a/tests/unit/Server/Backend/Storage/Journal/ChangeRecorderTest.php b/tests/unit/Server/Backend/Storage/Journal/Capture/ChangeRecorderTest.php similarity index 95% rename from tests/unit/Server/Backend/Storage/Journal/ChangeRecorderTest.php rename to tests/unit/Server/Backend/Storage/Journal/Capture/ChangeRecorderTest.php index 754efde2..95a1ba5c 100644 --- a/tests/unit/Server/Backend/Storage/Journal/ChangeRecorderTest.php +++ b/tests/unit/Server/Backend/Storage/Journal/Capture/ChangeRecorderTest.php @@ -11,7 +11,7 @@ * file that was distributed with this source code. */ -namespace Tests\Unit\FreeDSx\Ldap\Server\Backend\Storage\Journal; +namespace Tests\Unit\FreeDSx\Ldap\Server\Backend\Storage\Journal\Capture; use FreeDSx\Ldap\Control\ControlBag; use FreeDSx\Ldap\Entry\Attribute; @@ -20,9 +20,9 @@ use FreeDSx\Ldap\Schema\Definition\AttributeTypeOid; use FreeDSx\Ldap\Server\Backend\Storage\Adapter\InMemoryStorage; use FreeDSx\Ldap\Server\Backend\Storage\EntryStorageInterface; -use FreeDSx\Ldap\Server\Backend\Storage\Journal\ChangeRecord; -use FreeDSx\Ldap\Server\Backend\Storage\Journal\ChangeRecorder; -use FreeDSx\Ldap\Server\Backend\Storage\Journal\ChangeType; +use FreeDSx\Ldap\Server\Backend\Storage\Journal\Capture\ChangeRecorder; +use FreeDSx\Ldap\Server\Backend\Storage\Journal\Change\ChangeRecord; +use FreeDSx\Ldap\Server\Backend\Storage\Journal\Change\ChangeType; use FreeDSx\Ldap\Server\Backend\Storage\Journal\InMemoryChangeJournal; use FreeDSx\Ldap\Server\Backend\Write\WriteContext; use FreeDSx\Ldap\Server\Token\BindToken; diff --git a/tests/unit/Server/Backend/Storage/Journal/InMemoryChangeJournalTest.php b/tests/unit/Server/Backend/Storage/Journal/InMemoryChangeJournalTest.php index ad924f36..e35ba0b7 100644 --- a/tests/unit/Server/Backend/Storage/Journal/InMemoryChangeJournalTest.php +++ b/tests/unit/Server/Backend/Storage/Journal/InMemoryChangeJournalTest.php @@ -15,11 +15,12 @@ use FreeDSx\Ldap\Entry\Dn; use FreeDSx\Ldap\Protocol\Authorization\AuthzId; -use FreeDSx\Ldap\Server\Backend\Storage\Journal\ChangeRecord; -use FreeDSx\Ldap\Server\Backend\Storage\Journal\ChangeType; +use FreeDSx\Ldap\Server\Backend\Storage\Journal\Change\ChangeRecord; +use FreeDSx\Ldap\Server\Backend\Storage\Journal\Change\ChangeType; +use FreeDSx\Ldap\Server\Backend\Storage\Journal\Change\PendingChange; use FreeDSx\Ldap\Server\Backend\Storage\Journal\InMemoryChangeJournal; -use FreeDSx\Ldap\Server\Backend\Storage\Journal\PendingChange; use FreeDSx\Ldap\Server\Backend\Storage\Journal\ReplicaId; +use FreeDSx\Ldap\Server\Backend\Storage\Journal\RetentionPolicy; use PHPUnit\Framework\TestCase; use Tests\Support\FreeDSx\Ldap\Clock\FrozenClock; @@ -118,6 +119,83 @@ public function test_read_without_an_argument_returns_everything(): void ); } + public function test_an_unbounded_policy_prunes_nothing(): void + { + $this->subject->append($this->change('cn=a,dc=example,dc=com')); + $this->subject->append($this->change('cn=b,dc=example,dc=com')); + + self::assertSame( + 0, + $this->subject->prune(new RetentionPolicy()), + ); + self::assertCount( + 2, + iterator_to_array($this->subject->read()), + ); + } + + public function test_the_record_cap_keeps_only_the_newest_records(): void + { + for ($i = 0; $i < 5; $i++) { + $this->subject->append($this->change("cn={$i},dc=example,dc=com")); + } + + $removed = $this->subject->prune(new RetentionPolicy(maxRecords: 2)); + + $seqs = array_map( + static fn(ChangeRecord $record): int => $record->seq, + iterator_to_array($this->subject->read()), + ); + self::assertSame( + 3, + $removed, + ); + self::assertSame( + [4, 5], + $seqs, + ); + } + + public function test_the_age_window_drops_records_older_than_the_horizon(): void + { + $this->subject->append($this->change('cn=old,dc=example,dc=com')); + $this->clock->setTo($this->clock->now()->modify('+10 seconds')); + $this->subject->append($this->change('cn=new,dc=example,dc=com')); + + $removed = $this->subject->prune(new RetentionPolicy(maxAgeSeconds: 5)); + + $dns = array_map( + static fn(ChangeRecord $record): string => $record->change->dn->toString(), + iterator_to_array($this->subject->read()), + ); + self::assertSame( + 1, + $removed, + ); + self::assertSame( + ['cn=new,dc=example,dc=com'], + $dns, + ); + } + + public function test_pruning_leaves_the_seq_counter_climbing(): void + { + $this->subject->append($this->change('cn=a,dc=example,dc=com')); + $this->subject->append($this->change('cn=b,dc=example,dc=com')); + $this->subject->append($this->change('cn=c,dc=example,dc=com')); + + $this->subject->prune(new RetentionPolicy(maxRecords: 1)); + + self::assertSame( + 3, + $this->subject->latestSeq(), + ); + self::assertSame( + 4, + $this->subject->append($this->change('cn=d,dc=example,dc=com'))->seq, + ); + } + private function change(string $dn): PendingChange { return new PendingChange( diff --git a/tests/unit/Server/Backend/Storage/Journal/Read/ChangeScopeTest.php b/tests/unit/Server/Backend/Storage/Journal/Read/ChangeScopeTest.php new file mode 100644 index 00000000..1b0256de --- /dev/null +++ b/tests/unit/Server/Backend/Storage/Journal/Read/ChangeScopeTest.php @@ -0,0 +1,62 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Tests\Unit\FreeDSx\Ldap\Server\Backend\Storage\Journal\Read; + +use FreeDSx\Ldap\Entry\Dn; +use FreeDSx\Ldap\Server\Backend\Storage\Journal\Read\ChangeScope; +use PHPUnit\Framework\TestCase; + +final class ChangeScopeTest extends TestCase +{ + private Dn $base; + + protected function setUp(): void + { + $this->base = new Dn('dc=example,dc=com'); + } + + public function test_whole_subtree_contains_the_base_and_all_descendants(): void + { + $scope = ChangeScope::wholeSubtree($this->base); + + self::assertTrue($scope->contains(new Dn('dc=example,dc=com'))); + self::assertTrue($scope->contains(new Dn('cn=a,dc=example,dc=com'))); + self::assertTrue($scope->contains(new Dn('cn=x,ou=people,dc=example,dc=com'))); + self::assertFalse($scope->contains(new Dn('dc=other,dc=com'))); + } + + public function test_one_level_contains_only_direct_children(): void + { + $scope = ChangeScope::oneLevel($this->base); + + self::assertTrue($scope->contains(new Dn('cn=a,dc=example,dc=com'))); + self::assertFalse($scope->contains(new Dn('dc=example,dc=com'))); + self::assertFalse($scope->contains(new Dn('cn=x,ou=people,dc=example,dc=com'))); + } + + public function test_base_object_contains_only_the_base(): void + { + $scope = ChangeScope::baseObject($this->base); + + self::assertTrue($scope->contains(new Dn('dc=example,dc=com'))); + self::assertFalse($scope->contains(new Dn('cn=a,dc=example,dc=com'))); + } + + public function test_base_object_match_is_case_insensitive(): void + { + $scope = ChangeScope::baseObject($this->base); + + self::assertTrue($scope->contains(new Dn('DC=Example,DC=Com'))); + } +} diff --git a/tests/unit/Server/Backend/Storage/Journal/Read/ChangeStreamTest.php b/tests/unit/Server/Backend/Storage/Journal/Read/ChangeStreamTest.php new file mode 100644 index 00000000..e9a77017 --- /dev/null +++ b/tests/unit/Server/Backend/Storage/Journal/Read/ChangeStreamTest.php @@ -0,0 +1,111 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Tests\Unit\FreeDSx\Ldap\Server\Backend\Storage\Journal\Read; + +use FreeDSx\Ldap\Entry\Dn; +use FreeDSx\Ldap\Protocol\Authorization\AuthzId; +use FreeDSx\Ldap\Server\Backend\Storage\Journal\Change\ChangeRecord; +use FreeDSx\Ldap\Server\Backend\Storage\Journal\Change\ChangeType; +use FreeDSx\Ldap\Server\Backend\Storage\Journal\Change\PendingChange; +use FreeDSx\Ldap\Server\Backend\Storage\Journal\InMemoryChangeJournal; +use FreeDSx\Ldap\Server\Backend\Storage\Journal\Read\ChangeScope; +use FreeDSx\Ldap\Server\Backend\Storage\Journal\Read\ChangeStream; +use PHPUnit\Framework\TestCase; + +final class ChangeStreamTest extends TestCase +{ + private InMemoryChangeJournal $journal; + + private ChangeStream $subject; + + protected function setUp(): void + { + $this->journal = new InMemoryChangeJournal(); + $this->subject = new ChangeStream($this->journal); + } + + public function test_since_returns_only_records_after_the_given_seq(): void + { + $this->append('cn=a,dc=example,dc=com'); + $this->append('cn=b,dc=example,dc=com'); + $this->append('cn=c,dc=example,dc=com'); + + $seqs = array_map( + static fn(ChangeRecord $record): int => $record->seq, + iterator_to_array($this->subject->since(1)), + ); + + self::assertSame( + [2, 3], + $seqs, + ); + } + + public function test_since_without_a_scope_returns_every_record(): void + { + $this->append('cn=a,dc=example,dc=com'); + $this->append('cn=b,dc=other,dc=com'); + + self::assertCount( + 2, + iterator_to_array($this->subject->since()), + ); + } + + public function test_since_with_a_scope_filters_by_dn(): void + { + $this->append('dc=example,dc=com'); + $this->append('cn=a,dc=example,dc=com'); + $this->append('cn=b,dc=other,dc=com'); + + $dns = array_map( + static fn(ChangeRecord $record): string => $record->change->dn->toString(), + iterator_to_array($this->subject->since( + 0, + ChangeScope::wholeSubtree(new Dn('dc=example,dc=com')), + )), + ); + + self::assertSame( + ['dc=example,dc=com', 'cn=a,dc=example,dc=com'], + $dns, + ); + } + + public function test_latest_seq_reflects_the_journal_high_water_mark(): void + { + self::assertSame( + 0, + $this->subject->latestSeq(), + ); + + $this->append('cn=a,dc=example,dc=com'); + $this->append('cn=b,dc=example,dc=com'); + + self::assertSame( + 2, + $this->subject->latestSeq(), + ); + } + + private function append(string $dn): void + { + $this->journal->append(new PendingChange( + changeType: ChangeType::Add, + dn: new Dn($dn), + entryUuid: '11111111-1111-4111-8111-111111111111', + authzId: AuthzId::anonymous(), + )); + } +} diff --git a/tests/unit/Server/Backend/Storage/Journal/RetentionPolicyTest.php b/tests/unit/Server/Backend/Storage/Journal/RetentionPolicyTest.php new file mode 100644 index 00000000..09d5389e --- /dev/null +++ b/tests/unit/Server/Backend/Storage/Journal/RetentionPolicyTest.php @@ -0,0 +1,43 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Tests\Unit\FreeDSx\Ldap\Server\Backend\Storage\Journal; + +use FreeDSx\Ldap\Exception\InvalidArgumentException; +use FreeDSx\Ldap\Server\Backend\Storage\Journal\RetentionPolicy; +use PHPUnit\Framework\TestCase; + +final class RetentionPolicyTest extends TestCase +{ + public function test_it_allows_unbounded_axes(): void + { + $policy = new RetentionPolicy(); + + self::assertNull($policy->maxRecords); + self::assertNull($policy->maxAgeSeconds); + } + + public function test_it_rejects_a_non_positive_record_limit(): void + { + self::expectException(InvalidArgumentException::class); + + new RetentionPolicy(maxRecords: 0); + } + + public function test_it_rejects_a_non_positive_age_limit(): void + { + self::expectException(InvalidArgumentException::class); + + new RetentionPolicy(maxAgeSeconds: 0); + } +}