From c3c02cf9c638c48529cb488de0f7af3e23dc83fa Mon Sep 17 00:00:00 2001 From: Salvatore Martire <4652631+salmart-dev@users.noreply.github.com> Date: Wed, 28 Oct 2026 12:06:08 +0100 Subject: [PATCH] feat: introduce Actor data object in NCU Signed-off-by: Salvatore Martire <4652631+salmart-dev@users.noreply.github.com> Assisted-by: ClaudeCode:claude-opus-5-5 --- lib/composer/composer/autoload_classmap.php | 4 + lib/composer/composer/autoload_static.php | 4 + lib/unstable/Actor/Actor.php | 197 ++++++++++++++++++++ lib/unstable/Actor/DelegationKind.php | 41 ++++ lib/unstable/Actor/Principal.php | 166 +++++++++++++++++ lib/unstable/Actor/PrincipalType.php | 65 +++++++ tests/lib/Actor/ActorTest.php | 172 +++++++++++++++++ tests/lib/Actor/PrincipalTest.php | 100 ++++++++++ 8 files changed, 749 insertions(+) create mode 100644 lib/unstable/Actor/Actor.php create mode 100644 lib/unstable/Actor/DelegationKind.php create mode 100644 lib/unstable/Actor/Principal.php create mode 100644 lib/unstable/Actor/PrincipalType.php create mode 100644 tests/lib/Actor/ActorTest.php create mode 100644 tests/lib/Actor/PrincipalTest.php diff --git a/lib/composer/composer/autoload_classmap.php b/lib/composer/composer/autoload_classmap.php index b3b5b63fb3a1e..bfe108d4b1383 100644 --- a/lib/composer/composer/autoload_classmap.php +++ b/lib/composer/composer/autoload_classmap.php @@ -7,6 +7,10 @@ return array( 'Composer\\InstalledVersions' => $vendorDir . '/composer/InstalledVersions.php', + 'NCU\\Actor\\Actor' => $baseDir . '/lib/unstable/Actor/Actor.php', + 'NCU\\Actor\\DelegationKind' => $baseDir . '/lib/unstable/Actor/DelegationKind.php', + 'NCU\\Actor\\Principal' => $baseDir . '/lib/unstable/Actor/Principal.php', + 'NCU\\Actor\\PrincipalType' => $baseDir . '/lib/unstable/Actor/PrincipalType.php', 'NCU\\Config\\Exceptions\\IncorrectTypeException' => $baseDir . '/lib/unstable/Config/Exceptions/IncorrectTypeException.php', 'NCU\\Config\\Exceptions\\TypeConflictException' => $baseDir . '/lib/unstable/Config/Exceptions/TypeConflictException.php', 'NCU\\Config\\Exceptions\\UnknownKeyException' => $baseDir . '/lib/unstable/Config/Exceptions/UnknownKeyException.php', diff --git a/lib/composer/composer/autoload_static.php b/lib/composer/composer/autoload_static.php index 35c872fd7527d..e932f69aff755 100644 --- a/lib/composer/composer/autoload_static.php +++ b/lib/composer/composer/autoload_static.php @@ -48,6 +48,10 @@ class ComposerStaticInit749170dad3f5e7f9ca158f5a9f04f6a2 public static $classMap = array ( 'Composer\\InstalledVersions' => __DIR__ . '/..' . '/composer/InstalledVersions.php', + 'NCU\\Actor\\Actor' => __DIR__ . '/../../..' . '/lib/unstable/Actor/Actor.php', + 'NCU\\Actor\\DelegationKind' => __DIR__ . '/../../..' . '/lib/unstable/Actor/DelegationKind.php', + 'NCU\\Actor\\Principal' => __DIR__ . '/../../..' . '/lib/unstable/Actor/Principal.php', + 'NCU\\Actor\\PrincipalType' => __DIR__ . '/../../..' . '/lib/unstable/Actor/PrincipalType.php', 'NCU\\Config\\Exceptions\\IncorrectTypeException' => __DIR__ . '/../../..' . '/lib/unstable/Config/Exceptions/IncorrectTypeException.php', 'NCU\\Config\\Exceptions\\TypeConflictException' => __DIR__ . '/../../..' . '/lib/unstable/Config/Exceptions/TypeConflictException.php', 'NCU\\Config\\Exceptions\\UnknownKeyException' => __DIR__ . '/../../..' . '/lib/unstable/Config/Exceptions/UnknownKeyException.php', diff --git a/lib/unstable/Actor/Actor.php b/lib/unstable/Actor/Actor.php new file mode 100644 index 0000000000000..4bc8e5dea1da9 --- /dev/null +++ b/lib/unstable/Actor/Actor.php @@ -0,0 +1,197 @@ +chain(); + if (count($chain) > self::MAX_CHAIN_LENGTH) { + throw new InvalidArgumentException('Delegation chain too long'); + } + + $keys = array_map(static fn (Principal $p) => $p->key(), $chain); + if (count($keys) !== count(array_unique($keys))) { + throw new InvalidArgumentException('Principal appears more than once in delegation chain'); + } + } + + /** + * Actor with no delegation. + * + * @experimental 36.0.0 + */ + public static function of(Principal $principal): self { + return new self($principal); + } + + /** + * $this acts as $target with the target's full view. + * + * @experimental 36.0.0 + */ + public function impersonate(Principal $target): self { + return new self($target, $this, DelegationKind::Impersonation); + } + + /** + * $this acts on behalf of $subject. + * + * @experimental 36.0.0 + */ + public function actFor(Principal $subject): self { + return new self($subject, $this, DelegationKind::OnBehalfOf); + } + + /** + * Continue claiming the authority of a system component. + * + * @experimental 36.0.0 + */ + public function elevate(Principal $system): self { + return new self($system, $this, DelegationKind::SystemElevation); + } + + /** + * @experimental 36.0.0 + */ + public function isDelegated(): bool { + return $this->delegate !== null; + } + + /** + * The party at the end of the chain that actually started this (bot, admin, user who triggered elevation). + * + * @experimental 36.0.0 + */ + public function initiator(): Principal { + return $this->delegate?->initiator() ?? $this->principal; + } + + /** + * @return non-empty-list principal first, initiator last + * @experimental 36.0.0 + */ + public function chain(): array { + return [$this->principal, ...($this->delegate?->chain() ?? [])]; + } + + /** + * Whether any link in the chain is of the given type, e.g. "is a bot involved at all?" + * + * @experimental 36.0.0 + */ + public function involves(PrincipalType $type): bool { + foreach ($this->chain() as $p) { + if ($p->is($type)) { + return true; + } + } + return false; + } + + /** + * @experimental 36.0.0 + */ + #[Override] + public function jsonSerialize(): array { + return [ + 'principal' => $this->principal, + 'via' => $this->via?->value, + 'delegate' => $this->delegate, + ]; + } + + /** + * Rehydrate from a serialized payload. + * + * @param array $data + * @throws InvalidArgumentException + * @experimental 36.0.0 + */ + public static function fromArray(array $data): self { + $principal = $data['principal'] ?? null; + $delegate = $data['delegate'] ?? null; + $via = $data['via'] ?? null; + if (!is_array($principal) || ($delegate !== null && !is_array($delegate)) || ($via !== null && !is_string($via))) { + throw new InvalidArgumentException('Invalid actor data'); + } + + return new self(Principal::fromArray($principal), + $delegate !== null ? self::fromArray($delegate) : null, + $via !== null ? DelegationKind::tryFrom($via) ?? throw new InvalidArgumentException(sprintf('Unknown delegation kind "%s"', $via)) + : null, ); + } + + private static function assertValidLink(Principal $principal, Actor $delegate, DelegationKind $via): void { + $fail = static fn (string $why): never => throw new InvalidArgumentException( + sprintf('Invalid %s: %s', $via->value, $why), + ); + + match ($via) { + DelegationKind::Impersonation => match (true) { + !$principal->is(PrincipalType::User) => $fail('only local users can be impersonated'), + !$delegate->principal->is(PrincipalType::User) => $fail('only local users can impersonate'), + $delegate->isDelegated() => $fail('nested impersonation is not allowed'), + default => null, + }, + DelegationKind::OnBehalfOf => match (true) { + !$principal->type->isHuman() => $fail('subject must be a human principal'), + !$delegate->principal->is(PrincipalType::Bot) && !$delegate->principal->is(PrincipalType::System) => $fail('only bots and system components act on behalf of others'), + default => null, + }, + DelegationKind::SystemElevation => match (true) { + !$principal->is(PrincipalType::System) => $fail('target must be a system principal'), + $delegate->principal->is(PrincipalType::System) => $fail('system cannot elevate to system'), + default => null, + }, + }; + } +} diff --git a/lib/unstable/Actor/DelegationKind.php b/lib/unstable/Actor/DelegationKind.php new file mode 100644 index 0000000000000..7d5cdfa9a5922 --- /dev/null +++ b/lib/unstable/Actor/DelegationKind.php @@ -0,0 +1,41 @@ + $origin !== null && $origin !== '', + PrincipalType::Guest => $origin === null || $origin !== '', + PrincipalType::User, PrincipalType::Bot, PrincipalType::System => $origin === null, + default => false, + }; + if (!$originAllowed) { + throw new InvalidArgumentException(sprintf( + 'Invalid origin for principal of type "%s"', + $type->value, + )); + } + + $sourceAllowed = $type === PrincipalType::Guest + ? $source !== null && $source !== '' + : $source === null; + if (!$sourceAllowed) { + throw new InvalidArgumentException(sprintf( + 'Invalid source for principal of type "%s"', + $type->value, + )); + } + } + + /** + * @experimental 36.0.0 + */ + public static function user(string $uid): self { + return new self(PrincipalType::User, $uid); + } + + /** + * @experimental 36.0.0 + */ + public static function federated(string $userId, string $remote): self { + return new self(PrincipalType::Federated, $userId, $remote); + } + + /** + * @param string $source app-namespaced kind of guest, e.g. "spreed.email" + * @experimental 36.0.0 + */ + public static function guest(string $source, string $guestId, ?string $token = null): self { + return new self(PrincipalType::Guest, $guestId, $token, $source); + } + + /** + * @experimental 36.0.0 + */ + public static function bot(string $botId): self { + return new self(PrincipalType::Bot, $botId); + } + + /** + * Identifies a component, e.g. "governance.retention". + * + * @experimental 36.0.0 + */ + public static function system(string $component): self { + return new self(PrincipalType::System, $component); + } + + /** + * @experimental 36.0.0 + */ + public function is(PrincipalType $type): bool { + return $this->type === $type; + } + + /** + * @experimental 36.0.0 + */ + public function equals(self $other): bool { + return $this->type === $other->type + && $this->id === $other->id + && $this->origin === $other->origin + && $this->source === $other->source; + } + + /** + * Stable key for logs, cache keys and cycle detection, e.g. "federated:bob@cloud.example" + * or "guest:spreed.guest:abc123@roomtoken" + * + * @experimental 36.0.0 + */ + public function key(): string { + return $this->type->value . ':' + . ($this->source !== null ? $this->source . ':' : '') + . $this->id + . ($this->origin !== null ? '@' . $this->origin : ''); + } + + /** + * @return array{type: string, id: string, origin: ?string, source: ?string} + * @experimental 36.0.0 + */ + #[Override] + public function jsonSerialize(): array { + return ['type' => $this->type->value, 'id' => $this->id, 'origin' => $this->origin, 'source' => $this->source]; + } + + /** + * @param array $data + * @throws InvalidArgumentException + * @experimental 36.0.0 + */ + public static function fromArray(array $data): self { + $type = $data['type'] ?? null; + $id = $data['id'] ?? null; + $origin = $data['origin'] ?? null; + $source = $data['source'] ?? null; + if (!is_string($type) || !is_string($id) + || ($origin !== null && !is_string($origin)) + || ($source !== null && !is_string($source))) { + throw new InvalidArgumentException('Invalid principal data'); + } + + return new self( + PrincipalType::tryFrom($type) ?? throw new InvalidArgumentException(sprintf('Unknown principal type "%s"', $type)), + $id, + $origin, + $source, + ); + } +} diff --git a/lib/unstable/Actor/PrincipalType.php b/lib/unstable/Actor/PrincipalType.php new file mode 100644 index 0000000000000..f70080a401a10 --- /dev/null +++ b/lib/unstable/Actor/PrincipalType.php @@ -0,0 +1,65 @@ + true, + self::Bot, self::System => false, + }; + } +} diff --git a/tests/lib/Actor/ActorTest.php b/tests/lib/Actor/ActorTest.php new file mode 100644 index 0000000000000..46831aeb3616f --- /dev/null +++ b/tests/lib/Actor/ActorTest.php @@ -0,0 +1,172 @@ +assertSame($alice, $actor->principal); + $this->assertNull($actor->delegate); + $this->assertNull($actor->via); + $this->assertFalse($actor->isDelegated()); + $this->assertSame([$alice], $actor->chain()); + $this->assertSame($alice, $actor->initiator()); + } + + public static function dataValidLinks(): array { + return [ + 'user impersonates user' => [fn () => Actor::of(Principal::user('admin'))->impersonate(Principal::user('alice')), DelegationKind::Impersonation, ['user:alice', 'user:admin']], + 'bot acts for user' => [fn () => Actor::of(Principal::bot('summarizer'))->actFor(Principal::user('alice')), DelegationKind::OnBehalfOf, ['user:alice', 'bot:summarizer']], + 'bot acts for federated' => [fn () => Actor::of(Principal::bot('summarizer'))->actFor(Principal::federated('bob', 'cloud.example')), DelegationKind::OnBehalfOf, ['federated:bob@cloud.example', 'bot:summarizer']], + 'bot acts for guest' => [fn () => Actor::of(Principal::bot('summarizer'))->actFor(Principal::guest('spreed.guest', 'g1', 'room')), DelegationKind::OnBehalfOf, ['guest:spreed.guest:g1@room', 'bot:summarizer']], + 'system acts for user' => [fn () => Actor::of(Principal::system('files_versions.expire'))->actFor(Principal::user('alice')), DelegationKind::OnBehalfOf, ['user:alice', 'system:files_versions.expire']], + 'elevation acts for user' => [ + fn () => Actor::of(Principal::user('admin'))->elevate(Principal::system('files.retention'))->actFor(Principal::user('alice')), + DelegationKind::OnBehalfOf, ['user:alice', 'system:files.retention', 'user:admin'], + ], + 'alternating elevation and delegation' => [ + fn () => Actor::of(Principal::user('admin'))->elevate(Principal::system('files.retention'))->actFor(Principal::user('alice'))->elevate(Principal::system('files_versions.expire')), + DelegationKind::SystemElevation, ['system:files_versions.expire', 'user:alice', 'system:files.retention', 'user:admin'], + ], + 'user elevates' => [fn () => Actor::of(Principal::user('admin'))->elevate(Principal::system('files.retention')), DelegationKind::SystemElevation, ['system:files.retention', 'user:admin']], 'impersonation elevates' => [ + fn () => Actor::of(Principal::user('admin'))->impersonate(Principal::user('alice'))->elevate(Principal::system('files.retention')), + DelegationKind::SystemElevation, ['system:files.retention', 'user:alice', 'user:admin'], + ], + ]; + } + + /** + * @param Closure(): Actor $createActor + * @param list $expectedChainKeys + */ + #[DataProvider(methodName: 'dataValidLinks')] + public function testValidLinks(Closure $createActor, DelegationKind $expectedVia, array $expectedChainKeys): void { + $actor = $createActor(); + $this->assertTrue($actor->isDelegated()); + $this->assertSame($expectedVia, $actor->via); + $this->assertSame($expectedChainKeys, array_map(static fn (Principal $p) => $p->key(), $actor->chain())); + $this->assertSame($expectedChainKeys[0], $actor->principal->key()); + $this->assertSame(end($expectedChainKeys), $actor->initiator()->key()); + } + + public static function dataInvalidLinks(): array { + $admin = static fn () => Actor::of(Principal::user('admin')); + $bot = static fn () => Actor::of(Principal::bot('summarizer')); + return [ + 'impersonate guest' => [fn () => $admin()->impersonate(Principal::guest('spreed.guest', 'g1')), 'Invalid impersonation: only local users can be impersonated'], + 'impersonate federated' => [fn () => $admin()->impersonate(Principal::federated('bob', 'cloud.example')), 'Invalid impersonation: only local users can be impersonated'], + 'bot impersonates' => [fn () => $bot()->impersonate(Principal::user('alice')), 'Invalid impersonation: only local users can impersonate'], + 'nested impersonation' => [fn () => $admin()->impersonate(Principal::user('alice'))->impersonate(Principal::user('bob')), 'Invalid impersonation: nested impersonation is not allowed'], + 'act for bot' => [fn () => $bot()->actFor(Principal::bot('other')), 'Invalid on_behalf_of: subject must be a human principal'], + 'act for system' => [fn () => $bot()->actFor(Principal::system('files.retention')), 'Invalid on_behalf_of: subject must be a human principal'], + 'user acts for user' => [fn () => $admin()->actFor(Principal::user('alice')), 'Invalid on_behalf_of: only bots and system components act on behalf of others'], + 'elevate to user' => [fn () => $admin()->elevate(Principal::user('alice')), 'Invalid system_elevation: target must be a system principal'], + 'system elevates to system' => [fn () => Actor::of(Principal::system('a'))->elevate(Principal::system('b')), 'Invalid system_elevation: system cannot elevate to system'], + 'act for own initiator' => [ + fn () => Actor::of(Principal::user('alice'))->elevate(Principal::system('files.retention'))->actFor(Principal::user('alice')), + 'Principal appears more than once in delegation chain', + ], + 'self impersonation' => [fn () => $admin()->impersonate(Principal::user('admin')), 'Principal appears more than once in delegation chain'], + ]; + } + + /** + * @param Closure(): Actor $createActor + */ + #[DataProvider(methodName: 'dataInvalidLinks')] + public function testInvalidLinks(Closure $createActor, string $expectedExceptionMessage): void { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage($expectedExceptionMessage); + $createActor(); + } + + public function testInvolves(): void { + $actor = Actor::of(Principal::bot('summarizer'))->actFor(Principal::user('alice')); + + $this->assertTrue($actor->involves(PrincipalType::Bot)); + $this->assertTrue($actor->involves(PrincipalType::User)); + $this->assertFalse($actor->involves(PrincipalType::System)); + } + + /** + * @param Closure(): Actor $createActor + */ + #[DataProvider(methodName: 'dataValidLinks')] + public function testToJsonFromArray(Closure $createActor): void { + $actor = $createActor(); + $json = json_encode($actor); + $restored = Actor::fromArray(json_decode($json, true)); + + $this->assertSame($json, json_encode($restored)); + } + + public function testMaxChainLength(): void { + $actor = self::alternatingChain(Actor::MAX_CHAIN_LENGTH); + + $this->assertCount(Actor::MAX_CHAIN_LENGTH, $actor->chain()); + } + + public function testChainTooLong(): void { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('Delegation chain too long'); + self::alternatingChain(Actor::MAX_CHAIN_LENGTH + 1); + } + + /** + * Chain of the given length alternating between a system component elevation and acting for a user. + */ + private static function alternatingChain(int $length): Actor { + $actor = Actor::of(Principal::user('user0')); + for ($i = 1; $i < $length; $i++) { + $actor = $i % 2 === 1 + ? $actor->elevate(Principal::system('component' . $i)) + : $actor->actFor(Principal::user('user' . $i)); + } + return $actor; + } + + public static function dataInvalidFromArray(): array { + $alice = ['type' => 'user', 'id' => 'alice']; + $admin = ['principal' => ['type' => 'user', 'id' => 'admin']]; + return [ + 'missing principal' => [[], 'Invalid actor data'], + 'non-array principal' => [['principal' => 'alice'], 'Invalid actor data'], + 'non-array delegate' => [['principal' => $alice, 'via' => 'impersonation', 'delegate' => 'admin'], 'Invalid actor data'], + 'non-string via' => [['principal' => $alice, 'via' => 1, 'delegate' => $admin], 'Invalid actor data'], + 'unknown via' => [['principal' => $alice, 'via' => 'nope', 'delegate' => $admin], 'Unknown delegation kind "nope"'], + 'invalid principal' => [['principal' => ['type' => 'user']], 'Invalid principal data'], + 'delegate without via' => [['principal' => $alice, 'delegate' => $admin], 'A delegate requires a delegation kind and vice versa'], + 'via without delegate' => [['principal' => $alice, 'via' => 'impersonation'], 'A delegate requires a delegation kind and vice versa'], + 'forbidden link' => [ + ['principal' => ['type' => 'guest', 'id' => 'g1', 'source' => 'spreed.guest'], 'via' => 'impersonation', 'delegate' => $admin], + 'Invalid impersonation: only local users can be impersonated', + ], + ]; + } + + #[DataProvider(methodName: 'dataInvalidFromArray')] + public function testInvalidFromArray(array $data, string $message): void { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage($message); + Actor::fromArray($data); + } +} diff --git a/tests/lib/Actor/PrincipalTest.php b/tests/lib/Actor/PrincipalTest.php new file mode 100644 index 0000000000000..918269f25e2e8 --- /dev/null +++ b/tests/lib/Actor/PrincipalTest.php @@ -0,0 +1,100 @@ + [fn () => Principal::user('alice'), PrincipalType::User, 'alice', null, null, 'user:alice'], + 'federated' => [fn () => Principal::federated('bob', 'cloud.example'), PrincipalType::Federated, 'bob', 'cloud.example', null, 'federated:bob@cloud.example'], + 'guest with token' => [fn () => Principal::guest('spreed.guest', 'g1', 'room'), PrincipalType::Guest, 'g1', 'room', 'spreed.guest', 'guest:spreed.guest:g1@room'], + 'guest without token' => [fn () => Principal::guest('files_sharing.link', 'g1'), PrincipalType::Guest, 'g1', null, 'files_sharing.link', 'guest:files_sharing.link:g1'], + 'bot' => [fn () => Principal::bot('summarizer'), PrincipalType::Bot, 'summarizer', null, null, 'bot:summarizer'], + 'system' => [fn () => Principal::system('files.retention'), PrincipalType::System, 'files.retention', null, null, 'system:files.retention'], + ]; + } + + #[DataProvider(methodName: 'dataFactories')] + public function testFactories(Closure $create, PrincipalType $type, string $id, ?string $origin, ?string $source, string $key): void { + $principal = $create(); + $this->assertSame($type, $principal->type); + $this->assertSame($id, $principal->id); + $this->assertSame($origin, $principal->origin); + $this->assertSame($source, $principal->source); + $this->assertSame($key, $principal->key()); + $this->assertTrue($principal->is($type)); + } + + public static function dataInvalid(): array { + return [ + 'empty id' => [fn () => Principal::user(''), 'Principal id must not be empty'], + 'federated with empty remote' => [fn () => Principal::federated('bob', ''), 'Invalid origin for principal of type "federated"'], + 'guest with empty token' => [fn () => Principal::guest('spreed.guest', 'g1', ''), 'Invalid origin for principal of type "guest"'], + 'guest with empty source' => [fn () => Principal::guest('', 'g1'), 'Invalid source for principal of type "guest"'], + 'user with origin' => [fn () => Principal::fromArray(['type' => 'user', 'id' => 'alice', 'origin' => 'x']), 'Invalid origin for principal of type "user"'], + 'bot with source' => [fn () => Principal::fromArray(['type' => 'bot', 'id' => 'b', 'source' => 'spreed.guest']), 'Invalid source for principal of type "bot"'], + 'federated without remote' => [fn () => Principal::fromArray(['type' => 'federated', 'id' => 'bob']), 'Invalid origin for principal of type "federated"'], + 'guest without source' => [fn () => Principal::fromArray(['type' => 'guest', 'id' => 'g1']), 'Invalid source for principal of type "guest"'], + 'missing type' => [fn () => Principal::fromArray(['id' => 'alice']), 'Invalid principal data'], + 'missing id' => [fn () => Principal::fromArray(['type' => 'user']), 'Invalid principal data'], + 'non-string id' => [fn () => Principal::fromArray(['type' => 'user', 'id' => 1]), 'Invalid principal data'], + 'non-string origin' => [fn () => Principal::fromArray(['type' => 'federated', 'id' => 'bob', 'origin' => 1]), 'Invalid principal data'], + 'non-string source' => [fn () => Principal::fromArray(['type' => 'guest', 'id' => 'g1', 'source' => 1]), 'Invalid principal data'], + 'unknown type' => [fn () => Principal::fromArray(['type' => 'nope', 'id' => 'alice']), 'Unknown principal type "nope"'], + ]; + } + + #[DataProvider('dataInvalid')] + public function testInvalid(Closure $create, string $message): void { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage($message); + $create(); + } + + public static function dataEquals(): array { + $guest = fn () => Principal::guest('spreed.guest', 'g1', 'room'); + return [ + 'same' => [$guest, fn () => Principal::guest('spreed.guest', 'g1', 'room'), true], + 'different source' => [$guest, fn () => Principal::guest('spreed.email', 'g1', 'room'), false], + 'different id' => [$guest, fn () => Principal::guest('spreed.guest', 'g2', 'room'), false], + 'different origin' => [$guest, fn () => Principal::guest('spreed.guest', 'g1', 'other'), false], + 'no origin' => [$guest, fn () => Principal::guest('spreed.guest', 'g1'), false], + 'different type' => [fn () => Principal::user('alice'), fn () => Principal::bot('alice'), false], + ]; + } + + #[DataProvider('dataEquals')] + public function testEquals(Closure $createA, Closure $createB, bool $expected): void { + $a = $createA(); + $b = $createB(); + $this->assertSame($expected, $a->equals($b)); + $this->assertSame($expected, $b->equals($a)); + $this->assertSame($expected, $a->key() === $b->key()); + } + + #[DataProvider('dataFactories')] + public function testRoundTrip(Closure $create): void { + $principal = $create(); + $restored = Principal::fromArray(json_decode(json_encode($principal), true)); + + $this->assertTrue($principal->equals($restored)); + } + + public function testFromArrayWithoutOptionalKeys(): void { + $this->assertTrue(Principal::user('alice')->equals(Principal::fromArray(['type' => 'user', 'id' => 'alice']))); + } +}