diff --git a/README.md b/README.md index 29b0fd0..c9e23a5 100644 --- a/README.md +++ b/README.md @@ -384,6 +384,7 @@ MIT License | GET /v1/console/card-template-pairs | `console->listPassTemplatePairs()` | Y | | POST /v1/console/card-template-pairs | `console->createPassTemplatePair()` | Y | | POST /v1/console/card-templates/{id}/ios_preflight | `console->iosPreflight()` | Y | +| POST /v1/console/card-templates/{id}/publish | `console->publishTemplate()` | Y | | GET /v1/console/ledger-items | `console->ledgerItems()` | Y | | GET /v1/console/landing-pages | `console->listLandingPages()` | Y | | POST /v1/console/landing-pages | `console->createLandingPage()` | Y | diff --git a/src/AccessGridClient.php b/src/AccessGridClient.php index f5daca1..658b937 100644 --- a/src/AccessGridClient.php +++ b/src/AccessGridClient.php @@ -11,6 +11,8 @@ class AccessGridClient { + public const VERSION = '1.2.0'; + private string $accountId; private string $secretKey; private string $baseUrl; @@ -105,44 +107,58 @@ public function makeRequest(string $method, string $endpoint, ?array $data = nul $payload = !empty($data) ? json_encode($data) : ''; } - // Generate signature - $signature = $this->generateSignature($payload); - - $headers = [ - 'X-ACCT-ID: ' . $this->accountId, - 'X-PAYLOAD-SIG: ' . $signature, - 'Content-Type: application/json', - 'User-Agent: accessgrid-php @ v1.0.0' - ]; - // For requests with empty bodies (GET or action endpoints like unlink/suspend/resume), - // we need to include the sig_payload parameter + // include the ID payload in sig_payload query param if ($method === 'GET' || $method === 'DELETE' || ($method === 'POST' && empty($data))) { if ($params === null) { $params = []; } - // Include the ID payload in the query params if ($resourceId) { $params['sig_payload'] = json_encode(['id' => $resourceId]); } } - // Build final URL with query parameters - $finalUrl = $url; - if (!empty($params)) { - $queryString = http_build_query($params); - $separator = strpos($url, '?') !== false ? '&' : '?'; - $finalUrl = $url . $separator . $queryString; - } - // Build request body for POST/PUT/PATCH $requestBody = null; if (!empty($data) && $method !== 'GET') { $requestBody = json_encode($data); } - // Delegate to HTTP client - $response = $this->httpClient->send($method, $finalUrl, $headers, $requestBody); + return $this->executeSignedRequest($method, $url, $requestBody, $payload, $params ?? []); + } + + /** + * Sign a payload, build headers, send the request, and decode the response. + * Single source of truth for the wire-level signed-request behavior. + */ + private function executeSignedRequest( + string $method, + string $url, + ?string $body, + string $payloadToSign, + array $params = [] + ): array { + $signature = $this->generateSignature($payloadToSign); + + $headers = [ + 'X-ACCT-ID: ' . $this->accountId, + 'X-PAYLOAD-SIG: ' . $signature, + 'Content-Type: application/json', + 'User-Agent: accessgrid-php @ v' . self::VERSION, + ]; + + $finalUrl = $url; + if (!empty($params)) { + $separator = strpos($url, '?') !== false ? '&' : '?'; + $finalUrl = $url . $separator . http_build_query($params); + } + + $response = $this->httpClient->send($method, $finalUrl, $headers, $body); + return $this->decodeResponse($response); + } + + private function decodeResponse(\AccessGrid\Http\HttpResponse $response): array + { $httpCode = $response->getStatusCode(); $responseBody = $response->getBody(); diff --git a/src/Models/Template.php b/src/Models/Template.php index 9f5a36e..bb63df5 100644 --- a/src/Models/Template.php +++ b/src/Models/Template.php @@ -21,6 +21,8 @@ class Template public ?array $termsSettings; public ?array $styleSettings; public ?array $metadata; + public ?array $credentialProfiles; + public ?array $landingPages; // Convenience accessors (snake_case aliases) public ?string $use_case; @@ -28,6 +30,8 @@ class Template public ?string $last_published_at; public ?int $issued_keys_count; public ?int $active_keys_count; + public ?array $credential_profiles; + public ?array $landing_pages; // Convenience accessors extracted from nested objects public ?bool $allow_on_multiple_devices; @@ -51,6 +55,8 @@ public function __construct(AccessGridClient $client, array $data) $this->termsSettings = $data['terms_settings'] ?? null; $this->styleSettings = $data['style_settings'] ?? null; $this->metadata = $data['metadata'] ?? null; + $this->credentialProfiles = $data['credential_profiles'] ?? null; + $this->landingPages = $data['landing_pages'] ?? null; // snake_case aliases $this->use_case = $this->useCase; @@ -58,6 +64,8 @@ public function __construct(AccessGridClient $client, array $data) $this->last_published_at = $this->lastPublishedAt; $this->issued_keys_count = $this->issuedKeysCount; $this->active_keys_count = $this->activeKeysCount; + $this->credential_profiles = $this->credentialProfiles; + $this->landing_pages = $this->landingPages; // Convenience accessors from nested objects $this->allow_on_multiple_devices = $this->allowedDeviceCounts['allow_on_multiple_devices'] ?? null; diff --git a/src/Services/Console.php b/src/Services/Console.php index b6cb219..e231364 100644 --- a/src/Services/Console.php +++ b/src/Services/Console.php @@ -53,6 +53,19 @@ public function readTemplate(array $data): Template return new Template($this->client, $response); } + /** + * Publish a card template + */ + public function publishTemplate(array $data): object + { + $templateId = $data['card_template_id']; + $response = $this->client->post( + "/v1/console/card-templates/{$templateId}/publish", + ['card_template_id' => $templateId] + ); + return (object) $response; + } + /** * Get event logs for a card template */ diff --git a/tests/Models/TemplateTest.php b/tests/Models/TemplateTest.php index dbaa5c2..dc69fc1 100644 --- a/tests/Models/TemplateTest.php +++ b/tests/Models/TemplateTest.php @@ -103,4 +103,40 @@ public function testNullableArrayProperties(): void $this->assertNull($template->termsSettings); $this->assertNull($template->styleSettings); } + + public function testCredentialProfilesAndLandingPagesPresent(): void + { + $template = new Template($this->client, [ + 'credential_profiles' => ['cp_1', 'cp_2'], + 'landing_pages' => ['lp_1'], + ]); + + $this->assertEquals(['cp_1', 'cp_2'], $template->credentialProfiles); + $this->assertEquals(['lp_1'], $template->landingPages); + + // snake_case aliases + $this->assertEquals(['cp_1', 'cp_2'], $template->credential_profiles); + $this->assertEquals(['lp_1'], $template->landing_pages); + } + + public function testCredentialProfilesAndLandingPagesEmpty(): void + { + $template = new Template($this->client, [ + 'credential_profiles' => [], + 'landing_pages' => [], + ]); + + $this->assertEquals([], $template->credentialProfiles); + $this->assertEquals([], $template->landingPages); + } + + public function testCredentialProfilesAndLandingPagesAbsent(): void + { + $template = new Template($this->client, []); + + $this->assertNull($template->credentialProfiles); + $this->assertNull($template->landingPages); + $this->assertNull($template->credential_profiles); + $this->assertNull($template->landing_pages); + } } diff --git a/tests/Services/ConsoleTest.php b/tests/Services/ConsoleTest.php index 44d85b8..609b65a 100644 --- a/tests/Services/ConsoleTest.php +++ b/tests/Services/ConsoleTest.php @@ -81,6 +81,138 @@ public function testReadTemplate(): void $this->assertEquals('#FFFFFF', $template->styleSettings['background_color']); } + public function testPublishTemplate(): void + { + $this->expectRequest('POST', '/v1/console/card-templates/tmpl_123/publish', 200, [ + 'id' => 'tmpl_123', + 'status' => 'in-review', + ]); + + $result = $this->client->console->publishTemplate(['card_template_id' => 'tmpl_123']); + + $this->assertIsObject($result); + $this->assertEquals('tmpl_123', $result->id); + $this->assertEquals('in-review', $result->status); + } + + public function testPublishTemplateAndroidReady(): void + { + $this->expectRequest('POST', '/v1/console/card-templates/tmpl_456/publish', 200, [ + 'id' => 'tmpl_456', + 'status' => 'ready', + ]); + + $result = $this->client->console->publishTemplate(['card_template_id' => 'tmpl_456']); + + $this->assertEquals('ready', $result->status); + } + + public function testPublishTemplateSignsCardTemplateIdPayload(): void + { + // Per API convention: action endpoints send {"": ""} body and sign it. + $expectedBody = '{"card_template_id":"tmpl_123"}'; + $expectedSig = hash_hmac('sha256', base64_encode($expectedBody), 'test-secret-key'); + + $this->mockHttpClient + ->expects($this->once()) + ->method('send') + ->with( + $this->equalTo('POST'), + $this->logicalAnd( + $this->stringContains('/v1/console/card-templates/tmpl_123/publish'), + $this->logicalNot($this->stringContains('sig_payload=')) + ), + $this->callback(function (array $headers) use ($expectedSig) { + return in_array('X-PAYLOAD-SIG: ' . $expectedSig, $headers, true); + }), + $this->equalTo($expectedBody) + ) + ->willReturn(new \AccessGrid\Http\HttpResponse(200, json_encode([ + 'id' => 'tmpl_123', + 'status' => 'in-review', + ]))); + + $this->client->console->publishTemplate(['card_template_id' => 'tmpl_123']); + } + + public function testCreateTemplateWithCredentialProfilesAndLandingPages(): void + { + $this->mockHttpClient + ->expects($this->once()) + ->method('send') + ->with( + $this->equalTo('POST'), + $this->stringContains('/v1/console/card-templates'), + $this->isType('array'), + $this->callback(function ($body) { + $decoded = json_decode($body, true); + return is_array($decoded) + && ($decoded['credential_profiles'] ?? null) === ['cp_1', 'cp_2'] + && ($decoded['landing_pages'] ?? null) === ['lp_1']; + }) + ) + ->willReturn(new \AccessGrid\Http\HttpResponse(200, json_encode([ + 'id' => 'tmpl_123', + 'name' => 'Employee Badge', + ]))); + + $this->client->console->createTemplate([ + 'name' => 'Employee Badge', + 'platform' => 'apple', + 'credential_profiles' => ['cp_1', 'cp_2'], + 'landing_pages' => ['lp_1'], + ]); + } + + public function testUpdateTemplateWithCredentialProfilesAndLandingPages(): void + { + $this->mockHttpClient + ->expects($this->once()) + ->method('send') + ->with( + $this->equalTo('PUT'), + $this->stringContains('/v1/console/card-templates/tmpl_123'), + $this->isType('array'), + $this->callback(function ($body) { + $decoded = json_decode($body, true); + return is_array($decoded) + && ($decoded['credential_profiles'] ?? null) === ['cp_1'] + && ($decoded['landing_pages'] ?? null) === [] + && !isset($decoded['card_template_id']); + }) + ) + ->willReturn(new \AccessGrid\Http\HttpResponse(200, json_encode([ + 'id' => 'tmpl_123', + 'name' => 'Updated Badge', + ]))); + + $this->client->console->updateTemplate([ + 'card_template_id' => 'tmpl_123', + 'name' => 'Updated Badge', + 'credential_profiles' => ['cp_1'], + 'landing_pages' => [], + ]); + } + + public function testReadTemplateExposesCredentialProfilesAndLandingPages(): void + { + $this->expectRequest('GET', '/v1/console/card-templates/tmpl_123', 200, [ + 'id' => 'tmpl_123', + 'name' => 'Employee Badge', + 'credential_profiles' => ['cp_1', 'cp_2'], + 'landing_pages' => ['lp_1'], + ]); + + $template = $this->client->console->readTemplate([ + 'card_template_id' => 'tmpl_123', + ]); + + $this->assertEquals(['cp_1', 'cp_2'], $template->credentialProfiles); + $this->assertEquals(['lp_1'], $template->landingPages); + $this->assertEquals(['cp_1', 'cp_2'], $template->credential_profiles); + $this->assertEquals(['lp_1'], $template->landing_pages); + } + public function testGetLogs(): void { $this->expectRequest('GET', '/v1/console/card-templates/tmpl_123/logs', 200, [