diff --git a/config/github-client.php b/config/github-client.php index f08e346..9c193f7 100644 --- a/config/github-client.php +++ b/config/github-client.php @@ -65,6 +65,29 @@ ], ], + /* + |-------------------------------------------------------------------------- + | GitHub App Configuration + |-------------------------------------------------------------------------- + | + | Configuration for GitHub App authentication. GitHub Apps provide + | enhanced security and granular permissions compared to OAuth apps. + */ + 'github_app' => [ + // App ID from your GitHub App settings + 'app_id' => env('GITHUB_APP_ID'), + + // Installation ID (optional, can be set per request) + 'installation_id' => env('GITHUB_APP_INSTALLATION_ID'), + + // Private key for signing JWT tokens + // Can be the key contents directly or a path to the key file + 'private_key' => env('GITHUB_APP_PRIVATE_KEY'), + + // Path to private key file (alternative to direct key) + 'private_key_path' => env('GITHUB_APP_PRIVATE_KEY_PATH'), + ], + /* |-------------------------------------------------------------------------- | Rate Limiting diff --git a/src/Auth/GitHubAppAuthentication.php b/src/Auth/GitHubAppAuthentication.php index 85f9c6f..3762cb1 100644 --- a/src/Auth/GitHubAppAuthentication.php +++ b/src/Auth/GitHubAppAuthentication.php @@ -16,6 +16,8 @@ class GitHubAppAuthentication implements AuthenticationStrategy private ?DateTimeImmutable $installationTokenExpiry = null; + private ?object $connector = null; + public function __construct( private readonly string $appId, private readonly string $privateKey, @@ -107,6 +109,10 @@ private function hasValidInstallationToken(): bool /** * Refresh the installation token. + * + * Note: This method requires the connector to be set via setConnector() + * before calling refresh(). The connector is typically injected when + * used with GithubConnector. */ private function refreshInstallationToken(): void { @@ -114,11 +120,54 @@ private function refreshInstallationToken(): void throw AuthenticationException::githubAppAuthFailed('Installation ID required for installation token'); } - // This would typically make an API call to GitHub to get an installation token - // For now, we'll throw an exception indicating this needs to be implemented - throw AuthenticationException::githubAppAuthFailed( - 'Installation token refresh not yet implemented. Use GitHub client to fetch installation tokens.', + // Import the required classes + $createTokenRequest = new \JordanPartridge\GithubClient\Requests\Installations\CreateAccessToken( + (int) $this->installationId, ); + + // Make the API call to get the installation token + // This will use JWT authentication (app-level) to get the installation token + try { + $response = $this->makeApiRequest($createTokenRequest); + + if (! $response->successful()) { + throw AuthenticationException::githubAppAuthFailed( + 'Failed to refresh installation token: ' . ($response->json('message') ?? 'Unknown error'), + ); + } + + $data = $response->json(); + $this->installationToken = $data['token']; + $this->installationTokenExpiry = new DateTimeImmutable($data['expires_at']); + } catch (\Exception $e) { + throw AuthenticationException::githubAppAuthFailed( + 'Failed to refresh installation token: ' . $e->getMessage(), + ); + } + } + + /** + * Make an API request (to be implemented by connector integration). + * + * @throws AuthenticationException + */ + private function makeApiRequest(object $request): mixed + { + if (! isset($this->connector)) { + throw AuthenticationException::githubAppAuthFailed( + 'Connector not set. Cannot refresh installation token without connector.', + ); + } + + return $this->connector->send($request); + } + + /** + * Set the connector for making API requests. + */ + public function setConnector(object $connector): void + { + $this->connector = $connector; } /** diff --git a/src/Connectors/GithubConnector.php b/src/Connectors/GithubConnector.php index 85681a0..052389e 100644 --- a/src/Connectors/GithubConnector.php +++ b/src/Connectors/GithubConnector.php @@ -2,6 +2,8 @@ namespace JordanPartridge\GithubClient\Connectors; +use JordanPartridge\GithubClient\Auth\AuthenticationStrategy; +use JordanPartridge\GithubClient\Auth\GitHubAppAuthentication; use JordanPartridge\GithubClient\Auth\TokenResolver; use JordanPartridge\GithubClient\Exceptions\ApiException; use JordanPartridge\GithubClient\Exceptions\AuthenticationException; @@ -30,15 +32,25 @@ class GithubConnector extends Connector protected ?string $token; protected ?string $tokenSource; + protected ?AuthenticationStrategy $authStrategy = null; /** * Create a new GitHub connector. * - * @param string|null $token Optional GitHub token. If null, will attempt to resolve from multiple sources. + * @param string|AuthenticationStrategy|null $token Token, auth strategy, or null to auto-resolve */ - public function __construct(?string $token = null) + public function __construct(string|AuthenticationStrategy|null $token = null) { - if ($token !== null) { + if ($token instanceof AuthenticationStrategy) { + $this->authStrategy = $token; + $this->token = null; + $this->tokenSource = $token->getType(); + + // Inject connector into auth strategy if it's a GitHub App + if ($token instanceof GitHubAppAuthentication) { + $token->setConnector($this); + } + } elseif ($token !== null) { $this->token = $token; $this->tokenSource = 'explicit'; } else { @@ -73,6 +85,23 @@ protected function defaultHeaders(): array */ protected function defaultAuth(): ?Authenticator { + // Use auth strategy if set + if ($this->authStrategy) { + // Check if token needs refresh + if ($this->authStrategy->needsRefresh()) { + $this->authStrategy->refresh(); + } + + // Get the authorization header value + $authHeader = $this->authStrategy->getAuthorizationHeader(); + + // Extract token from "Bearer " format + $token = str_replace('Bearer ', '', $authHeader); + + return new TokenAuthenticator($token); + } + + // Fall back to simple token authentication if (! $this->token || $this->token === '') { return null; } @@ -85,7 +114,7 @@ protected function defaultAuth(): ?Authenticator */ public function isAuthenticated(): bool { - return ! empty($this->token); + return $this->authStrategy !== null || ! empty($this->token); } /** diff --git a/src/Data/Installations/InstallationData.php b/src/Data/Installations/InstallationData.php new file mode 100644 index 0000000..cabd4ca --- /dev/null +++ b/src/Data/Installations/InstallationData.php @@ -0,0 +1,50 @@ + $this->id, + 'account_login' => $this->account_login, + 'account_type' => $this->account_type, + 'target_type' => $this->target_type, + 'permissions' => $this->permissions, + 'events' => $this->events, + 'created_at' => $this->created_at?->toISOString(), + 'updated_at' => $this->updated_at?->toISOString(), + 'app_slug' => $this->app_slug, + ], fn ($value) => $value !== null); + } +} diff --git a/src/Data/Installations/InstallationTokenData.php b/src/Data/Installations/InstallationTokenData.php new file mode 100644 index 0000000..1a25dba --- /dev/null +++ b/src/Data/Installations/InstallationTokenData.php @@ -0,0 +1,45 @@ + $this->token, + 'expires_at' => $this->expires_at->toISOString(), + 'permissions' => $this->permissions, + 'repository_selection' => $this->repository_selection, + ], fn ($value) => $value !== null); + } + + public function isExpired(): bool + { + return Carbon::now()->greaterThanOrEqualTo($this->expires_at); + } + + public function expiresIn(): int + { + return Carbon::now()->diffInSeconds($this->expires_at, false); + } +} diff --git a/src/Github.php b/src/Github.php index e976adc..326f897 100644 --- a/src/Github.php +++ b/src/Github.php @@ -8,10 +8,12 @@ use JordanPartridge\GithubClient\Exceptions\ApiException; use JordanPartridge\GithubClient\Exceptions\NetworkException; use JordanPartridge\GithubClient\Requests\RateLimit\Get; +use JordanPartridge\GithubClient\Auth\GitHubAppAuthentication; use JordanPartridge\GithubClient\Resources\ActionsResource; use JordanPartridge\GithubClient\Resources\CommentsResource; use JordanPartridge\GithubClient\Resources\CommitResource; use JordanPartridge\GithubClient\Resources\FileResource; +use JordanPartridge\GithubClient\Resources\InstallationsResource; use JordanPartridge\GithubClient\Resources\IssuesResource; use JordanPartridge\GithubClient\Resources\PullRequestResource; use JordanPartridge\GithubClient\Resources\ReleasesResource; @@ -72,6 +74,11 @@ public function releases(): ReleasesResource return new ReleasesResource($this); } + public function installations(): InstallationsResource + { + return new InstallationsResource($this); + } + /** * Get the current rate limit status for all resources. * @@ -170,4 +177,86 @@ public function deleteRepo(string $fullName): Response return $this->repos()->delete($repo); } + + /** + * Create a new GitHub client authenticated as a GitHub App installation. + * + * This creates a new instance configured to act on behalf of a specific + * installation, using installation tokens instead of JWT tokens. + * + * @param int $installationId The installation ID to authenticate as + * + * @return self A new Github instance authenticated for the installation + */ + public static function forInstallation(int $installationId): self + { + // Get GitHub App config + $appId = config('github-client.github_app.app_id'); + $privateKey = self::resolvePrivateKey(); + + if (! $appId || ! $privateKey) { + throw new \RuntimeException( + 'GitHub App not configured. Set GITHUB_APP_ID and GITHUB_APP_PRIVATE_KEY or GITHUB_APP_PRIVATE_KEY_PATH', + ); + } + + $auth = new GitHubAppAuthentication( + appId: $appId, + privateKey: $privateKey, + installationId: (string) $installationId, + ); + + $connector = new GithubConnector($auth); + + return new self($connector); + } + + /** + * Create a new GitHub client with custom GitHub App credentials. + * + * This allows using GitHub App authentication without relying on config files. + * + * @param string $appId The GitHub App ID + * @param string $privateKey The private key (PEM format or base64 encoded) + * @param int|null $installationId Optional installation ID + * + * @return self A new Github instance with GitHub App authentication + */ + public static function withApp(string $appId, string $privateKey, ?int $installationId = null): self + { + $auth = new GitHubAppAuthentication( + appId: $appId, + privateKey: $privateKey, + installationId: $installationId ? (string) $installationId : null, + ); + + $connector = new GithubConnector($auth); + + return new self($connector); + } + + /** + * Resolve the private key from config. + */ + private static function resolvePrivateKey(): ?string + { + // Try direct key first + $key = config('github-client.github_app.private_key'); + if ($key) { + return $key; + } + + // Try file path + $path = config('github-client.github_app.private_key_path'); + if ($path && file_exists($path)) { + return file_get_contents($path); + } + + // Try base_path for relative paths + if ($path && file_exists(base_path($path))) { + return file_get_contents(base_path($path)); + } + + return null; + } } diff --git a/src/Requests/Installations/CreateAccessToken.php b/src/Requests/Installations/CreateAccessToken.php new file mode 100644 index 0000000..b9402ea --- /dev/null +++ b/src/Requests/Installations/CreateAccessToken.php @@ -0,0 +1,46 @@ + $this->repositories, + 'permissions' => $this->permissions, + ], fn ($value) => $value !== null); + } + + public function createDtoFromResponse(Response $response): InstallationTokenData + { + return InstallationTokenData::fromArray($response->json()); + } + + public function resolveEndpoint(): string + { + return "/app/installations/{$this->installationId}/access_tokens"; + } +} diff --git a/src/Requests/Installations/GetInstallation.php b/src/Requests/Installations/GetInstallation.php new file mode 100644 index 0000000..02eea9d --- /dev/null +++ b/src/Requests/Installations/GetInstallation.php @@ -0,0 +1,27 @@ +json()); + } + + public function resolveEndpoint(): string + { + return "/app/installations/{$this->installationId}"; + } +} diff --git a/src/Requests/Installations/ListInstallations.php b/src/Requests/Installations/ListInstallations.php new file mode 100644 index 0000000..9c66676 --- /dev/null +++ b/src/Requests/Installations/ListInstallations.php @@ -0,0 +1,48 @@ +per_page !== null && ($this->per_page < 1 || $this->per_page > 100)) { + throw new InvalidArgumentException('Per page must be between 1 and 100'); + } + } + + protected function defaultQuery(): array + { + return array_filter([ + 'per_page' => $this->per_page, + 'page' => $this->page, + ], fn ($value) => $value !== null); + } + + public function createDtoFromResponse(Response $response): mixed + { + return array_map( + fn ($installation) => InstallationData::fromArray($installation), + $response->json(), + ); + } + + public function resolveEndpoint(): string + { + return '/app/installations'; + } +} diff --git a/src/Resources/InstallationsResource.php b/src/Resources/InstallationsResource.php new file mode 100644 index 0000000..165b71f --- /dev/null +++ b/src/Resources/InstallationsResource.php @@ -0,0 +1,122 @@ + Array of installation data objects + * + * @link https://docs.github.com/en/rest/apps/installations#list-installations-for-the-authenticated-app + */ + public function list(?int $per_page = null, ?int $page = null): array + { + $response = $this->connector()->send(new ListInstallations($per_page, $page)); + + return $response->dto(); + } + + /** + * Get details about a specific installation. + * + * Requires GitHub App authentication (JWT token). + * + * @param int $installationId The installation ID + * + * @return InstallationData The installation data + * + * @link https://docs.github.com/en/rest/apps/installations#get-an-installation-for-the-authenticated-app + */ + public function get(int $installationId): InstallationData + { + $response = $this->connector()->send(new GetInstallation($installationId)); + + return $response->dto(); + } + + /** + * Create an installation access token. + * + * Generates a new access token that can be used to make authenticated + * requests on behalf of the installation. Tokens expire after 1 hour. + * + * Requires GitHub App authentication (JWT token). + * + * @param int $installationId The installation ID + * @param array|null $repositories Optional array of repository names to limit access + * @param array|null $permissions Optional permissions to request + * + * @return InstallationTokenData The installation access token data + * + * @link https://docs.github.com/en/rest/apps/installations#create-an-installation-access-token-for-an-app + */ + public function createAccessToken( + int $installationId, + ?array $repositories = null, + ?array $permissions = null, + ): InstallationTokenData { + $response = $this->connector()->send( + new CreateAccessToken($installationId, $repositories, $permissions), + ); + + return $response->dto(); + } + + /** + * List all installations with automatic pagination. + * + * This method automatically fetches all installations across multiple pages. + * + * @param int|null $per_page Number of results per page (max 100, default 100) + * + * @return array Array of all installation data objects + */ + public function listAll(?int $per_page = 100): array + { + $page = 1; + $allInstallations = []; + $maxPages = 100; + + do { + if ($page > $maxPages) { + throw new \RuntimeException("Maximum page limit ($maxPages) exceeded during pagination"); + } + + $response = $this->connector()->send(new ListInstallations($per_page, $page)); + $installations = $response->dto(); + + if (! empty($installations)) { + $allInstallations = array_merge($allInstallations, $installations); + } + + $linkHeader = $response->header('Link'); + $hasNextPage = $linkHeader && str_contains($linkHeader, 'rel="next"'); + + $page++; + } while ($hasNextPage && ! empty($installations)); + + return $allInstallations; + } +} diff --git a/tests/Feature/GitHubAppAuthenticationTest.php b/tests/Feature/GitHubAppAuthenticationTest.php new file mode 100644 index 0000000..d5b1231 --- /dev/null +++ b/tests/Feature/GitHubAppAuthenticationTest.php @@ -0,0 +1,333 @@ +privateKey = <<<'KEY' +-----BEGIN RSA PRIVATE KEY----- +MIIEpAIBAAKCAQEA0Z3VS5JJcds3xfn/ygWyF8l0qBnX7l+4MNwMPkN6YODeFbRu +Z0p3AevFKhqiLVT6M3p6wTtQlKJPxjGPMd1YqV0wPG6NNJVFfLNpjt8pSfSiQ7cr +Gw2bqklqjR7lhY0lBF/2YY7v8wWPqfR7FLK7yLNVYC7H6FqN0qMBnJbEbHQk7i0+ +uNcm2Ql0cX2XpdjHqKfJ2QmKxKCb8v3HDNNnUU2P+rKxL0FwRLBXRfNRnBBMvVqf +Ll1gxYEGmVGQKWp0pVjJwPTbh8L8fRrSYfLvhNYVPgjNJ9j8UhOCiGfNJDDU4PQp +0SeFZnWPDwGqWVYcNGX5pYHGGqC5pxVJcFNkqQIDAQABAoIBACLQGLXxKiP5N7u+ +CY1L5KGmGQCyBpF3YJZfMx+eWRMNT5dTLhE4LPZM5GlKEQ3rqN3nJN7wKQPGsWqG +T7KOKGmMvXNSx8m2YRqT7WBEKb6nW1YR8nO8CxDXvYqBMgHCNJuT8K8IHHnPcKqN ++dQf0bxXq0YR7hBDqP6sV8gGPqHfBLv5wNl4P7+gGwWlJHkqLv5fJLnYFHKNBrMJ +2P7vqNjE7UQ8JH5qL8fKGpL7DqPNRJK2vP5HqBkJNQGPqNVL7M+Q8PxNgQP9LqYN +FHQqL7NJqPvKxHNL8Q6P7NqLvQH8PxQGLvN7JqPNFH5Q8LvP7NqLHQqP7NqLvQH8 +PxQGLvN7JqPNFHECgYEA7ZR3vN8m2xP5QqLvN7JqPNFHQqL7NqLvQH8PxQGLvN7J +qPNFHQqL7NqLvQH8PxQGLvN7JqPNFHQqL7NqLvQH8PxQGLvN7JqPNFHQqL7NqLvQ +H8PxQGLvN7JqPNFHQqL7NqLvQH8PxQGLvN7JqPNFHQqL7NqLvQH8PxQGLvN7JqPN +FHQqL7NqLvQH8CkCgYEA4YP5qLvN7JqPNFHQqL7NqLvQH8PxQGLvN7JqPNFHQqL7 +NqLvQH8PxQGLvN7JqPNFHQqL7NqLvQH8PxQGLvN7JqPNFHQqL7NqLvQH8PxQGLvN +7JqPNFHQqL7NqLvQH8PxQGLvN7JqPNFHQqL7NqLvQH8PxQGLvN7JqPNFHQqL7NqL +vQH8PxQGLvN7JqPNFHQCgYEA2P5qLvN7JqPNFHQqL7NqLvQH8PxQGLvN7JqPNFHQ +qL7NqLvQH8PxQGLvN7JqPNFHQqL7NqLvQH8PxQGLvN7JqPNFHQqL7NqLvQH8PxQG +LvN7JqPNFHQqL7NqLvQH8PxQGLvN7JqPNFHQqL7NqLvQH8PxQGLvN7JqPNFHQqL7 +NqLvQH8PxQGLvN7JqPNFHQkCgYBqLvN7JqPNFHQqL7NqLvQH8PxQGLvN7JqPNFHQ +qL7NqLvQH8PxQGLvN7JqPNFHQqL7NqLvQH8PxQGLvN7JqPNFHQqL7NqLvQH8PxQG +LvN7JqPNFHQqL7NqLvQH8PxQGLvN7JqPNFHQqL7NqLvQH8PxQGLvN7JqPNFHQqL7 +NqLvQH8PxQGLvN7JqPNFHQQKBgQC5qLvN7JqPNFHQqL7NqLvQH8PxQGLvN7JqPNF +HQqL7NqLvQH8PxQGLvN7JqPNFHQqL7NqLvQH8PxQGLvN7JqPNFHQqL7NqLvQH8Px +QGLvN7JqPNFHQqL7NqLvQH8PxQGLvN7JqPNFHQqL7NqLvQH8PxQGLvN7JqPNFHQq +L7NqLvQH8PxQGLvN7JqPNFHQ== +-----END RSA PRIVATE KEY----- +KEY; + }); + + it('validates GitHub App credentials', function () { + $auth = new GitHubAppAuthentication( + appId: '12345', + privateKey: $this->privateKey, + ); + + expect(fn () => $auth->validate())->not->toThrow(AuthenticationException::class); + }); + + it('throws exception for invalid app ID', function () { + $auth = new GitHubAppAuthentication( + appId: 'invalid', + privateKey: $this->privateKey, + ); + + expect(fn () => $auth->validate())->toThrow( + AuthenticationException::class, + 'App ID must be numeric', + ); + }); + + it('throws exception for empty private key', function () { + expect(fn () => new GitHubAppAuthentication( + appId: '12345', + privateKey: '', + ))->toThrow(AuthenticationException::class); + }); + + it('generates JWT token for app-level auth', function () { + $auth = new GitHubAppAuthentication( + appId: '12345', + privateKey: $this->privateKey, + ); + + $header = $auth->getAuthorizationHeader(); + + expect($header)->toStartWith('Bearer ') + ->and(strlen($header))->toBeGreaterThan(100); + }); + + it('returns installation token when set', function () { + $auth = new GitHubAppAuthentication( + appId: '12345', + privateKey: $this->privateKey, + installationId: '67890', + ); + + $expiry = new DateTimeImmutable('+1 hour'); + $auth->setInstallationToken('test_installation_token', $expiry); + + $header = $auth->getAuthorizationHeader(); + + expect($header)->toBe('Bearer test_installation_token'); + }); + + it('refreshes installation token when needed', function () { + $mockClient = new MockClient([ + MockResponse::make([ + 'token' => 'new_installation_token', + 'expires_at' => (new DateTimeImmutable('+1 hour'))->format('c'), + ]), + ]); + + $auth = new GitHubAppAuthentication( + appId: '12345', + privateKey: $this->privateKey, + installationId: '67890', + ); + + $connector = new GithubConnector($auth); + $connector->withMockClient($mockClient); + + expect($auth->needsRefresh())->toBeTrue(); + + $auth->refresh(); + + expect($auth->needsRefresh())->toBeFalse(); + }); + + it('integrates with GithubConnector', function () { + $auth = new GitHubAppAuthentication( + appId: '12345', + privateKey: $this->privateKey, + ); + + $connector = new GithubConnector($auth); + + expect($connector->isAuthenticated())->toBeTrue() + ->and($connector->getAuthenticationSource())->toBe('github_app'); + }); +}); + +describe('GitHub App Installations Resource', function () { + beforeEach(function () { + $this->mockClient = new MockClient(); + $this->privateKey = <<<'KEY' +-----BEGIN RSA PRIVATE KEY----- +MIIEpAIBAAKCAQEA0Z3VS5JJcds3xfn/ygWyF8l0qBnX7l+4MNwMPkN6YODeFbRu +Z0p3AevFKhqiLVT6M3p6wTtQlKJPxjGPMd1YqV0wPG6NNJVFfLNpjt8pSfSiQ7cr +Gw2bqklqjR7lhY0lBF/2YY7v8wWPqfR7FLK7yLNVYC7H6FqN0qMBnJbEbHQk7i0+ +uNcm2Ql0cX2XpdjHqKfJ2QmKxKCb8v3HDNNnUU2P+rKxL0FwRLBXRfNRnBBMvVqf +Ll1gxYEGmVGQKWp0pVjJwPTbh8L8fRrSYfLvhNYVPgjNJ9j8UhOCiGfNJDDU4PQp +0SeFZnWPDwGqWVYcNGX5pYHGGqC5pxVJcFNkqQIDAQABAoIBACLQGLXxKiP5N7u+ +CY1L5KGmGQCyBpF3YJZfMx+eWRMNT5dTLhE4LPZM5GlKEQ3rqN3nJN7wKQPGsWqG +KEY; + + $auth = new GitHubAppAuthentication( + appId: '12345', + privateKey: $this->privateKey, + ); + + $this->github = new Github(new GithubConnector($auth)); + $this->github->connector()->withMockClient($this->mockClient); + }); + + it('can list installations', function () { + $this->mockClient->addResponse(MockResponse::make([ + [ + 'id' => 1, + 'account' => [ + 'login' => 'octocat', + 'type' => 'User', + ], + 'target_type' => 'User', + 'permissions' => ['contents' => 'read'], + 'events' => ['push'], + 'created_at' => '2023-01-01T00:00:00Z', + 'updated_at' => '2023-01-02T00:00:00Z', + 'app_slug' => 'my-app', + ], + ])); + + $installations = $this->github->installations()->list(); + + expect($installations)->toHaveCount(1) + ->and($installations[0])->toBeInstanceOf(InstallationData::class) + ->and($installations[0]->id)->toBe(1) + ->and($installations[0]->account_login)->toBe('octocat'); + }); + + it('can get a specific installation', function () { + $this->mockClient->addResponse(MockResponse::make([ + 'id' => 1, + 'account' => [ + 'login' => 'octocat', + 'type' => 'User', + ], + 'target_type' => 'User', + 'permissions' => ['contents' => 'read'], + 'events' => ['push'], + 'created_at' => '2023-01-01T00:00:00Z', + 'updated_at' => '2023-01-02T00:00:00Z', + 'app_slug' => 'my-app', + ])); + + $installation = $this->github->installations()->get(1); + + expect($installation)->toBeInstanceOf(InstallationData::class) + ->and($installation->id)->toBe(1) + ->and($installation->account_login)->toBe('octocat'); + }); + + it('can create installation access token', function () { + $this->mockClient->addResponse(MockResponse::make([ + 'token' => 'ghs_installationtoken', + 'expires_at' => '2023-01-01T01:00:00Z', + 'permissions' => ['contents' => 'read'], + 'repository_selection' => 'all', + ])); + + $token = $this->github->installations()->createAccessToken(1); + + expect($token)->toBeInstanceOf(InstallationTokenData::class) + ->and($token->token)->toBe('ghs_installationtoken') + ->and($token->repository_selection)->toBe('all'); + }); + + it('handles pagination when listing all installations', function () { + // First page + $this->mockClient->addResponse( + MockResponse::make([ + ['id' => 1, 'account' => ['login' => 'user1', 'type' => 'User']], + ['id' => 2, 'account' => ['login' => 'user2', 'type' => 'User']], + ])->withHeader('Link', '; rel="next"'), + ); + + // Second page + $this->mockClient->addResponse( + MockResponse::make([ + ['id' => 3, 'account' => ['login' => 'user3', 'type' => 'User']], + ]), + ); + + $installations = $this->github->installations()->listAll(2); + + expect($installations)->toHaveCount(3) + ->and($installations[0]->id)->toBe(1) + ->and($installations[2]->id)->toBe(3); + }); +}); + +describe('GitHub App Helper Methods', function () { + it('creates client for installation', function () { + config([ + 'github-client.github_app.app_id' => '12345', + 'github-client.github_app.private_key' => <<<'KEY' +-----BEGIN RSA PRIVATE KEY----- +MIIEpAIBAAKCAQEA0Z3VS5JJcds3xfn/ygWyF8l0qBnX7l+4MNwMPkN6YODeFbRu +Z0p3AevFKhqiLVT6M3p6wTtQlKJPxjGPMd1YqV0wPG6NNJVFfLNpjt8pSfSiQ7cr +KEY, + ]); + + $github = Github::forInstallation(67890); + + expect($github)->toBeInstanceOf(Github::class) + ->and($github->connector()->isAuthenticated())->toBeTrue(); + }); + + it('creates client with custom app credentials', function () { + $privateKey = <<<'KEY' +-----BEGIN RSA PRIVATE KEY----- +MIIEpAIBAAKCAQEA0Z3VS5JJcds3xfn/ygWyF8l0qBnX7l+4MNwMPkN6YODeFbRu +Z0p3AevFKhqiLVT6M3p6wTtQlKJPxjGPMd1YqV0wPG6NNJVFfLNpjt8pSfSiQ7cr +KEY; + + $github = Github::withApp('12345', $privateKey, 67890); + + expect($github)->toBeInstanceOf(Github::class) + ->and($github->connector()->isAuthenticated())->toBeTrue(); + }); + + it('throws exception when app not configured', function () { + config([ + 'github-client.github_app.app_id' => null, + 'github-client.github_app.private_key' => null, + ]); + + expect(fn () => Github::forInstallation(67890)) + ->toThrow(RuntimeException::class, 'GitHub App not configured'); + }); +}); + +describe('InstallationTokenData', function () { + it('can check if token is expired', function () { + $expiredToken = new InstallationTokenData( + token: 'test_token', + expires_at: \Carbon\Carbon::now()->subHour(), + ); + + $validToken = new InstallationTokenData( + token: 'test_token', + expires_at: \Carbon\Carbon::now()->addHour(), + ); + + expect($expiredToken->isExpired())->toBeTrue() + ->and($validToken->isExpired())->toBeFalse(); + }); + + it('can calculate time until expiry', function () { + $token = new InstallationTokenData( + token: 'test_token', + expires_at: \Carbon\Carbon::now()->addMinutes(30), + ); + + $expiresIn = $token->expiresIn(); + + expect($expiresIn)->toBeGreaterThan(1700) + ->and($expiresIn)->toBeLessThan(1900); + }); + + it('converts to array correctly', function () { + $token = new InstallationTokenData( + token: 'test_token', + expires_at: \Carbon\Carbon::parse('2023-01-01T01:00:00Z'), + permissions: ['contents' => 'read'], + repository_selection: 'all', + ); + + $array = $token->toArray(); + + expect($array)->toHaveKey('token') + ->and($array)->toHaveKey('expires_at') + ->and($array)->toHaveKey('permissions') + ->and($array['token'])->toBe('test_token'); + }); +});