From e6a18ce5d8cfb3547a959ff0f10856d81068a6ae Mon Sep 17 00:00:00 2001 From: Jeppe Bundgaard Date: Thu, 11 Jun 2026 15:06:31 +0200 Subject: [PATCH] Add Slack customer registration webhook test endpoint --- services/nginx/app/classes/slack.php | 56 +++++++++++- services/nginx/app/openapi.yaml | 33 +++++++ services/nginx/app/routes/authRoute.php | 21 +---- .../nginx/app/routes/moduleConfigRoute.php | 29 +++++++ .../Unit/Slack/SlackConfigRouteWiringTest.php | 8 +- .../SlackCustomerRegistrationWebhookTest.php | 86 +++++++++++++++++++ .../nginx/app/tests/auth/RegisterCvrTest.php | 28 ++++++ 7 files changed, 241 insertions(+), 20 deletions(-) create mode 100644 services/nginx/app/tests/Unit/Slack/SlackCustomerRegistrationWebhookTest.php diff --git a/services/nginx/app/classes/slack.php b/services/nginx/app/classes/slack.php index 0c9ca577..6b5f276c 100644 --- a/services/nginx/app/classes/slack.php +++ b/services/nginx/app/classes/slack.php @@ -134,7 +134,7 @@ class slack implements notification_i . "Status: $status"; } - public function send_message(string $string, string $module = null): void + public function send_message(string $string, ?string $module = null): void { global $SLACK_DEFAULT_WEBHOOK; // Format the message if a module is provided @@ -147,7 +147,7 @@ class slack implements notification_i public function send_customer_registration_notification(int $customer_number): self { - $webhook = trim((string)$this->getConfig()->customer_registration_webhook_url->getVariableValue()); + $webhook = $this->get_customer_registration_webhook_url(); if ($webhook === '') { return $this; } @@ -160,6 +160,52 @@ class slack implements notification_i return $this; } + /** + * Send a sanitized customer-registration test notification to the saved Slack webhook. + * + * @return array{configured:bool,sent:bool,message:string} + */ + public function test_customer_registration_webhook(): array + { + $webhook = $this->get_customer_registration_webhook_url(); + if ($webhook === '') { + return [ + 'configured' => false, + 'sent' => false, + 'message' => 'Slack customer registration webhook URL is not configured.', + ]; + } + + $result = $this->send_webhook_message( + $this->format_customer_registration_test(), + $webhook + ); + $sent = $this->is_webhook_send_successful($result); + + self::add_log($sent + ? 'Slack customer registration test webhook sent successfully.' + : 'Slack customer registration test webhook failed.' + ); + + return [ + 'configured' => true, + 'sent' => $sent, + 'message' => $sent + ? 'Slack test message sent successfully.' + : 'Slack test message failed.', + ]; + } + + protected function get_customer_registration_webhook_url(): string + { + return trim((string)$this->getConfig()->customer_registration_webhook_url->getVariableValue()); + } + + public function is_webhook_send_successful(string $result): bool + { + return !str_starts_with($result, 'Failed to send message:'); + } + public function format_customer_registration(int $customer_number): string { $customer = (new users_o())->getUserByCustomerNumber($customer_number); @@ -178,4 +224,10 @@ class slack implements notification_i . "Customer: $customerName ($safeCustomerNumber)\n" . "Open in Superuser: $customerUrl"; } + + public function format_customer_registration_test(): string + { + return "*Truck Wash Slack test*\n" + . "Customer registration notifications are configured correctly."; + } } diff --git a/services/nginx/app/openapi.yaml b/services/nginx/app/openapi.yaml index f1a8d4cd..2af7ef74 100644 --- a/services/nginx/app/openapi.yaml +++ b/services/nginx/app/openapi.yaml @@ -11665,6 +11665,23 @@ paths: schema: $ref: '#/components/schemas/ModuleConfigUpdateResponse' + /slack/config/test: + post: + tags: [Config] + summary: Test Slack customer registration webhook + operationId: testSlackCustomerRegistrationWebhook + responses: + '200': + description: Slack customer registration webhook test completed successfully + content: + application/json: + schema: + $ref: '#/components/schemas/SlackConfigTestResponse' + '400': + description: Slack customer registration webhook URL is not configured + '502': + description: Slack customer registration webhook test failed + /backups/config: get: tags: [Config] @@ -15190,6 +15207,14 @@ components: example: https://hooks.slack.com/services/... required: [module, variable, type, value] + SlackConfigTestResult: + type: object + properties: + configured: { type: boolean } + sent: { type: boolean } + message: { type: string } + required: [configured, sent, message] + BackupsConfigEntry: type: object properties: @@ -15466,6 +15491,14 @@ components: data: { type: array, items: { $ref: '#/components/schemas/SlackConfigEntry' } } required: [data] + SlackConfigTestResponse: + allOf: + - $ref: '#/components/schemas/ModuleConfigEnvelopeBase' + - type: object + properties: + data: { $ref: '#/components/schemas/SlackConfigTestResult' } + required: [data] + BackupsConfigListResponse: allOf: - $ref: '#/components/schemas/ModuleConfigEnvelopeBase' diff --git a/services/nginx/app/routes/authRoute.php b/services/nginx/app/routes/authRoute.php index b3de8ea3..f5e8ef6f 100644 --- a/services/nginx/app/routes/authRoute.php +++ b/services/nginx/app/routes/authRoute.php @@ -462,8 +462,7 @@ class authRoute $economic, (string)$cvr, $companyPhone, - (string)$invoiceEmail, - $exception + (string)$invoiceEmail ); if ($recoveredCustomer !== null) { @@ -788,13 +787,10 @@ class authRoute economic $economic, string $cvr, int $customerNumber, - string $invoiceEmail, - Exception $exception + string $invoiceEmail ): ?object { - if (!$this->isRecoverableEconomicDuplicateError($exception)) { - return null; - } - + // The upstream POST can commit before the client receives a validation/transport error. + // Re-read by CVR and only recover when e-conomic confirms the requested customer number. try { $economic_response = $this->searchEconomicCustomersByCvr($economic, $cvr); } catch (Exception $searchException) { @@ -818,15 +814,6 @@ class authRoute return $matchingEconomicCustomer; } - private function isRecoverableEconomicDuplicateError(Exception $exception): bool - { - $message = strtolower($exception->getMessage()); - - return str_contains($message, 'already exists') - || str_contains($message, 'already exist') - || str_contains($message, 'duplicate'); - } - private function findEconomicCustomerByNumber(array $customers, int $customerNumber): ?object { foreach ($customers as $customer) { diff --git a/services/nginx/app/routes/moduleConfigRoute.php b/services/nginx/app/routes/moduleConfigRoute.php index f950c02d..4c017acd 100644 --- a/services/nginx/app/routes/moduleConfigRoute.php +++ b/services/nginx/app/routes/moduleConfigRoute.php @@ -221,6 +221,35 @@ class moduleConfigRoute ] ); + /** Slack config > TEST */ + $this->post('/slack/config/test', function () { + global $response; + $this->requirePermission('slack_config'); + $user = (new authentication())->get_user(); + if (!$user) { + (new logs_o())->add('slack_config', 'global', 1, 0, 'SLACK_CONFIG', 'No user found, or invalid session'); + $response->error('Invalid session', 400); + } + + $result = (new slack())->test_customer_registration_webhook(); + if (($result['configured'] ?? false) !== true) { + (new logs_o())->add('slack_config', 'global', 0, $user->id, 'SLACK_CONFIG_TEST', 'Slack customer registration webhook URL is not configured'); + $response->error($result['message'] ?? 'Slack customer registration webhook URL is not configured.', 400); + } + + if (($result['sent'] ?? false) !== true) { + (new logs_o())->add('slack_config', 'global', 0, $user->id, 'SLACK_CONFIG_TEST', 'Slack customer registration test webhook failed'); + $response->error($result['message'] ?? 'Slack test message failed.', 502); + } + + (new logs_o())->add('slack_config', 'global', 1, $user->id, 'SLACK_CONFIG_TEST', 'Successfully tested Slack customer registration webhook'); + $response->success($result); + }, + [ + 'slack_config' => 'Test Slack config' + ] + ); + $this->get('/backups/config', function () { global $response; $this->requirePermission('backups_config'); diff --git a/services/nginx/app/tests/Unit/Slack/SlackConfigRouteWiringTest.php b/services/nginx/app/tests/Unit/Slack/SlackConfigRouteWiringTest.php index 54b70c6c..1f7bd9c8 100644 --- a/services/nginx/app/tests/Unit/Slack/SlackConfigRouteWiringTest.php +++ b/services/nginx/app/tests/Unit/Slack/SlackConfigRouteWiringTest.php @@ -6,9 +6,11 @@ it('registers Slack module config endpoints and customer registration webhook co expect($routeContent)->not->toBeFalse() ->and($routeContent)->toContain('/slack/config') + ->and($routeContent)->toContain('/slack/config/test') ->and($routeContent)->toContain("requirePermission('slack_config')") ->and($routeContent)->toContain("(new slack())->getConfig()->getConfigRequest()") - ->and($routeContent)->toContain("(new slack())->getConfig()->postConfigRequest()"); + ->and($routeContent)->toContain("(new slack())->getConfig()->postConfigRequest()") + ->and($routeContent)->toContain("(new slack())->test_customer_registration_webhook()"); $moduleContent = file_get_contents(app_path('modules/slack/slack_c.php')); $variableContent = file_get_contents(app_path('modules/slack/config/slack_customer_registration_webhook_url_c.php')); @@ -24,11 +26,15 @@ it('registers Slack module config endpoints and customer registration webhook co ->and($variableContent)->toContain('Slack webhook URL used for successful customer registration notifications') ->and($slackClassContent)->not->toBeFalse() ->and($slackClassContent)->toContain('send_customer_registration_notification') + ->and($slackClassContent)->toContain('test_customer_registration_webhook') + ->and($slackClassContent)->toContain('format_customer_registration_test') ->and($slackClassContent)->toContain('format_customer_registration') ->and($authRouteContent)->not->toBeFalse() ->and($authRouteContent)->toContain("AUTH_REGISTER_CVR_SLACK_NOTIFICATION_FAILED") ->and($openApiContent)->not->toBeFalse() ->and($openApiContent)->toContain('/slack/config') + ->and($openApiContent)->toContain('/slack/config/test') ->and($openApiContent)->toContain('SlackConfigListResponse') + ->and($openApiContent)->toContain('SlackConfigTestResponse') ->and($openApiContent)->toContain('SlackConfigEntry'); }); diff --git a/services/nginx/app/tests/Unit/Slack/SlackCustomerRegistrationWebhookTest.php b/services/nginx/app/tests/Unit/Slack/SlackCustomerRegistrationWebhookTest.php new file mode 100644 index 00000000..a61a43ce --- /dev/null +++ b/services/nginx/app/tests/Unit/Slack/SlackCustomerRegistrationWebhookTest.php @@ -0,0 +1,86 @@ +webhook; + } + + public function send_webhook_message(string $message, string $webhook): string + { + $this->messages[] = [ + 'message' => $message, + 'webhook' => $webhook, + ]; + + return $this->sendResult; + } +} + +it('does not send customer registration test notifications without a saved webhook', function (): void { + $slack = new SlackCustomerRegistrationWebhookFake(''); + + $result = $slack->test_customer_registration_webhook(); + + expect($result) + ->toBe([ + 'configured' => false, + 'sent' => false, + 'message' => 'Slack customer registration webhook URL is not configured.', + ]) + ->and($slack->messages)->toBe([]) + ->and($slack->get_log())->toBe([]); +}); + +it('sends customer registration test notifications to the saved webhook', function (): void { + $slack = new SlackCustomerRegistrationWebhookFake('https://hooks.slack.test/services/secret-token'); + + $result = $slack->test_customer_registration_webhook(); + + expect($result) + ->toBe([ + 'configured' => true, + 'sent' => true, + 'message' => 'Slack test message sent successfully.', + ]) + ->and($slack->messages)->toHaveCount(1) + ->and($slack->messages[0]['webhook'])->toBe('https://hooks.slack.test/services/secret-token') + ->and($slack->messages[0]['message'])->toContain('Truck Wash Slack test') + ->and($slack->messages[0]['message'])->toContain('Customer registration notifications are configured correctly.') + ->and(json_encode($slack->get_log(), JSON_UNESCAPED_SLASHES))->toContain('sent successfully') + ->and(json_encode($slack->get_log(), JSON_UNESCAPED_SLASHES))->not->toContain('secret-token'); +}); + +it('reports customer registration test notification failures without exposing the webhook', function (): void { + $slack = new SlackCustomerRegistrationWebhookFake( + 'https://hooks.slack.test/services/secret-token', + 'Failed to send message: cURL error for https://hooks.slack.test/services/secret-token' + ); + + $result = $slack->test_customer_registration_webhook(); + + expect($result) + ->toBe([ + 'configured' => true, + 'sent' => false, + 'message' => 'Slack test message failed.', + ]) + ->and($slack->messages)->toHaveCount(1) + ->and(json_encode($result, JSON_UNESCAPED_SLASHES))->not->toContain('secret-token') + ->and(json_encode($slack->get_log(), JSON_UNESCAPED_SLASHES))->toContain('failed') + ->and(json_encode($slack->get_log(), JSON_UNESCAPED_SLASHES))->not->toContain('secret-token'); +}); diff --git a/services/nginx/app/tests/auth/RegisterCvrTest.php b/services/nginx/app/tests/auth/RegisterCvrTest.php index 9b7a723b..e654effb 100644 --- a/services/nginx/app/tests/auth/RegisterCvrTest.php +++ b/services/nginx/app/tests/auth/RegisterCvrTest.php @@ -591,6 +591,34 @@ namespace { assert_true(count(\classes\slack::$customer_registration_notifications) === 1, 'Duplicate create recovery must notify Slack once.'); }, ], + [ + 'name' => 'Generic create failure recovers a confirmed just-created e-conomic customer', + 'params' => $baseParams, + 'setup' => static function (): void { + \classes\economic::$mock_create_exception = new \RuntimeException('e-conomic request failed with HTTP 400: Validation failed. | details={"httpStatusCode":400}'); + \classes\economic::$mock_collection_after_create_exception = [ + (object)[ + 'customerNumber' => 12345678, + 'name' => 'Recovered After Generic Create Failure', + 'email' => 'test@test.com', + ], + ]; + \objects\users_o::$mock_importable_customer_numbers = [12345678]; + }, + 'expected_success' => (object)[ + 'customerNumber' => 12345678, + 'name' => 'Recovered After Generic Create Failure', + 'email' => 'test@test.com', + ], + 'expected_status' => 200, + 'assert' => static function (): void { + assert_true(count(\classes\economic::$create_calls) === 1, 'Generic create recovery must still record the attempted create call.'); + assert_true(count(\classes\economic::$search_calls) === 2, 'Generic create recovery must confirm the customer by searching e-conomic again.'); + assert_true(count(\classes\email::$sent) === 2, 'Generic create recovery must send two welcome emails.'); + assert_true(count(\classes\email::$superuser_notifications) === 1, 'Generic create recovery must notify opted-in superusers once.'); + assert_true(count(\classes\slack::$customer_registration_notifications) === 1, 'Generic create recovery must notify Slack once.'); + }, + ], [ 'name' => 'Fresh create mismatch returns conflict without local bootstrap or email', 'params' => $baseParams,