diff --git a/composer.json b/composer.json index f033ec4..aa74856 100644 --- a/composer.json +++ b/composer.json @@ -34,7 +34,7 @@ "phpstan/phpstan": "^1.0", "phpstan/phpstan-phpunit": "^1.0", "phpstan/phpstan-strict-rules": "^1.0", - "phpunit/phpunit": "^9.3 || ^10.0", + "phpunit/phpunit": "^10.0", "squizlabs/php_codesniffer": "^3.5" }, "conflict": { diff --git a/phpunit.xml b/phpunit.xml index 7be976b..41d88c5 100644 --- a/phpunit.xml +++ b/phpunit.xml @@ -1,22 +1,20 @@ + cacheDirectory=".phpunit.cache" + requireCoverageMetadata="true" + beStrictAboutCoverageMetadata="true"> tests - - + src diff --git a/src/Credential.php b/src/Credential.php index 27c1994..5a56957 100644 --- a/src/Credential.php +++ b/src/Credential.php @@ -4,13 +4,59 @@ namespace SnapAuth; +use DateTimeImmutable; + class Credential { public readonly string $id; + public readonly string $aaguid; + public readonly string $name; + public readonly bool $isActive; + public readonly bool $isBackedUp; + public readonly bool $isBackupEligible; + public readonly bool $isUvInitialized; + public readonly DateTimeImmutable $createdAt; + /** + * @var WebAuthn\AuthenticatorTransport[] + */ + public readonly array $transports; - // @phpstan-ignore-next-line + /** + * @internal The Credential object is part of the SDK, but its constructor + * is not part of SemVer scope. + * + * @phpstan-ignore-next-line + * + * This is the correct shape: + * param array{ + * id: string, + * aaguid: string, + * name: string, + * isActive: bool, + * isBackedUp: bool, + * isBackupEligible: bool, + * isUvInitialized: bool, + * createdAt: int, + * transports: string[], + * } $data + */ public function __construct(array $data) { $this->id = $data['id']; + $this->aaguid = $data['aaguid']; + $this->name = $data['name']; + $this->isActive = $data['isActive']; + $this->isBackedUp = $data['isBackedUp']; + $this->isBackupEligible = $data['isBackupEligible']; + $this->isUvInitialized = $data['isUvInitialized']; + + $this->createdAt = (new DateTimeImmutable())->setTimestamp($data['createdAt']); + + // Ensure array_is_list if anything is filtered + $this->transports = array_values(array_filter( + // If other transport methods are added on the API (which itself + // requires a WebAuthn spec bump), filter out unknown values + array_map(WebAuthn\AuthenticatorTransport::tryFrom(...), $data['transports']) + )); } } diff --git a/src/WebAuthn/AuthenticatorTransport.php b/src/WebAuthn/AuthenticatorTransport.php new file mode 100644 index 0000000..54bf36b --- /dev/null +++ b/src/WebAuthn/AuthenticatorTransport.php @@ -0,0 +1,42 @@ +readFixture('credential1.json'); + $cred = new Credential($data); + + self::assertSame('ctl_2893f2Vg86463c8xV7wVv5PG', $cred->id); + self::assertSame('fbfc3007-154e-4ecc-8c0b-6e020557d7bd', $cred->aaguid); + self::assertTrue($cred->isActive); + self::assertTrue($cred->isBackedUp); + self::assertTrue($cred->isBackupEligible); + self::assertTrue($cred->isUvInitialized); + self::assertSame('iCloud Keychain', $cred->name); + self::assertSame([ + WebAuthn\AuthenticatorTransport::Hybrid, + WebAuthn\AuthenticatorTransport::Internal, + ], $cred->transports); + self::assertEquals(new DateTimeImmutable('2024-03-07T20:02:04Z'), $cred->createdAt); + } + + public function testDecodingUsbFromApiResponse(): void + { + $data = $this->readFixture('credential2.json'); + $cred = new Credential($data); + + self::assertSame('ctl_28CWCw4G3R4MGCg2cc2ccvGr', $cred->id); + self::assertSame('00000000-0000-0000-0000-000000000000', $cred->aaguid); + self::assertTrue($cred->isActive); + self::assertFalse($cred->isBackedUp); + self::assertFalse($cred->isBackupEligible); + self::assertFalse($cred->isUvInitialized); + self::assertSame('Passkey', $cred->name); + self::assertSame([WebAuthn\AuthenticatorTransport::Usb], $cred->transports); + self::assertEquals(new DateTimeImmutable('2024-08-05T21:35:48Z'), $cred->createdAt); + } + + /** + * @return mixed[] + */ + private function readFixture(string $path): array + { + $path = sprintf('%s/%s/%s', __DIR__, 'fixtures', $path); + $json = file_get_contents($path); + assert($json !== false); + $data = json_decode($json, true, flags: JSON_THROW_ON_ERROR); + assert(is_array($data)); + return $data; + } +} diff --git a/tests/fixtures/credential1.json b/tests/fixtures/credential1.json new file mode 100644 index 0000000..9b39d14 --- /dev/null +++ b/tests/fixtures/credential1.json @@ -0,0 +1,15 @@ +{ + "id": "ctl_2893f2Vg86463c8xV7wVv5PG", + "aaguid": "fbfc3007-154e-4ecc-8c0b-6e020557d7bd", + "isActive": true, + "isBackedUp": true, + "isBackupEligible": true, + "isUvInitialized": true, + "name": "iCloud Keychain", + "transports": [ + "hybrid", + "unknown_ignoreme", + "internal" + ], + "createdAt": 1709841724 +} diff --git a/tests/fixtures/credential2.json b/tests/fixtures/credential2.json new file mode 100644 index 0000000..7143e7c --- /dev/null +++ b/tests/fixtures/credential2.json @@ -0,0 +1,13 @@ +{ + "id": "ctl_28CWCw4G3R4MGCg2cc2ccvGr", + "aaguid": "00000000-0000-0000-0000-000000000000", + "isActive": true, + "isBackedUp": false, + "isBackupEligible": false, + "isUvInitialized": false, + "name": "Passkey", + "transports": [ + "usb" + ], + "createdAt": 1722893748 +}