From a82e3eecf4e2923a7d99ef5d23938d7869e1f216 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9s=20Ignacio=20Torres?= Date: Fri, 22 May 2026 23:40:19 -0700 Subject: [PATCH] feat: implement 'com.atproto.sync.getRepoStatus' --- README.md | 1 + app/routes.php | 2 + .../Atproto/Sync/GetLatestCommitAction.php | 22 +- .../Pds/Atproto/Sync/GetRepoStatusAction.php | 85 +++++ .../Pds/Atproto/Sync/ListReposAction.php | 22 +- src/Domain/Actor/Actor.php | 26 ++ src/Domain/Did/Did.php | 37 ++ .../Atproto/Sync/GetRepoStatusResponse.php | 60 ++++ .../Atproto/Sync/GetRepoStatusActionTest.php | 322 ++++++++++++++++++ tests/Domain/Actor/ActorTest.php | 66 ++++ tests/Domain/Did/DidTest.php | 63 ++++ 11 files changed, 671 insertions(+), 35 deletions(-) create mode 100644 src/Application/Actions/Pds/Atproto/Sync/GetRepoStatusAction.php create mode 100644 src/Domain/Did/Did.php create mode 100644 src/Domain/Pds/Atproto/Sync/GetRepoStatusResponse.php create mode 100644 tests/Application/Actions/Pds/Atproto/Sync/GetRepoStatusActionTest.php create mode 100644 tests/Domain/Did/DidTest.php diff --git a/README.md b/README.md index cdf9b1a..3fd1ffc 100644 --- a/README.md +++ b/README.md @@ -13,6 +13,7 @@ The following XRPC endpoints are implemented: - `com.atproto.server.createInviteCode` - `com.atproto.server.describeServer` - `com.atproto.sync.getLatestCommit` +- `com.atproto.sync.getRepoStatus` - `com.atproto.sync.listRepos` ## Installation diff --git a/app/routes.php b/app/routes.php index ec43bde..4df50f8 100644 --- a/app/routes.php +++ b/app/routes.php @@ -7,6 +7,7 @@ use App\Application\Actions\Pds\Atproto\Identity\ResolveHandleAction; use App\Application\Actions\Pds\Atproto\Server\CreateInviteCodeAction; use App\Application\Actions\Pds\Atproto\Server\DescribeServerAction; use App\Application\Actions\Pds\Atproto\Sync\GetLatestCommitAction; +use App\Application\Actions\Pds\Atproto\Sync\GetRepoStatusAction; use App\Application\Actions\Pds\Atproto\Sync\ListReposAction; use App\Application\Middleware\AdminAuthMiddleware; use Composer\InstalledVersions; @@ -76,6 +77,7 @@ ASCII; // atproto sync $group->get('/com.atproto.sync.listRepos', ListReposAction::class); $group->get('/com.atproto.sync.getLatestCommit', GetLatestCommitAction::class); + $group->get('/com.atproto.sync.getRepoStatus', GetRepoStatusAction::class); // misc $group->get('/_health', function (Request $request, Response $response) { diff --git a/src/Application/Actions/Pds/Atproto/Sync/GetLatestCommitAction.php b/src/Application/Actions/Pds/Atproto/Sync/GetLatestCommitAction.php index ae82b6b..6641d2a 100644 --- a/src/Application/Actions/Pds/Atproto/Sync/GetLatestCommitAction.php +++ b/src/Application/Actions/Pds/Atproto/Sync/GetLatestCommitAction.php @@ -10,7 +10,9 @@ use App\Application\Settings\SettingsInterface; use App\Domain\Actor\ActorNotFoundException; use App\Domain\Actor\ActorRepository; use App\Domain\ActorStore\ActorStoreFactory; +use App\Domain\Did\Did; use App\Domain\Pds\Atproto\Sync\GetLatestCommitResponse; +use App\Domain\Pds\Atproto\Sync\RepoView; use App\Domain\Repo\RepoRootNotFoundException; use Fig\Http\Message\StatusCodeInterface; use Psr\Http\Message\ResponseInterface as Response; @@ -46,7 +48,9 @@ class GetLatestCommitAction extends PdsAction } $did = trim($didParam); - $this->validateDid($did); + if (!Did::isValid($did)) { + throw XrpcException::invalidParam($this->actionName, 'Invalid DID', $did); + } try { $actor = $this->actorRepository->findActorByDid($did); @@ -54,11 +58,12 @@ class GetLatestCommitAction extends PdsAction throw $this->namedError('RepoNotFound', sprintf('Could not find repo for DID: %s', $did)); } - if ($actor->getTakedownRef() !== null) { + $status = $actor->getRepoStatus(); + if ($status === RepoView::STATUS_TAKENDOWN) { throw $this->namedError('RepoTakendown', sprintf('Repo has been taken down: %s', $did)); } - if ($actor->getDeactivatedAt() !== null) { + if ($status === RepoView::STATUS_DEACTIVATED) { throw $this->namedError('RepoDeactivated', sprintf('Repo has been deactivated: %s', $did)); } @@ -74,17 +79,6 @@ class GetLatestCommitAction extends PdsAction )); } - private function validateDid(string $did): void - { - if (!str_starts_with($did, 'did:')) { - throw XrpcException::invalidParam( - $this->actionName, - 'Invalid DID', - $did - ); - } - } - private function namedError(string $error, string $message): XrpcException { return new XrpcException( diff --git a/src/Application/Actions/Pds/Atproto/Sync/GetRepoStatusAction.php b/src/Application/Actions/Pds/Atproto/Sync/GetRepoStatusAction.php new file mode 100644 index 0000000..db61cde --- /dev/null +++ b/src/Application/Actions/Pds/Atproto/Sync/GetRepoStatusAction.php @@ -0,0 +1,85 @@ +actorRepository = $actorRepository; + $this->actorStoreFactory = $actorStoreFactory; + } + + /** + * {@inheritdoc} + */ + protected function action(): Response + { + $params = $this->request->getQueryParams(); + $didParam = $params['did'] ?? null; + + if (!is_string($didParam) || trim($didParam) === '') { + $this->throwMissingKeyException('did'); + } + + $did = trim($didParam); + if (!Did::isValid($did)) { + throw XrpcException::invalidParam($this->actionName, 'Invalid DID', $did); + } + + try { + $actor = $this->actorRepository->findActorByDid($did); + } catch (ActorNotFoundException $e) { + throw new XrpcException( + 'RepoNotFound', + sprintf('Could not find repo for DID: %s', $did), + StatusCodeInterface::STATUS_BAD_REQUEST + ); + } + + $status = $actor->getRepoStatus(); + $active = $status === null; + + $rev = null; + if ($active) { + try { + $root = $this->actorStoreFactory->get($did)->getRepoRoot()->findByDid($did); + $rev = $root->getRev(); + } catch (RepoRootNotFoundException $e) { + // active actor without an initialised repo + $rev = null; + } + } + + return $this->respondWithData(new GetRepoStatusResponse( + did: $did, + active: $active, + status: $status, + rev: $rev, + )); + } +} diff --git a/src/Application/Actions/Pds/Atproto/Sync/ListReposAction.php b/src/Application/Actions/Pds/Atproto/Sync/ListReposAction.php index 47782d7..632e7b1 100644 --- a/src/Application/Actions/Pds/Atproto/Sync/ListReposAction.php +++ b/src/Application/Actions/Pds/Atproto/Sync/ListReposAction.php @@ -59,7 +59,7 @@ class ListReposAction extends PdsAction continue; } - $status = $this->deriveStatus($actor); + $status = $actor->getRepoStatus(); $repos[] = new RepoView( did: $actor->getDid(), head: $root->getCid(), @@ -124,24 +124,4 @@ class ListReposAction extends PdsAction $cursor = trim($raw); return $cursor === '' ? null : $cursor; } - - /** - * Derive the lex `status` for an actor's repo view. - * - * Returns null when the repo is active, and otherwise - * returns a string indicating a non-active repo status - * (e.g. "takendown" or "deactivated"). - */ - private function deriveStatus(\App\Domain\Actor\Actor $actor): ?string - { - if ($actor->getTakedownRef() !== null) { - return RepoView::STATUS_TAKENDOWN; - } - - if ($actor->getDeactivatedAt() !== null) { - return RepoView::STATUS_DEACTIVATED; - } - - return null; - } } diff --git a/src/Domain/Actor/Actor.php b/src/Domain/Actor/Actor.php index df1e2fa..0163a72 100644 --- a/src/Domain/Actor/Actor.php +++ b/src/Domain/Actor/Actor.php @@ -5,6 +5,7 @@ declare(strict_types=1); namespace App\Domain\Actor; use App\Domain\Common\StringNormalizer; +use App\Domain\Pds\Atproto\Sync\RepoView; use DateTimeImmutable; use JsonSerializable; @@ -68,6 +69,31 @@ class Actor implements JsonSerializable return $this->deleteAfter; } + /** + * Derive the lex `status` value for this actor's repo. + * + * Returns null when the repo is active, otherwise the matching + * non-active status string (e.g. "takendown", "deactivated"). + * Takedown takes precedence over deactivation. + */ + public function getRepoStatus(): ?string + { + if ($this->takedownRef !== null) { + return RepoView::STATUS_TAKENDOWN; + } + + if ($this->deactivatedAt !== null) { + return RepoView::STATUS_DEACTIVATED; + } + + return null; + } + + public function isRepoActive(): bool + { + return $this->getRepoStatus() === null; + } + /** * @return array */ diff --git a/src/Domain/Did/Did.php b/src/Domain/Did/Did.php new file mode 100644 index 0000000..146786e --- /dev/null +++ b/src/Domain/Did/Did.php @@ -0,0 +1,37 @@ +:` + * with non-empty method and identifier parts. + */ + public static function isValid(string $did): bool + { + if (!str_starts_with($did, self::PREFIX)) { + return false; + } + + $parts = explode(':', $did, 3); + if (count($parts) !== 3) { + return false; + } + + return $parts[1] !== '' && $parts[2] !== ''; + } +} diff --git a/src/Domain/Pds/Atproto/Sync/GetRepoStatusResponse.php b/src/Domain/Pds/Atproto/Sync/GetRepoStatusResponse.php new file mode 100644 index 0000000..f93f9cf --- /dev/null +++ b/src/Domain/Pds/Atproto/Sync/GetRepoStatusResponse.php @@ -0,0 +1,60 @@ +did; + } + + public function isActive(): bool + { + return $this->active; + } + + public function getStatus(): ?string + { + return $this->status; + } + + public function getRev(): ?string + { + return $this->rev; + } + + /** + * @return array + */ + #[\ReturnTypeWillChange] + public function jsonSerialize(): array + { + $out = [ + 'did' => $this->did, + 'active' => $this->active, + ]; + if ($this->status !== null) { + $out['status'] = $this->status; + } + if ($this->rev !== null) { + $out['rev'] = $this->rev; + } + return $out; + } +} diff --git a/tests/Application/Actions/Pds/Atproto/Sync/GetRepoStatusActionTest.php b/tests/Application/Actions/Pds/Atproto/Sync/GetRepoStatusActionTest.php new file mode 100644 index 0000000..85e32ef --- /dev/null +++ b/tests/Application/Actions/Pds/Atproto/Sync/GetRepoStatusActionTest.php @@ -0,0 +1,322 @@ + $root, or throws + * RepoRootNotFoundException when $root is null. + */ + private function makeFactory(string $did, ?RepoRoot $root): ActorStoreFactory + { + $repoRootProphecy = $this->prophesize(RepoRootRepository::class); + if ($root === null) { + $repoRootProphecy->findByDid($did)->willThrow(new RepoRootNotFoundException()); + } else { + $repoRootProphecy->findByDid($did)->willReturn($root); + } + + $storeProphecy = $this->prophesize(ActorStore::class); + $storeProphecy->getRepoRoot()->willReturn($repoRootProphecy->reveal()); + + $factoryProphecy = $this->prophesize(ActorStoreFactory::class); + $factoryProphecy->get($did)->willReturn($storeProphecy->reveal()); + + return $factoryProphecy->reveal(); + } + + public function testActionReturnsActiveStatusWithRevForLiveRepo(): void + { + $logger = $this->prophesize(LoggerInterface::class)->reveal(); + $settings = new Settings([]); + + $actor = $this->makeActor('did:web:alice.pds.test'); + $repoProphecy = $this->prophesize(ActorRepository::class); + $repoProphecy->findActorByDid('did:web:alice.pds.test') + ->willReturn($actor) + ->shouldBeCalledOnce(); + + $root = new RepoRoot( + 'did:web:alice.pds.test', + 'bafyHead', + '3kabc', + new DateTimeImmutable('2026-01-02T00:00:00Z') + ); + $factory = $this->makeFactory('did:web:alice.pds.test', $root); + + $action = new GetRepoStatusAction($logger, $settings, $repoProphecy->reveal(), $factory); + + $request = $this->createRequest('GET', '/xrpc/com.atproto.sync.getRepoStatus') + ->withQueryParams(['did' => 'did:web:alice.pds.test']); + $response = (new ResponseFactory())->createResponse(); + + $actualResponse = $action($request, $response, []); + + $expected = json_encode( + [ + 'did' => 'did:web:alice.pds.test', + 'active' => true, + 'rev' => '3kabc', + ], + JSON_PRETTY_PRINT + ); + + $this->assertSame(200, $actualResponse->getStatusCode()); + $this->assertSame('application/json', $actualResponse->getHeaderLine('Content-Type')); + $this->assertSame($expected, (string) $actualResponse->getBody()); + } + + public function testActionOmitsRevWhenActiveRepoHasNoRoot(): void + { + $logger = $this->prophesize(LoggerInterface::class)->reveal(); + $settings = new Settings([]); + + $actor = $this->makeActor('did:web:newbie.pds.test'); + $repoProphecy = $this->prophesize(ActorRepository::class); + $repoProphecy->findActorByDid('did:web:newbie.pds.test')->willReturn($actor); + + $factory = $this->makeFactory('did:web:newbie.pds.test', null); + + $action = new GetRepoStatusAction($logger, $settings, $repoProphecy->reveal(), $factory); + + $request = $this->createRequest('GET', '/xrpc/com.atproto.sync.getRepoStatus') + ->withQueryParams(['did' => 'did:web:newbie.pds.test']); + $response = (new ResponseFactory())->createResponse(); + + $actualResponse = $action($request, $response, []); + + $expected = json_encode( + [ + 'did' => 'did:web:newbie.pds.test', + 'active' => true, + ], + JSON_PRETTY_PRINT + ); + + $this->assertSame(200, $actualResponse->getStatusCode()); + $this->assertSame($expected, (string) $actualResponse->getBody()); + } + + public function testActionReturnsTakendownStatusWithoutRev(): void + { + $logger = $this->prophesize(LoggerInterface::class)->reveal(); + $settings = new Settings([]); + + $actor = $this->makeActor('did:web:banned.pds.test', null, 'mod-action-123'); + $repoProphecy = $this->prophesize(ActorRepository::class); + $repoProphecy->findActorByDid('did:web:banned.pds.test')->willReturn($actor); + + $factory = $this->prophesize(ActorStoreFactory::class); + $factory->get(Argument::any())->shouldNotBeCalled(); + + $action = new GetRepoStatusAction( + $logger, + $settings, + $repoProphecy->reveal(), + $factory->reveal() + ); + + $request = $this->createRequest('GET', '/xrpc/com.atproto.sync.getRepoStatus') + ->withQueryParams(['did' => 'did:web:banned.pds.test']); + $response = (new ResponseFactory())->createResponse(); + + $actualResponse = $action($request, $response, []); + + $expected = json_encode( + [ + 'did' => 'did:web:banned.pds.test', + 'active' => false, + 'status' => 'takendown', + ], + JSON_PRETTY_PRINT + ); + + $this->assertSame(200, $actualResponse->getStatusCode()); + $this->assertSame($expected, (string) $actualResponse->getBody()); + } + + public function testActionReturnsDeactivatedStatusWithoutRev(): void + { + $logger = $this->prophesize(LoggerInterface::class)->reveal(); + $settings = new Settings([]); + + $actor = $this->makeActor( + 'did:web:gone.pds.test', + new DateTimeImmutable('2026-02-01T00:00:00Z') + ); + $repoProphecy = $this->prophesize(ActorRepository::class); + $repoProphecy->findActorByDid('did:web:gone.pds.test')->willReturn($actor); + + $factory = $this->prophesize(ActorStoreFactory::class); + $factory->get(Argument::any())->shouldNotBeCalled(); + + $action = new GetRepoStatusAction( + $logger, + $settings, + $repoProphecy->reveal(), + $factory->reveal() + ); + + $request = $this->createRequest('GET', '/xrpc/com.atproto.sync.getRepoStatus') + ->withQueryParams(['did' => 'did:web:gone.pds.test']); + $response = (new ResponseFactory())->createResponse(); + + $actualResponse = $action($request, $response, []); + + $expected = json_encode( + [ + 'did' => 'did:web:gone.pds.test', + 'active' => false, + 'status' => 'deactivated', + ], + JSON_PRETTY_PRINT + ); + + $this->assertSame(200, $actualResponse->getStatusCode()); + $this->assertSame($expected, (string) $actualResponse->getBody()); + } + + public function testActionPrefersTakedownOverDeactivation(): void + { + $logger = $this->prophesize(LoggerInterface::class)->reveal(); + $settings = new Settings([]); + + $actor = $this->makeActor( + 'did:web:both.pds.test', + new DateTimeImmutable('2026-02-01T00:00:00Z'), + 'mod-action-123' + ); + $repoProphecy = $this->prophesize(ActorRepository::class); + $repoProphecy->findActorByDid('did:web:both.pds.test')->willReturn($actor); + + $factory = $this->prophesize(ActorStoreFactory::class); + $factory->get(Argument::any())->shouldNotBeCalled(); + + $action = new GetRepoStatusAction( + $logger, + $settings, + $repoProphecy->reveal(), + $factory->reveal() + ); + + $request = $this->createRequest('GET', '/xrpc/com.atproto.sync.getRepoStatus') + ->withQueryParams(['did' => 'did:web:both.pds.test']); + $response = (new ResponseFactory())->createResponse(); + + $actualResponse = $action($request, $response, []); + + /** @var array{status?: string} $payload */ + $payload = json_decode((string) $actualResponse->getBody(), true); + $this->assertSame('takendown', $payload['status'] ?? null); + } + + public function testActionThrowsRepoNotFoundWhenActorMissing(): void + { + $logger = $this->prophesize(LoggerInterface::class)->reveal(); + $settings = new Settings([]); + + $repoProphecy = $this->prophesize(ActorRepository::class); + $repoProphecy->findActorByDid('did:web:missing.pds.test') + ->willThrow(new ActorNotFoundException()); + + $factory = $this->prophesize(ActorStoreFactory::class); + $factory->get(Argument::any())->shouldNotBeCalled(); + + $action = new GetRepoStatusAction( + $logger, + $settings, + $repoProphecy->reveal(), + $factory->reveal() + ); + + $request = $this->createRequest('GET', '/xrpc/com.atproto.sync.getRepoStatus') + ->withQueryParams(['did' => 'did:web:missing.pds.test']); + $response = (new ResponseFactory())->createResponse(); + + try { + $action($request, $response, []); + $this->fail('Expected XrpcException was not thrown.'); + } catch (XrpcException $e) { + $this->assertSame('RepoNotFound', $e->getError()); + $this->assertSame(400, $e->getStatusCode()); + } + } + + public function testActionThrowsXrpcInvalidRequestWhenDidParamMissing(): void + { + $logger = $this->prophesize(LoggerInterface::class)->reveal(); + $settings = new Settings([]); + $repo = $this->prophesize(ActorRepository::class); + $repo->findActorByDid(Argument::any())->shouldNotBeCalled(); + $factory = $this->prophesize(ActorStoreFactory::class)->reveal(); + + $action = new GetRepoStatusAction($logger, $settings, $repo->reveal(), $factory); + + $request = $this->createRequest('GET', '/xrpc/com.atproto.sync.getRepoStatus'); + $response = (new ResponseFactory())->createResponse(); + + try { + $action($request, $response, []); + $this->fail('Expected XrpcException was not thrown.'); + } catch (XrpcException $e) { + $this->assertSame('InvalidRequest', $e->getError()); + $this->assertSame(400, $e->getStatusCode()); + $this->assertSame( + 'Invalid com.atproto.sync.getRepoStatus params: Missing required key "did"', + $e->getMessage() + ); + } + } + + public function testActionThrowsXrpcInvalidParamWhenDidIsMalformed(): void + { + $logger = $this->prophesize(LoggerInterface::class)->reveal(); + $settings = new Settings([]); + $repo = $this->prophesize(ActorRepository::class); + $repo->findActorByDid(Argument::any())->shouldNotBeCalled(); + $factory = $this->prophesize(ActorStoreFactory::class)->reveal(); + + $action = new GetRepoStatusAction($logger, $settings, $repo->reveal(), $factory); + + $request = $this->createRequest('GET', '/xrpc/com.atproto.sync.getRepoStatus') + ->withQueryParams(['did' => 'not-a-did']); + $response = (new ResponseFactory())->createResponse(); + + $this->expectException(XrpcException::class); + $action($request, $response, []); + } +} diff --git a/tests/Domain/Actor/ActorTest.php b/tests/Domain/Actor/ActorTest.php index 3e2d03e..17976a9 100644 --- a/tests/Domain/Actor/ActorTest.php +++ b/tests/Domain/Actor/ActorTest.php @@ -5,6 +5,7 @@ declare(strict_types=1); namespace Tests\Domain\Actor; use App\Domain\Actor\Actor; +use App\Domain\Pds\Atproto\Sync\RepoView; use DateTimeImmutable; use Tests\TestCase; @@ -79,4 +80,69 @@ class ActorTest extends TestCase $this->assertNull($payload['deactivatedAt']); $this->assertNull($payload['deleteAfter']); } + + public function testGetRepoStatusReturnsNullForActiveActor(): void + { + $actor = new Actor( + did: 'did:web:alice.pds.test', + handle: 'alice.pds.test', + createdAt: new DateTimeImmutable('2026-01-01T00:00:00Z'), + ); + + $this->assertNull($actor->getRepoStatus()); + $this->assertTrue($actor->isRepoActive()); + } + + public function testGetRepoStatusReturnsTakendownWhenTakedownRefSet(): void + { + $actor = new Actor( + did: 'did:web:banned.pds.test', + handle: 'banned.pds.test', + createdAt: new DateTimeImmutable('2026-01-01T00:00:00Z'), + takedownRef: 'mod-action-123', + ); + + $this->assertSame(RepoView::STATUS_TAKENDOWN, $actor->getRepoStatus()); + $this->assertFalse($actor->isRepoActive()); + } + + public function testGetRepoStatusReturnsDeactivatedWhenDeactivatedAtSet(): void + { + $actor = new Actor( + did: 'did:web:gone.pds.test', + handle: 'gone.pds.test', + createdAt: new DateTimeImmutable('2026-01-01T00:00:00Z'), + deactivatedAt: new DateTimeImmutable('2026-02-01T00:00:00Z'), + ); + + $this->assertSame(RepoView::STATUS_DEACTIVATED, $actor->getRepoStatus()); + $this->assertFalse($actor->isRepoActive()); + } + + public function testGetRepoStatusPrefersTakendownOverDeactivated(): void + { + $actor = new Actor( + did: 'did:web:both.pds.test', + handle: 'both.pds.test', + createdAt: new DateTimeImmutable('2026-01-01T00:00:00Z'), + takedownRef: 'mod-action-123', + deactivatedAt: new DateTimeImmutable('2026-02-01T00:00:00Z'), + ); + + $this->assertSame(RepoView::STATUS_TAKENDOWN, $actor->getRepoStatus()); + $this->assertFalse($actor->isRepoActive()); + } + + public function testGetRepoStatusIgnoresDeleteAfter(): void + { + $actor = new Actor( + did: 'did:web:scheduled.pds.test', + handle: 'scheduled.pds.test', + createdAt: new DateTimeImmutable('2026-01-01T00:00:00Z'), + deleteAfter: new DateTimeImmutable('2026-03-01T00:00:00Z'), + ); + + $this->assertNull($actor->getRepoStatus()); + $this->assertTrue($actor->isRepoActive()); + } } diff --git a/tests/Domain/Did/DidTest.php b/tests/Domain/Did/DidTest.php new file mode 100644 index 0000000..bd1d21a --- /dev/null +++ b/tests/Domain/Did/DidTest.php @@ -0,0 +1,63 @@ + + */ + public static function validDidProvider(): array + { + return [ + 'did:web with hostname' => ['did:web:alice.pds.test'], + 'did:plc identifier' => ['did:plc:abcdefghijklmnopqrstuvwx'], + 'did:web with port and path' => ['did:web:example.com%3A8443:user:alice'], + 'unknown method still valid' => ['did:example:123'], + 'method id with extra colons' => ['did:web:host:with:many:colons'], + 'single char method and id' => ['did:a:b'], + ]; + } + + /** + * @return array + */ + public static function invalidDidProvider(): array + { + return [ + 'empty string' => [''], + 'missing prefix' => ['alice.pds.test'], + 'wrong prefix scheme' => ['urn:web:alice.pds.test'], + 'prefix only' => ['did:'], + 'prefix with empty parts' => ['did::'], + 'missing identifier' => ['did:web:'], + 'missing method' => ['did::alice.pds.test'], + 'only two segments' => ['did:web'], + 'whitespace prefix' => [' did:web:alice.pds.test'], + 'wrong casing of prefix' => ['DID:web:alice.pds.test'], + ]; + } + + #[DataProvider('validDidProvider')] + public function testIsValidAcceptsWellFormedDids(string $did): void + { + $this->assertTrue(Did::isValid($did), sprintf('Expected "%s" to be valid', $did)); + } + + #[DataProvider('invalidDidProvider')] + public function testIsValidRejectsMalformedDids(string $did): void + { + $this->assertFalse(Did::isValid($did), sprintf('Expected "%s" to be invalid', $did)); + } + + public function testPrefixConstant(): void + { + $this->assertSame('did:', Did::PREFIX); + } +} -- 2.51.2