From a8b8c785492cf36f8c7c585e4fe1c1b4de681f6c Mon Sep 17 00:00:00 2001 From: Vladimir Kuznichenkov Date: Thu, 20 Aug 2026 11:19:54 +0300 Subject: [PATCH 1/2] Add encrypted incoming webhook secrets MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Let administrators create and revoke shared secrets for incoming webhooks. Encrypt secret values at rest with Packeton’s existing encrypted_text DBAL type, reveal generated values only once, and use Symfony form validation and CSRF protection for management actions. --- config/services.yaml | 1 + src/Controller/WebhookSecretController.php | 76 ++++++++++++++++ src/Entity/WebhookSecret.php | 86 +++++++++++++++++++ src/Form/Type/WebhookSecretType.php | 35 ++++++++ src/Menu/MenuBuilder.php | 1 + src/Repository/WebhookSecretRepository.php | 40 +++++++++ templates/webhook_secret/create.html.twig | 17 ++++ templates/webhook_secret/index.html.twig | 36 ++++++++ .../webhook_secret/show_secret.html.twig | 18 ++++ .../WebhookSecretControllerTest.php | 53 ++++++++++++ translations/messages.en.yml | 3 +- 11 files changed, 365 insertions(+), 1 deletion(-) create mode 100644 src/Controller/WebhookSecretController.php create mode 100644 src/Entity/WebhookSecret.php create mode 100644 src/Form/Type/WebhookSecretType.php create mode 100644 src/Repository/WebhookSecretRepository.php create mode 100644 templates/webhook_secret/create.html.twig create mode 100644 templates/webhook_secret/index.html.twig create mode 100644 templates/webhook_secret/show_secret.html.twig create mode 100644 tests/Functional/Controller/WebhookSecretControllerTest.php diff --git a/config/services.yaml b/config/services.yaml index 605ba43d..45a159c6 100644 --- a/config/services.yaml +++ b/config/services.yaml @@ -25,6 +25,7 @@ parameters: - 'Packeton\Entity\SshCredentials' - 'Packeton\Entity\ApiToken' - 'Packeton\Entity\OAuthIntegration' + - 'Packeton\Entity\WebhookSecret' security_policy_forbidden_properties: 'Packeton\Entity\User': ['apiToken', 'githubToken', 'password', 'salt'] 'Packeton\Entity\Package': ['credentials'] diff --git a/src/Controller/WebhookSecretController.php b/src/Controller/WebhookSecretController.php new file mode 100644 index 00000000..a502aa8d --- /dev/null +++ b/src/Controller/WebhookSecretController.php @@ -0,0 +1,76 @@ +render('webhook_secret/index.html.twig', [ + 'secrets' => $this->registry->getRepository(WebhookSecret::class)->findAllOrdered(), + ]); + } + + #[Route('/create', name: 'webhook_secret_create', methods: ['GET', 'POST'])] + public function createAction(Request $request): Response + { + $secret = (new WebhookSecret())->setSecret(WebhookSecret::generateSecret()); + $form = $this->createForm(WebhookSecretType::class, $secret); + $form->handleRequest($request); + + if ($form->isSubmitted() && $form->isValid()) { + $entityManager = $this->registry->getManager(); + $entityManager->persist($secret); + $entityManager->flush(); + + $response = $this->render('webhook_secret/show_secret.html.twig', [ + 'secret' => $secret, + 'generatedSecret' => $secret->getSecret(), + ]); + $response->setPrivate(); + $response->headers->addCacheControlDirective('no-store'); + + return $response; + } + + return $this->render('webhook_secret/create.html.twig', [ + 'form' => $form->createView(), + ]); + } + + #[Route('/{id}/delete', name: 'webhook_secret_delete', requirements: ['id' => '\d+'], methods: ['POST'])] + public function deleteAction(Request $request, #[Vars] WebhookSecret $secret): Response + { + if (!$this->isCsrfTokenValid('webhook_secret_delete_'.$secret->getId(), $request->request->get('_token'))) { + return new Response('Invalid csrf token', Response::HTTP_BAD_REQUEST); + } + + $entityManager = $this->registry->getManager(); + $entityManager->remove($secret); + $entityManager->flush(); + + $this->addFlash('success', 'Webhook secret deleted.'); + + return $this->redirectToRoute('webhook_secret_index'); + } +} diff --git a/src/Entity/WebhookSecret.php b/src/Entity/WebhookSecret.php new file mode 100644 index 00000000..700fb84e --- /dev/null +++ b/src/Entity/WebhookSecret.php @@ -0,0 +1,86 @@ +createdAt = new \DateTime('now', new \DateTimeZone('UTC')); + } + + public function getId(): ?int + { + return $this->id; + } + + public function getName(): ?string + { + return $this->name; + } + + public function setName(string $name): self + { + $this->name = $name; + + return $this; + } + + public function getSecret(): ?string + { + return $this->secret; + } + + public function setSecret(string $secret): self + { + $this->secret = $secret; + + return $this; + } + + public function getCreatedAt(): \DateTimeInterface + { + return $this->createdAt; + } + + public function getLastUsedAt(): ?\DateTimeInterface + { + return $this->lastUsedAt; + } + + public function updateLastUsedAt(): self + { + $this->lastUsedAt = new \DateTime('now', new \DateTimeZone('UTC')); + + return $this; + } + + public static function generateSecret(): string + { + return bin2hex(random_bytes(32)); + } +} diff --git a/src/Form/Type/WebhookSecretType.php b/src/Form/Type/WebhookSecretType.php new file mode 100644 index 00000000..9e5999f4 --- /dev/null +++ b/src/Form/Type/WebhookSecretType.php @@ -0,0 +1,35 @@ +add('name', TextType::class, [ + 'label' => 'Name', + 'help' => 'Use a descriptive name for the GitHub organization or webhook.', + 'constraints' => [ + new NotBlank(), + new Length(max: 255), + ], + ]); + } + + public function configureOptions(OptionsResolver $resolver): void + { + $resolver->setDefaults([ + 'data_class' => WebhookSecret::class, + ]); + } +} diff --git a/src/Menu/MenuBuilder.php b/src/Menu/MenuBuilder.php index bde02b5e..3bf31775 100644 --- a/src/Menu/MenuBuilder.php +++ b/src/Menu/MenuBuilder.php @@ -56,6 +56,7 @@ public function createAdminMenu() $menu->addChild($this->translator->trans('menu.my_groups'), ['label' => 'menu.my_groups_icon', 'route' => 'groups_index', 'extras' => ['safe_label' => true]]); $menu->addChild($this->translator->trans('menu.ssh_keys'), ['label' => 'menu.ssh_keys_icon', 'route' => 'user_add_sshkey', 'extras' => ['safe_label' => true]]); $menu->addChild($this->translator->trans('menu.webhooks'), ['label' => 'menu.webhooks_icon', 'route' => 'webhook_index', 'extras' => ['safe_label' => true]]); + $menu->addChild($this->translator->trans('menu.webhook_secrets'), ['label' => 'menu.webhook_secrets_icon', 'route' => 'webhook_secret_index', 'extras' => ['safe_label' => true]]); $menu->addChild($this->translator->trans('menu.proxies'), ['label' => 'menu.proxies_icon', 'route' => 'proxies_list', 'extras' => ['safe_label' => true]]); $menu->addChild($this->translator->trans('menu.subrepository'), ['label' => 'menu.subrepository_icon', 'route' => 'subrepository_index', 'extras' => ['safe_label' => true]]); if ($this->integrations->getNames()) { diff --git a/src/Repository/WebhookSecretRepository.php b/src/Repository/WebhookSecretRepository.php new file mode 100644 index 00000000..9d04103d --- /dev/null +++ b/src/Repository/WebhookSecretRepository.php @@ -0,0 +1,40 @@ + + */ +class WebhookSecretRepository extends EntityRepository +{ + /** + * @return WebhookSecret[] + */ + public function findAllOrdered(): array + { + return $this->createQueryBuilder('secret') + ->orderBy('secret.createdAt', 'DESC') + ->getQuery() + ->getResult(); + } + + /** + * @return array + */ + public function findSecretValues(): array + { + $secrets = []; + foreach ($this->findAll() as $secret) { + if (null !== $secret->getId() && null !== $secret->getSecret()) { + $secrets[$secret->getId()] = $secret->getSecret(); + } + } + + return $secrets; + } +} diff --git a/templates/webhook_secret/create.html.twig b/templates/webhook_secret/create.html.twig new file mode 100644 index 00000000..80ed8a53 --- /dev/null +++ b/templates/webhook_secret/create.html.twig @@ -0,0 +1,17 @@ +{% extends "layout.html.twig" %} + +{% block content %} +

Add incoming webhook secret

+ +
+ {{ form_start(form, {attr: {class: 'col-md-6'}}) }} + {{ form_rest(form) }} + + {{ form_end(form) }} + +
+

GitHub organization webhooks

+

Packeton generates the shared secret. Copy it to GitHub when it is displayed on the next page.

+
+
+{% endblock %} diff --git a/templates/webhook_secret/index.html.twig b/templates/webhook_secret/index.html.twig new file mode 100644 index 00000000..60ceb943 --- /dev/null +++ b/templates/webhook_secret/index.html.twig @@ -0,0 +1,36 @@ +{% extends "layout.html.twig" %} + +{% block content %} +
+
+
+ Add webhook secret +
+

Incoming webhook secrets

+

+ These secrets authenticate incoming GitHub organization webhooks. Secret values are encrypted in the + database and are only displayed once, immediately after creation. +

+
+ +
+ + {% for secret in secrets %} +
+
+ {{ secret.name }} +
+
+
+ + +
+

Created: {{ secret.createdAt|date('Y-m-d H:i:s T') }}

+

Last used: {{ secret.lastUsedAt ? secret.lastUsedAt|date('Y-m-d H:i:s T') : 'Never' }}

+
+
+ {% else %} +
No incoming webhook secrets have been configured.
+ {% endfor %} +
+{% endblock %} diff --git a/templates/webhook_secret/show_secret.html.twig b/templates/webhook_secret/show_secret.html.twig new file mode 100644 index 00000000..055c3ca3 --- /dev/null +++ b/templates/webhook_secret/show_secret.html.twig @@ -0,0 +1,18 @@ +{% extends "layout.html.twig" %} + +{% block content %} +

Incoming webhook secret created

+ +
+ Copy this secret now. Packeton encrypts it for validation and will not display it again. +
+ +
+
{{ secret.name }}
+
+ {{ generatedSecret }} +
+
+ + Back to webhook secrets +{% endblock %} diff --git a/tests/Functional/Controller/WebhookSecretControllerTest.php b/tests/Functional/Controller/WebhookSecretControllerTest.php new file mode 100644 index 00000000..4885bb37 --- /dev/null +++ b/tests/Functional/Controller/WebhookSecretControllerTest.php @@ -0,0 +1,53 @@ +loginUser($this->getUser('user1')); + + $client->request('GET', '/webhook-secrets'); + + static::assertResponseStatusCodeSame(403); + } + + public function testAdminCanCreateEncryptedWebhookSecret(): void + { + $client = static::createClient(); + $registry = static::getContainer()->get(ManagerRegistry::class); + $registry->getConnection()->executeStatement('DELETE FROM webhook_secret'); + + $client->loginUser($this->getUser('admin')); + $crawler = $client->request('GET', '/webhook-secrets/create'); + $form = $crawler->selectButton('Create secret')->form([ + 'webhook_secret[name]' => 'GitHub organization', + ]); + $crawler = $client->submit($form); + + static::assertResponseIsSuccessful(); + static::assertTrue($client->getResponse()->headers->hasCacheControlDirective('no-store')); + $plainSecret = trim($crawler->filter('#generated-webhook-secret')->text()); + static::assertMatchesRegularExpression('/^[a-f0-9]{64}$/', $plainSecret); + + $storedSecret = $registry->getConnection()->fetchOne('SELECT secret FROM webhook_secret'); + static::assertIsString($storedSecret); + static::assertNotSame($plainSecret, $storedSecret); + + $registry->getManager()->clear(); + $secret = $registry->getRepository(WebhookSecret::class)->findOneBy(['name' => 'GitHub organization']); + static::assertInstanceOf(WebhookSecret::class, $secret); + static::assertSame($plainSecret, $secret->getSecret()); + } +} diff --git a/translations/messages.en.yml b/translations/messages.en.yml index 89e7717c..fdf0b32b 100644 --- a/translations/messages.en.yml +++ b/translations/messages.en.yml @@ -28,6 +28,7 @@ menu: my_groups: My groups ssh_keys: Credentials webhooks: Webhooks + webhook_secrets: Incoming webhook secrets proxies: Composer Proxies subrepository: SubRepositories integrations: Integrations @@ -43,6 +44,7 @@ menu: my_login_icon: 'Login attempts' ssh_keys_icon: 'Credentials' webhooks_icon: 'Webhooks' + webhook_secrets_icon: 'Incoming webhook secrets' subrepository_icon: 'SubRepositories' break_line_icon: '
' proxies_icon: 'Composer Proxies' @@ -215,4 +217,3 @@ Order: 'Order' Username: 'Username' asc: 'asc' desc: 'desc' - From 7d3555a5f73e4f6fae167e6b7f6612d0007fef39 Mon Sep 17 00:00:00 2001 From: Vladimir Kuznichenkov Date: Thu, 20 Aug 2026 11:36:59 +0300 Subject: [PATCH 2/2] Authenticate GitHub webhooks with HMAC signatures Add a public GitHub organization webhook endpoint that authenticates the raw request body with X-Hub-Signature-256 before scheduling package updates. Support secret rotation, ignore non-push events safely, retain the legacy token endpoint, and document the new setup flow. --- README.md | 16 +++- config/packages/security.yaml | 4 + docs/usage/update-packages.md | 17 +++- src/Controller/Api/ApiController.php | 80 ++++++++++++++++++ src/Service/WebhookSignatureValidator.php | 29 +++++++ templates/about/about.html.twig | 11 ++- .../GitHubWebhookControllerTest.php | 83 +++++++++++++++++++ 7 files changed, 235 insertions(+), 5 deletions(-) create mode 100644 src/Service/WebhookSignatureValidator.php create mode 100644 tests/Functional/Controller/GitHubWebhookControllerTest.php diff --git a/README.md b/README.md index 8bbdb42d..99fbf3d3 100644 --- a/README.md +++ b/README.md @@ -354,7 +354,8 @@ every time you push code. More simple way use group webhooks, to prevent from be | Provider | Group webhook support | Target Path | |-----------|-----------------------|-----------------------------------------------------------| -| GitHub | Yes | `https://example.org/api/github?token=` | +| GitHub | Yes (signature-based) | `https://example.org/api/hooks/github` | +| GitHub | Yes (legacy token) | `https://example.org/api/github?token=` | | GitLab | Only paid plan | `https://example.org/api/update-package?token=` | | Gitea | Yes | `https://example.org/api/update-package?token=` | | Bitbucket | Yes | `https://example.org/api/bitbucket?token=` | @@ -380,7 +381,18 @@ To enable the Group GitLab webhook you must have the paid plan. Go to your GitLab Group > Settings > Webhooks. Enter `https:///api/update-package?token=user:token` as URL. -#### GitHub Webhooks +#### GitHub Organization Webhooks (recommended) +For organization-wide webhooks, use signature-based authentication instead of putting a user API token in the URL. + +1. In Packeton, go to Settings > Incoming webhook secrets and create a secret. +2. In GitHub, go to Organization Settings > Webhooks > Add webhook. +3. Set the payload URL to `https:///api/hooks/github`. +4. Select `application/json`, paste the generated secret, and subscribe to push events. + +Packeton validates GitHub's `X-Hub-Signature-256` header before updating packages. Existing token-based webhook URLs +remain available for backwards compatibility. + +#### GitHub Repository Webhooks (legacy) To enable the GitHub webhook go to your GitHub repository. Click the "Settings" button, click "Webhooks". Add a new hook. Enter `https:///api/github?token=user:token` as URL. diff --git a/config/packages/security.yaml b/config/packages/security.yaml index 096d7cdf..2f4dac1e 100644 --- a/config/packages/security.yaml +++ b/config/packages/security.yaml @@ -22,6 +22,9 @@ security: dev: pattern: ^/(_(profiler|wdt)|css|images|js)/ security: false + github_webhook: + pattern: ^/api/hooks/github$ + security: false packages: pattern: (^(.+\.json$|/p/|/mirror/|/zipball/|/feeds/.+(\.rss|\.atom)|/packages/[A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+?(\.json|/changelog)|/packages/list\.json|/packages/upload/|/downloads/|/api/))+ api_basic: @@ -72,6 +75,7 @@ security: # Maintainers - { path: (^(/users/(.+)/packages))+, roles: ROLE_MAINTAINER } - { path: (^(/users/(.+)/favorites))+, roles: ROLE_MAINTAINER } + - { path: ^/api/hooks/github$, roles: PUBLIC_ACCESS } - { path: (^(/metadata/changes.json$|/explore|/jobs/|/archive/|/api/hooks/))+, roles: ROLE_MAINTAINER } # Secured part of the site diff --git a/docs/usage/update-packages.md b/docs/usage/update-packages.md index 704da986..ac672792 100644 --- a/docs/usage/update-packages.md +++ b/docs/usage/update-packages.md @@ -4,7 +4,7 @@ You can use GitLab, GitHub, and Bitbucket project post-receive hook to keep your ## Into -Webhook API request authorization with minimum access level `ROLE_MAINTAINER`. +Token-based webhook API request authorization requires a minimum access level of `ROLE_MAINTAINER`. You can use `token` query parameter with `` to call it. Also support Packagist.org authorization with `username` and `apiToken` query parameters. @@ -29,7 +29,20 @@ To enable the Group GitLab webhook you must have the paid plan. Go to your GitLab Group > Settings > Webhooks. Enter `https:///api/update-package?token=user:token` as URL. -## GitHub Webhooks +## GitHub Organization Webhooks (recommended) + +Organization webhooks can authenticate with GitHub's `X-Hub-Signature-256` header, without exposing a user API token +in the webhook URL. + +1. In Packeton, go to Settings > Incoming webhook secrets and create a secret. +2. In GitHub, go to Organization Settings > Webhooks > Add webhook. +3. Set the payload URL to `https:///api/hooks/github` and the content type to `application/json`. +4. Paste the generated secret and subscribe to push events. + +The secret is shown once and stored encrypted by Packeton. Existing token-based endpoints remain available for +backwards compatibility. + +## GitHub Repository Webhooks (legacy) To enable the GitHub webhook go to your GitHub repository. Click the "Settings" button, click "Webhooks". Add a new hook. Enter `https:///api/github?token=user:token` as URL. diff --git a/src/Controller/Api/ApiController.php b/src/Controller/Api/ApiController.php index 30ea8c9b..c1882981 100644 --- a/src/Controller/Api/ApiController.php +++ b/src/Controller/Api/ApiController.php @@ -11,6 +11,7 @@ use Packeton\Entity\Package; use Packeton\Entity\User; use Packeton\Entity\Webhook; +use Packeton\Entity\WebhookSecret; use Packeton\Integrations\IntegrationRegistry; use Packeton\Integrations\Model\AppUtils; use Packeton\Model\AutoHookUser; @@ -21,6 +22,7 @@ use Packeton\Service\JobPersister; use Packeton\Service\Scheduler; use Packeton\Service\SubRepositoryHelper; +use Packeton\Service\WebhookSignatureValidator; use Packeton\Util\PacketonUtils; use Packeton\Webhook\HookBus; use Psr\Log\LoggerInterface; @@ -155,6 +157,84 @@ public function updatePackageAction(Request $request, #[Vars] ?Package $package return $this->schedulePostJobs($packages); } + #[Route('/api/hooks/github', name: 'github_secure_postreceive', methods: ['POST'])] + public function secureGitHubWebhookAction(Request $request, WebhookSignatureValidator $signatureValidator): Response + { + $secretRepository = $this->registry->getRepository(WebhookSecret::class); + $secrets = $secretRepository->findSecretValues(); + if (!$secrets) { + return new JsonResponse( + ['status' => 'error', 'message' => 'No incoming webhook secrets are configured'], + Response::HTTP_SERVICE_UNAVAILABLE, + ); + } + + $signature = $request->headers->get(WebhookSignatureValidator::SIGNATURE_HEADER); + if (null === $signature || '' === $signature) { + $this->logger->warning('GitHub webhook signature validation failed: missing signature header', [ + 'ip' => $request->getClientIp(), + 'user_agent' => $request->headers->get('User-Agent'), + ]); + + return new JsonResponse( + ['status' => 'error', 'message' => 'Missing X-Hub-Signature-256 header'], + Response::HTTP_UNAUTHORIZED, + ); + } + + $matchedSecretId = $signatureValidator->findMatchingSecretId($request->getContent(), $signature, $secrets); + if (null === $matchedSecretId) { + $this->logger->warning('GitHub webhook signature validation failed: invalid signature', [ + 'ip' => $request->getClientIp(), + 'user_agent' => $request->headers->get('User-Agent'), + ]); + + return new JsonResponse( + ['status' => 'error', 'message' => 'Invalid signature'], + Response::HTTP_FORBIDDEN, + ); + } + + $secret = $secretRepository->find($matchedSecretId); + if (null !== $secret) { + $secret->updateLastUsedAt(); + $this->registry->getManager()->flush(); + } + + $event = $request->headers->get('X-GitHub-Event'); + if ('ping' === $event) { + return new JsonResponse(['status' => 'success', 'message' => 'Webhook configured successfully']); + } + if ('push' !== $event) { + return new JsonResponse( + ['status' => 'success', 'message' => sprintf('GitHub event "%s" ignored', $event ?? '')], + Response::HTTP_ACCEPTED, + ); + } + + $payload = $this->getJsonPayload($request); + if (!$payload) { + return new JsonResponse( + ['status' => 'error', 'message' => 'Missing or invalid JSON payload'], + Response::HTTP_NOT_ACCEPTABLE, + ); + } + + $packages = PacketonUtils::findPackagesByPayload( + $payload, + $this->registry->getRepository(Package::class), + true, + ); + if (!$packages) { + return new JsonResponse( + ['status' => 'error', 'message' => 'No matching packages found'], + Response::HTTP_NOT_FOUND, + ); + } + + return $this->schedulePostJobs($packages); + } + #[Route('/api/packages/{name}', name: 'api_edit_package', requirements: ['name' => '%package_name_regex%'], methods: ['PUT'])] public function editPackageAction(Request $request, #[Vars] Package $package): Response { diff --git a/src/Service/WebhookSignatureValidator.php b/src/Service/WebhookSignatureValidator.php new file mode 100644 index 00000000..08e0a43f --- /dev/null +++ b/src/Service/WebhookSignatureValidator.php @@ -0,0 +1,29 @@ + $secrets + */ + public function findMatchingSecretId(string $payload, string $signature, array $secrets): ?int + { + if (1 !== preg_match('/\Asha256=([A-Fa-f0-9]{64})\z/', $signature, $matches)) { + return null; + } + + $providedHash = strtolower($matches[1]); + foreach ($secrets as $id => $secret) { + if (hash_equals(hash_hmac('sha256', $payload, $secret), $providedHash)) { + return $id; + } + } + + return null; + } +} diff --git a/templates/about/about.html.twig b/templates/about/about.html.twig index 2001714c..5ca2a438 100644 --- a/templates/about/about.html.twig +++ b/templates/about/about.html.twig @@ -33,7 +33,16 @@ composer config --global --auth http-basic.{{ app.request.getHttpHost() }} {{ ap

How to update packages?

-

GitHub Service Hook

+

GitHub Organization Webhook

+

Organization webhooks can use an HMAC signature instead of exposing a user API token in the URL.

+
    +
  • {% if is_granted('ROLE_ADMIN') %}Create an incoming webhook secret{% else %}Ask an administrator to create an incoming webhook secret{% endif %}
  • +
  • Go to your GitHub organization settings and add a webhook
  • +
  • Enter {{ url }}/api/hooks/github as the payload URL
  • +
  • Select JSON content, enter the generated secret, and subscribe to push events
  • +
+ +

GitHub Repository Webhook (legacy)

Enabling the Packagist service hook ensures that your package will always be updated instantly when you push to GitHub.

To do so you can:

    diff --git a/tests/Functional/Controller/GitHubWebhookControllerTest.php b/tests/Functional/Controller/GitHubWebhookControllerTest.php new file mode 100644 index 00000000..f3cdc5e2 --- /dev/null +++ b/tests/Functional/Controller/GitHubWebhookControllerTest.php @@ -0,0 +1,83 @@ +get(ManagerRegistry::class); + $registry->getConnection()->executeStatement('DELETE FROM webhook_secret'); + + $payload = '{"zen":"Keep it logically awesome."}'; + $this->requestWebhook($client, $payload, 'ping', 'sha256='.str_repeat('0', 64)); + static::assertResponseStatusCodeSame(503); + + $plainSecret = WebhookSecret::generateSecret(); + $secret = (new WebhookSecret()) + ->setName('GitHub organization') + ->setSecret($plainSecret); + $registry->getManager()->persist($secret); + $registry->getManager()->flush(); + + $this->requestWebhook($client, $payload, 'ping'); + static::assertResponseStatusCodeSame(401); + + $this->requestWebhook($client, $payload, 'ping', 'sha256=invalid'); + static::assertResponseStatusCodeSame(403); + + $this->requestWebhook($client, $payload, 'ping', 'sha256='.str_repeat('0', 64)); + static::assertResponseStatusCodeSame(403); + + $signature = 'sha256='.hash_hmac('sha256', $payload, $plainSecret); + $this->requestWebhook($client, $payload, 'ping', $signature); + static::assertResponseIsSuccessful(); + static::assertJsonStringEqualsJsonString( + '{"status":"success","message":"Webhook configured successfully"}', + (string) $client->getResponse()->getContent(), + ); + + $registry = static::getContainer()->get(ManagerRegistry::class); + $registry->getManager()->clear(); + $secret = $registry->getRepository(WebhookSecret::class)->findOneBy(['name' => 'GitHub organization']); + static::assertInstanceOf(WebhookSecret::class, $secret); + static::assertNotNull($secret->getLastUsedAt()); + + $package = $registry->getRepository(Package::class)->findOneBy(['name' => 'okvpn/cron-bundle']); + static::assertInstanceOf(Package::class, $package); + $pushPayload = json_encode(['repository' => ['url' => $package->getRepository()]], JSON_THROW_ON_ERROR); + $pushSignature = 'sha256='.hash_hmac('sha256', $pushPayload, $plainSecret); + + $this->requestWebhook($client, $pushPayload, 'push', $pushSignature); + static::assertResponseStatusCodeSame(202); + $response = json_decode((string) $client->getResponse()->getContent(), true, flags: JSON_THROW_ON_ERROR); + static::assertSame('success', $response['status']); + static::assertNotEmpty($response['jobs']); + } + + private function requestWebhook( + KernelBrowser $client, + string $payload, + string $event, + ?string $signature = null, + ): void { + $server = [ + 'CONTENT_TYPE' => 'application/json', + 'HTTP_X_GITHUB_EVENT' => $event, + ]; + if (null !== $signature) { + $server['HTTP_X_HUB_SIGNATURE_256'] = $signature; + } + + $client->request('POST', '/api/hooks/github', [], [], $server, $payload); + } +}