From 14497e7a2a3ec6b312edfc4dd09448687dd862af Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9s=20Ignacio=20Torres?= Date: Tue, 19 May 2026 19:47:42 -0700 Subject: [PATCH] feat: implement com.atproto.server.createInviteCode --- README.md | 1 + app/constants.php | 8 -- app/repositories.php | 52 ++++---- app/routes.php | 4 + public/index.php | 1 - .../Atproto/Server/CreateInviteCodeAction.php | 117 +++++++++++++++++ .../InviteCode/InviteCodeGenerator.php | 34 +++++ src/Domain/Common/Base32.php | 87 +++++++++++++ .../Server/CreateInviteCodeResponse.php | 27 ++++ src/Domain/Repo/CidUtil.php | 77 +----------- .../Server/CreateInviteCodeActionTest.php | 118 ++++++++++++++++++ .../InviteCode/InviteCodeGeneratorTest.php | 32 +++++ tests/Domain/Common/Base32Test.php | 95 ++++++++++++++ tests/bootstrap.php | 1 - 14 files changed, 550 insertions(+), 104 deletions(-) delete mode 100644 app/constants.php create mode 100644 src/Application/Actions/Pds/Atproto/Server/CreateInviteCodeAction.php create mode 100644 src/Domain/Account/InviteCode/InviteCodeGenerator.php create mode 100644 src/Domain/Common/Base32.php create mode 100644 src/Domain/Pds/Atproto/Server/CreateInviteCodeResponse.php create mode 100644 tests/Application/Actions/Pds/Atproto/Server/CreateInviteCodeActionTest.php create mode 100644 tests/Domain/Account/InviteCode/InviteCodeGeneratorTest.php create mode 100644 tests/Domain/Common/Base32Test.php diff --git a/README.md b/README.md index b927dbe..86e0034 100644 --- a/README.md +++ b/README.md @@ -9,6 +9,7 @@ This is a work-in-progress project done for fun! Don't expect it to be productio The following XRPC endpoints are implemented: - `com.atproto.identity.resolveHandle` +- `com.atproto.server.createInviteCode` - `com.atproto.server.describeServer` - `com.atproto.sync.getLatestCommit` - `com.atproto.sync.listRepos` diff --git a/app/constants.php b/app/constants.php deleted file mode 100644 index 70457e2..0000000 --- a/app/constants.php +++ /dev/null @@ -1,8 +0,0 @@ -addDefinitions([ // Database singletons - DB_ACCOUNT => function (ContainerInterface $c) use ($dbSettings): Database { + 'db.account' => function (ContainerInterface $c) use ($dbSettings): Database { $db = new Database($dbSettings($c)['accountDb']); AccountSchema::apply($db); return $db; }, - DB_SEQUENCER => function (ContainerInterface $c) use ($dbSettings): Database { + 'db.sequencer' => function (ContainerInterface $c) use ($dbSettings): Database { $db = new Database($dbSettings($c)['sequencerDb']); SequencerSchema::apply($db); return $db; }, - DB_DID_CACHE => function (ContainerInterface $c) use ($dbSettings): Database { + 'db.didCache' => function (ContainerInterface $c) use ($dbSettings): Database { $db = new Database($dbSettings($c)['didCacheDb']); DidCacheSchema::apply($db); return $db; @@ -122,11 +125,11 @@ return function (ContainerBuilder $containerBuilder) use ($dbSettings, $getDb) { return new GuzzleAppViewClient($httpClient); }, AccountRepository::class => fn (ContainerInterface $c): SqliteAccountRepository => - new SqliteAccountRepository($getDb($c, DB_ACCOUNT)), + new SqliteAccountRepository($getDb($c, 'db.account')), AccountDeviceRepository::class => fn (ContainerInterface $c): SqliteAccountDeviceRepository => - new SqliteAccountDeviceRepository($getDb($c, DB_ACCOUNT)), + new SqliteAccountDeviceRepository($getDb($c, 'db.account')), ActorRepository::class => fn (ContainerInterface $c): SqliteActorRepository => - new SqliteActorRepository($getDb($c, DB_ACCOUNT)), + new SqliteActorRepository($getDb($c, 'db.account')), ActorStoreFactory::class => function (ContainerInterface $c) use ($dbSettings): SqliteActorStoreFactory { $settings = $dbSettings($c); return new SqliteActorStoreFactory( @@ -135,34 +138,41 @@ return function (ContainerBuilder $containerBuilder) use ($dbSettings, $getDb) { ); }, AuthorizationRequestRepository::class => fn (ContainerInterface $c): SqliteAuthorizationRequestRepository => - new SqliteAuthorizationRequestRepository($getDb($c, DB_ACCOUNT)), + new SqliteAuthorizationRequestRepository($getDb($c, 'db.account')), AuthorizedClientRepository::class => fn (ContainerInterface $c): SqliteAuthorizedClientRepository => - new SqliteAuthorizedClientRepository($getDb($c, DB_ACCOUNT)), + new SqliteAuthorizedClientRepository($getDb($c, 'db.account')), CarReader::class => autowire(NativeCarReader::class), CarWriter::class => autowire(NativeCarWriter::class), DagCborDecoder::class => autowire(NativeDagCborDecoder::class), DagCborEncoder::class => autowire(NativeDagCborEncoder::class), DeviceRepository::class => fn (ContainerInterface $c): SqliteDeviceRepository => - new SqliteDeviceRepository($getDb($c, DB_ACCOUNT)), + new SqliteDeviceRepository($getDb($c, 'db.account')), DidCacheRepository::class => fn (ContainerInterface $c): SqliteDidCacheRepository => - new SqliteDidCacheRepository($getDb($c, DB_DID_CACHE)), + new SqliteDidCacheRepository($getDb($c, 'db.didCache')), AppPasswordRepository::class => fn (ContainerInterface $c): SqliteAppPasswordRepository => - new SqliteAppPasswordRepository($getDb($c, DB_ACCOUNT)), + new SqliteAppPasswordRepository($getDb($c, 'db.account')), EmailTokenRepository::class => fn (ContainerInterface $c): SqliteEmailTokenRepository => - new SqliteEmailTokenRepository($getDb($c, DB_ACCOUNT)), + new SqliteEmailTokenRepository($getDb($c, 'db.account')), InviteCodeRepository::class => fn (ContainerInterface $c): SqliteInviteCodeRepository => - new SqliteInviteCodeRepository($getDb($c, DB_ACCOUNT)), + new SqliteInviteCodeRepository($getDb($c, 'db.account')), + InviteCodeGenerator::class => function (ContainerInterface $c): InviteCodeGenerator { + $settings = $c->get(SettingsInterface::class); + assert($settings instanceof SettingsInterface); + /** @var array{hostname: string} $pdsSettings */ + $pdsSettings = $settings->get('pds'); + return new InviteCodeGenerator($pdsSettings['hostname']); + }, LexiconRepository::class => fn (ContainerInterface $c): SqliteLexiconRepository => - new SqliteLexiconRepository($getDb($c, DB_ACCOUNT)), + new SqliteLexiconRepository($getDb($c, 'db.account')), OAuthTokenRepository::class => fn (ContainerInterface $c): SqliteOAuthTokenRepository => - new SqliteOAuthTokenRepository($getDb($c, DB_ACCOUNT)), + new SqliteOAuthTokenRepository($getDb($c, 'db.account')), RefreshTokenRepository::class => fn (ContainerInterface $c): SqliteRefreshTokenRepository => - new SqliteRefreshTokenRepository($getDb($c, DB_ACCOUNT)), + new SqliteRefreshTokenRepository($getDb($c, 'db.account')), RepoRootRepository::class => fn (ContainerInterface $c): SqliteRepoRootRepository => - new SqliteRepoRootRepository($getDb($c, DB_ACCOUNT)), + new SqliteRepoRootRepository($getDb($c, 'db.account')), SequencerRepository::class => fn (ContainerInterface $c): SqliteSequencerRepository => - new SqliteSequencerRepository($getDb($c, DB_SEQUENCER)), + new SqliteSequencerRepository($getDb($c, 'db.sequencer')), UsedRefreshTokenRepository::class => fn (ContainerInterface $c): SqliteUsedRefreshTokenRepository => - new SqliteUsedRefreshTokenRepository($getDb($c, DB_ACCOUNT)), + new SqliteUsedRefreshTokenRepository($getDb($c, 'db.account')), ]); }; diff --git a/app/routes.php b/app/routes.php index d9d3810..9d1277d 100644 --- a/app/routes.php +++ b/app/routes.php @@ -3,9 +3,11 @@ declare(strict_types=1); 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\ListReposAction; +use App\Application\Middleware\AdminAuthMiddleware; use Composer\InstalledVersions; use Psr\Http\Message\ResponseInterface as Response; use Psr\Http\Message\ServerRequestInterface as Request; @@ -59,6 +61,8 @@ ASCII; $app->group('/xrpc', function (Group $group) { // atproto server + $group->post('/com.atproto.server.createInviteCode', CreateInviteCodeAction::class) + ->add(AdminAuthMiddleware::class); $group->get('/com.atproto.server.describeServer', DescribeServerAction::class); // atproto identity diff --git a/public/index.php b/public/index.php index f238463..cb2a8f7 100644 --- a/public/index.php +++ b/public/index.php @@ -13,7 +13,6 @@ use Slim\Factory\AppFactory; use Slim\Factory\ServerRequestCreatorFactory; require __DIR__ . '/../vendor/autoload.php'; -require_once __DIR__ . '/../app/constants.php'; // Loading environment variables from .env file, if it exists $envPath = __DIR__ . '/../.env'; diff --git a/src/Application/Actions/Pds/Atproto/Server/CreateInviteCodeAction.php b/src/Application/Actions/Pds/Atproto/Server/CreateInviteCodeAction.php new file mode 100644 index 0000000..5ec1361 --- /dev/null +++ b/src/Application/Actions/Pds/Atproto/Server/CreateInviteCodeAction.php @@ -0,0 +1,117 @@ +inviteCodes = $inviteCodes; + $this->generator = $generator; + } + + /** + * {@inheritdoc} + */ + protected function action(): Response + { + $body = $this->getFormData(); + if (!is_array($body)) { + throw XrpcException::invalidRequest('Request body must be a JSON object'); + } + + $useCount = $this->parseUseCount($body['useCount'] ?? null); + $forAccount = $this->parseForAccount($body['forAccount'] ?? null); + + $code = new InviteCode( + code: $this->generator->generate(), + availableUses: $useCount, + disabled: false, + forAccount: $forAccount, + createdBy: 'admin', + createdAt: new DateTimeImmutable(), + ); + + $this->inviteCodes->save($code); + + return $this->respondWithData(new CreateInviteCodeResponse($code->getCode())); + } + + private function parseUseCount(mixed $raw): int + { + if ($raw === null) { + $this->throwMissingKeyException('useCount'); + } + + if (is_int($raw)) { + $value = $raw; + } elseif (is_string($raw) && preg_match('/^-?\d+$/', $raw)) { + $value = (int) $raw; + } else { + throw XrpcException::invalidParam( + $this->actionName, + 'useCount must be an integer', + is_scalar($raw) ? (string) $raw : '' + ); + } + + if ($value < 1) { + throw XrpcException::invalidParam( + $this->actionName, + 'useCount must be a positive integer', + (string) $value + ); + } + + return $value; + } + + private function parseForAccount(mixed $raw): string + { + if ($raw === null || $raw === '') { + return self::DEFAULT_FOR_ACCOUNT; + } + + if (!is_string($raw)) { + throw XrpcException::invalidParam( + $this->actionName, + 'forAccount must be a string', + is_scalar($raw) ? (string) $raw : '' + ); + } + + return $raw; + } +} diff --git a/src/Domain/Account/InviteCode/InviteCodeGenerator.php b/src/Domain/Account/InviteCode/InviteCodeGenerator.php new file mode 100644 index 0000000..aa821c9 --- /dev/null +++ b/src/Domain/Account/InviteCode/InviteCodeGenerator.php @@ -0,0 +1,34 @@ +-xxxxx-xxxxx`, + * where `` is the PDS hostname with `.`s replaced by `-`s and the + * suffix is two five-character lowercase base32 (RFC 4648, no padding) + * tokens. + */ +class InviteCodeGenerator +{ + public function __construct(private readonly string $hostname) + { + } + + public function generate(): string + { + return str_replace('.', '-', $this->hostname) . '-' . $this->randomToken(); + } + + private function randomToken(): string + { + $chars = ''; + for ($i = 0; $i < 10; $i++) { + $chars .= Base32::ALPHABET[random_int(0, 31)]; + } + return substr($chars, 0, 5) . '-' . substr($chars, 5, 5); + } +} diff --git a/src/Domain/Common/Base32.php b/src/Domain/Common/Base32.php new file mode 100644 index 0000000..ab49472 --- /dev/null +++ b/src/Domain/Common/Base32.php @@ -0,0 +1,87 @@ += 5) { + $bits -= 5; + $result .= self::ALPHABET[($value >> $bits) & 0x1f]; + } + } + + if ($bits > 0) { + $result .= self::ALPHABET[($value << (5 - $bits)) & 0x1f]; + } + + return $result; + } + + /** + * Decode a base32 lowercase string (no padding) into bytes. + * + * @throws InvalidArgumentException for invalid characters + */ + public static function decode(string $encoded): string + { + /** @var array|null $table */ + static $table = null; + + if ($table === null) { + $table = []; + for ($i = 0; $i < 32; $i++) { + $table[self::ALPHABET[$i]] = $i; + } + } + + $result = ''; + $bits = 0; + $value = 0; + $len = strlen($encoded); + + for ($i = 0; $i < $len; $i++) { + $c = $encoded[$i]; + + if (!isset($table[$c])) { + throw new InvalidArgumentException("Invalid base32 character: '{$c}'"); + } + + $value = ($value << 5) | $table[$c]; + $bits += 5; + + if ($bits >= 8) { + $bits -= 8; + $result .= chr(($value >> $bits) & 0xff); + } + } + + return $result; + } +} diff --git a/src/Domain/Pds/Atproto/Server/CreateInviteCodeResponse.php b/src/Domain/Pds/Atproto/Server/CreateInviteCodeResponse.php new file mode 100644 index 0000000..c49e033 --- /dev/null +++ b/src/Domain/Pds/Atproto/Server/CreateInviteCodeResponse.php @@ -0,0 +1,27 @@ +code; + } + + /** + * @return array + */ + public function jsonSerialize(): array + { + return ['code' => $this->code]; + } +} diff --git a/src/Domain/Repo/CidUtil.php b/src/Domain/Repo/CidUtil.php index ff09e8e..6fd2952 100644 --- a/src/Domain/Repo/CidUtil.php +++ b/src/Domain/Repo/CidUtil.php @@ -4,6 +4,7 @@ declare(strict_types=1); namespace App\Domain\Repo; +use App\Domain\Common\Base32; use InvalidArgumentException; /** @@ -21,8 +22,6 @@ use InvalidArgumentException; */ final class CidUtil { - private const ALPHABET = 'abcdefghijklmnopqrstuvwxyz234567'; - /** CIDv1 / dag-cbor / sha2-256 prefix (4 bytes). */ private const CID_PREFIX = "\x01\x71\x12\x20"; @@ -34,7 +33,7 @@ final class CidUtil $hash = hash('sha256', $cborBytes, true); $raw = self::CID_PREFIX . $hash; - return 'b' . self::base32Lower($raw); + return 'b' . Base32::encode($raw); } /** @@ -92,7 +91,7 @@ final class CidUtil ); } - return self::base32LowerDecode(substr($cid, 1)); + return Base32::decode(substr($cid, 1)); } /** @@ -100,74 +99,6 @@ final class CidUtil */ public static function fromRawBytes(string $rawBytes): string { - return 'b' . self::base32Lower($rawBytes); - } - - /** - * Calculates the base32 lowercase encoding of the given bytes, without padding. - */ - private static function base32Lower(string $bytes): string - { - $result = ''; - $bits = 0; - $value = 0; - $len = strlen($bytes); - - for ($i = 0; $i < $len; $i++) { - $value = ($value << 8) | ord($bytes[$i]); - $bits += 8; - - while ($bits >= 5) { - $bits -= 5; - $result .= self::ALPHABET[($value >> $bits) & 0x1f]; - } - } - - if ($bits > 0) { - $result .= self::ALPHABET[($value << (5 - $bits)) & 0x1f]; - } - - return $result; - } - - /** - * Decodes a base32 lowercase string into bytes. - * - * @throws InvalidArgumentException for invalid characters - */ - private static function base32LowerDecode(string $encoded): string - { - /** @var array|null $table */ - static $table = null; - - if ($table === null) { - $table = []; - for ($i = 0; $i < 32; $i++) { - $table[self::ALPHABET[$i]] = $i; - } - } - - $result = ''; - $bits = 0; - $value = 0; - $len = strlen($encoded); - - for ($i = 0; $i < $len; $i++) { - $c = $encoded[$i]; - - if (!isset($table[$c])) { - throw new InvalidArgumentException("Invalid base32 character: '{$c}'"); - } - - $value = ($value << 5) | $table[$c]; - $bits += 5; - - if ($bits >= 8) { - $bits -= 8; - $result .= chr(($value >> $bits) & 0xff); - } - } - - return $result; + return 'b' . Base32::encode($rawBytes); } } diff --git a/tests/Application/Actions/Pds/Atproto/Server/CreateInviteCodeActionTest.php b/tests/Application/Actions/Pds/Atproto/Server/CreateInviteCodeActionTest.php new file mode 100644 index 0000000..835c4f0 --- /dev/null +++ b/tests/Application/Actions/Pds/Atproto/Server/CreateInviteCodeActionTest.php @@ -0,0 +1,118 @@ +prophesize(LoggerInterface::class)->reveal(); + $settings = new Settings(['pds' => ['hostname' => 'pds.test']]); + $generator = new InviteCodeGenerator('pds.test'); + return new CreateInviteCodeAction($logger, $settings, $repo, $generator); + } + + /** + * @param array $body + */ + private function invoke(CreateInviteCodeAction $action, array $body): ResponseInterface + { + $request = $this->createRequest('POST', '/xrpc/com.atproto.server.createInviteCode') + ->withParsedBody($body); + $response = (new ResponseFactory())->createResponse(); + return $action($request, $response, []); + } + + public function testCreatesInviteCodeWithDefaultForAccount(): void + { + $repo = $this->prophesize(InviteCodeRepository::class); + $repo->save(Argument::that(function (InviteCode $code): bool { + return preg_match('/^pds-test-[a-z2-7]{5}-[a-z2-7]{5}$/', $code->getCode()) === 1 + && $code->getAvailableUses() === 1 + && $code->getForAccount() === 'admin' + && $code->getCreatedBy() === 'admin' + && $code->isDisabled() === false; + }))->shouldBeCalledOnce(); + + $response = $this->invoke($this->makeAction($repo->reveal()), ['useCount' => 1]); + + $this->assertSame(200, $response->getStatusCode()); + $this->assertSame('application/json', $response->getHeaderLine('Content-Type')); + + $payload = json_decode((string) $response->getBody(), true); + $this->assertIsArray($payload); + $code = $payload['code']; + $this->assertIsString($code); + $this->assertMatchesRegularExpression('/^pds-test-[a-z2-7]{5}-[a-z2-7]{5}$/', $code); + } + + public function testRespectsExplicitForAccount(): void + { + $repo = $this->prophesize(InviteCodeRepository::class); + $repo->save(Argument::that(function (InviteCode $code): bool { + return $code->getForAccount() === 'did:plc:alice' && $code->getAvailableUses() === 3; + }))->shouldBeCalledOnce(); + + $response = $this->invoke($this->makeAction($repo->reveal()), [ + 'useCount' => 3, + 'forAccount' => 'did:plc:alice', + ]); + + $this->assertSame(200, $response->getStatusCode()); + } + + public function testMissingUseCountReturnsInvalidRequest(): void + { + $repo = $this->prophesize(InviteCodeRepository::class); + $repo->save(Argument::any())->shouldNotBeCalled(); + + try { + $this->invoke($this->makeAction($repo->reveal()), []); + $this->fail('Expected XrpcException'); + } catch (XrpcException $e) { + $this->assertSame('InvalidRequest', $e->getError()); + $this->assertStringContainsString('useCount', $e->getMessage()); + } + } + + public function testNonIntegerUseCountIsRejected(): void + { + $repo = $this->prophesize(InviteCodeRepository::class); + $repo->save(Argument::any())->shouldNotBeCalled(); + + $this->expectException(XrpcException::class); + $this->invoke($this->makeAction($repo->reveal()), ['useCount' => 'banana']); + } + + public function testZeroUseCountIsRejected(): void + { + $repo = $this->prophesize(InviteCodeRepository::class); + $repo->save(Argument::any())->shouldNotBeCalled(); + + $this->expectException(XrpcException::class); + $this->invoke($this->makeAction($repo->reveal()), ['useCount' => 0]); + } + + public function testNonStringForAccountIsRejected(): void + { + $repo = $this->prophesize(InviteCodeRepository::class); + $repo->save(Argument::any())->shouldNotBeCalled(); + + $this->expectException(XrpcException::class); + $this->invoke($this->makeAction($repo->reveal()), ['useCount' => 1, 'forAccount' => 42]); + } +} diff --git a/tests/Domain/Account/InviteCode/InviteCodeGeneratorTest.php b/tests/Domain/Account/InviteCode/InviteCodeGeneratorTest.php new file mode 100644 index 0000000..9e8f9a4 --- /dev/null +++ b/tests/Domain/Account/InviteCode/InviteCodeGeneratorTest.php @@ -0,0 +1,32 @@ +generate(); + + $this->assertMatchesRegularExpression( + '/^pds-example-com-[a-z2-7]{5}-[a-z2-7]{5}$/', + $code + ); + } + + public function testGeneratedCodesAreUniqueAcrossInvocations(): void + { + $generator = new InviteCodeGenerator('pds.test'); + $codes = []; + for ($i = 0; $i < 25; $i++) { + $codes[] = $generator->generate(); + } + $this->assertCount(25, array_unique($codes)); + } +} diff --git a/tests/Domain/Common/Base32Test.php b/tests/Domain/Common/Base32Test.php new file mode 100644 index 0000000..97ed717 --- /dev/null +++ b/tests/Domain/Common/Base32Test.php @@ -0,0 +1,95 @@ +assertSame('abcdefghijklmnopqrstuvwxyz234567', Base32::ALPHABET); + $this->assertSame(32, strlen(Base32::ALPHABET)); + } + + public function testEncodeEmptyStringReturnsEmpty(): void + { + $this->assertSame('', Base32::encode('')); + } + + public function testDecodeEmptyStringReturnsEmpty(): void + { + $this->assertSame('', Base32::decode('')); + } + + /** + * @return array + */ + public static function rfc4648VectorsProvider(): array + { + // RFC 4648 ยง10 test vectors, lowercased and stripped of padding. + return [ + 'f' => ['f', 'my'], + 'fo' => ['fo', 'mzxq'], + 'foo' => ['foo', 'mzxw6'], + 'foob' => ['foob', 'mzxw6yq'], + 'fooba' => ['fooba', 'mzxw6ytb'], + 'foobar' => ['foobar', 'mzxw6ytboi'], + ]; + } + + /** + * @dataProvider rfc4648VectorsProvider + */ + #[DataProvider('rfc4648VectorsProvider')] + public function testEncodeMatchesRfc4648Vectors(string $plain, string $encoded): void + { + $this->assertSame($encoded, Base32::encode($plain)); + } + + /** + * @dataProvider rfc4648VectorsProvider + */ + #[DataProvider('rfc4648VectorsProvider')] + public function testDecodeMatchesRfc4648Vectors(string $plain, string $encoded): void + { + $this->assertSame($plain, Base32::decode($encoded)); + } + + public function testRoundtripPreservesBinaryBytes(): void + { + $bytes = random_bytes(64); + $this->assertSame($bytes, Base32::decode(Base32::encode($bytes))); + } + + public function testEncodeUsesOnlyAlphabetCharacters(): void + { + $encoded = Base32::encode(random_bytes(128)); + $this->assertSame( + strlen($encoded), + strspn($encoded, Base32::ALPHABET) + ); + } + + public function testDecodeRejectsInvalidCharacter(): void + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage("Invalid base32 character: '1'"); + Base32::decode('mzxw1'); // '1' is not in the RFC 4648 alphabet + } + + public function testDecodeRejectsUppercase(): void + { + // The codec is strictly lowercase; uppercase letters must not decode. + $this->expectException(InvalidArgumentException::class); + Base32::decode('MZXW6'); + } +} diff --git a/tests/bootstrap.php b/tests/bootstrap.php index bcf88c0..ea2caf3 100644 --- a/tests/bootstrap.php +++ b/tests/bootstrap.php @@ -1,7 +1,6 @@