Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
Expand Down
60 changes: 38 additions & 22 deletions src/AccessGridClient.php
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,8 @@

class AccessGridClient
{
public const VERSION = '1.2.0';

private string $accountId;
private string $secretKey;
private string $baseUrl;
Expand Down Expand Up @@ -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();

Expand Down
8 changes: 8 additions & 0 deletions src/Models/Template.php
Original file line number Diff line number Diff line change
Expand Up @@ -21,13 +21,17 @@ 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;
public ?string $created_at;
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;
Expand All @@ -51,13 +55,17 @@ 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;
$this->created_at = $this->createdAt;
$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;
Expand Down
13 changes: 13 additions & 0 deletions src/Services/Console.php
Original file line number Diff line number Diff line change
Expand Up @@ -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
*/
Expand Down
36 changes: 36 additions & 0 deletions tests/Models/TemplateTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
}
132 changes: 132 additions & 0 deletions tests/Services/ConsoleTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -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 {"<id_key>": "<id>"} 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, [
Expand Down
Loading