Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e6a18ce5d8 | ||
|
|
b5c24ef80a |
@@ -134,7 +134,7 @@ class slack implements notification_i
|
|||||||
. "Status: $status";
|
. "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;
|
global $SLACK_DEFAULT_WEBHOOK;
|
||||||
// Format the message if a module is provided
|
// 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
|
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 === '') {
|
if ($webhook === '') {
|
||||||
return $this;
|
return $this;
|
||||||
}
|
}
|
||||||
@@ -160,6 +160,52 @@ class slack implements notification_i
|
|||||||
return $this;
|
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
|
public function format_customer_registration(int $customer_number): string
|
||||||
{
|
{
|
||||||
$customer = (new users_o())->getUserByCustomerNumber($customer_number);
|
$customer = (new users_o())->getUserByCustomerNumber($customer_number);
|
||||||
@@ -178,4 +224,10 @@ class slack implements notification_i
|
|||||||
. "Customer: $customerName ($safeCustomerNumber)\n"
|
. "Customer: $customerName ($safeCustomerNumber)\n"
|
||||||
. "Open in Superuser: $customerUrl";
|
. "Open in Superuser: $customerUrl";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function format_customer_registration_test(): string
|
||||||
|
{
|
||||||
|
return "*Truck Wash Slack test*\n"
|
||||||
|
. "Customer registration notifications are configured correctly.";
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11665,6 +11665,23 @@ paths:
|
|||||||
schema:
|
schema:
|
||||||
$ref: '#/components/schemas/ModuleConfigUpdateResponse'
|
$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:
|
/backups/config:
|
||||||
get:
|
get:
|
||||||
tags: [Config]
|
tags: [Config]
|
||||||
@@ -15190,6 +15207,14 @@ components:
|
|||||||
example: https://hooks.slack.com/services/...
|
example: https://hooks.slack.com/services/...
|
||||||
required: [module, variable, type, value]
|
required: [module, variable, type, value]
|
||||||
|
|
||||||
|
SlackConfigTestResult:
|
||||||
|
type: object
|
||||||
|
properties:
|
||||||
|
configured: { type: boolean }
|
||||||
|
sent: { type: boolean }
|
||||||
|
message: { type: string }
|
||||||
|
required: [configured, sent, message]
|
||||||
|
|
||||||
BackupsConfigEntry:
|
BackupsConfigEntry:
|
||||||
type: object
|
type: object
|
||||||
properties:
|
properties:
|
||||||
@@ -15466,6 +15491,14 @@ components:
|
|||||||
data: { type: array, items: { $ref: '#/components/schemas/SlackConfigEntry' } }
|
data: { type: array, items: { $ref: '#/components/schemas/SlackConfigEntry' } }
|
||||||
required: [data]
|
required: [data]
|
||||||
|
|
||||||
|
SlackConfigTestResponse:
|
||||||
|
allOf:
|
||||||
|
- $ref: '#/components/schemas/ModuleConfigEnvelopeBase'
|
||||||
|
- type: object
|
||||||
|
properties:
|
||||||
|
data: { $ref: '#/components/schemas/SlackConfigTestResult' }
|
||||||
|
required: [data]
|
||||||
|
|
||||||
BackupsConfigListResponse:
|
BackupsConfigListResponse:
|
||||||
allOf:
|
allOf:
|
||||||
- $ref: '#/components/schemas/ModuleConfigEnvelopeBase'
|
- $ref: '#/components/schemas/ModuleConfigEnvelopeBase'
|
||||||
|
|||||||
@@ -462,8 +462,7 @@ class authRoute
|
|||||||
$economic,
|
$economic,
|
||||||
(string)$cvr,
|
(string)$cvr,
|
||||||
$companyPhone,
|
$companyPhone,
|
||||||
(string)$invoiceEmail,
|
(string)$invoiceEmail
|
||||||
$exception
|
|
||||||
);
|
);
|
||||||
|
|
||||||
if ($recoveredCustomer !== null) {
|
if ($recoveredCustomer !== null) {
|
||||||
@@ -788,13 +787,10 @@ class authRoute
|
|||||||
economic $economic,
|
economic $economic,
|
||||||
string $cvr,
|
string $cvr,
|
||||||
int $customerNumber,
|
int $customerNumber,
|
||||||
string $invoiceEmail,
|
string $invoiceEmail
|
||||||
Exception $exception
|
|
||||||
): ?object {
|
): ?object {
|
||||||
if (!$this->isRecoverableEconomicDuplicateError($exception)) {
|
// The upstream POST can commit before the client receives a validation/transport error.
|
||||||
return null;
|
// Re-read by CVR and only recover when e-conomic confirms the requested customer number.
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
$economic_response = $this->searchEconomicCustomersByCvr($economic, $cvr);
|
$economic_response = $this->searchEconomicCustomersByCvr($economic, $cvr);
|
||||||
} catch (Exception $searchException) {
|
} catch (Exception $searchException) {
|
||||||
@@ -818,15 +814,6 @@ class authRoute
|
|||||||
return $matchingEconomicCustomer;
|
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
|
private function findEconomicCustomerByNumber(array $customers, int $customerNumber): ?object
|
||||||
{
|
{
|
||||||
foreach ($customers as $customer) {
|
foreach ($customers as $customer) {
|
||||||
|
|||||||
@@ -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 () {
|
$this->get('/backups/config', function () {
|
||||||
global $response;
|
global $response;
|
||||||
$this->requirePermission('backups_config');
|
$this->requirePermission('backups_config');
|
||||||
|
|||||||
@@ -6,9 +6,11 @@ it('registers Slack module config endpoints and customer registration webhook co
|
|||||||
|
|
||||||
expect($routeContent)->not->toBeFalse()
|
expect($routeContent)->not->toBeFalse()
|
||||||
->and($routeContent)->toContain('/slack/config')
|
->and($routeContent)->toContain('/slack/config')
|
||||||
|
->and($routeContent)->toContain('/slack/config/test')
|
||||||
->and($routeContent)->toContain("requirePermission('slack_config')")
|
->and($routeContent)->toContain("requirePermission('slack_config')")
|
||||||
->and($routeContent)->toContain("(new slack())->getConfig()->getConfigRequest()")
|
->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'));
|
$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'));
|
$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($variableContent)->toContain('Slack webhook URL used for successful customer registration notifications')
|
||||||
->and($slackClassContent)->not->toBeFalse()
|
->and($slackClassContent)->not->toBeFalse()
|
||||||
->and($slackClassContent)->toContain('send_customer_registration_notification')
|
->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($slackClassContent)->toContain('format_customer_registration')
|
||||||
->and($authRouteContent)->not->toBeFalse()
|
->and($authRouteContent)->not->toBeFalse()
|
||||||
->and($authRouteContent)->toContain("AUTH_REGISTER_CVR_SLACK_NOTIFICATION_FAILED")
|
->and($authRouteContent)->toContain("AUTH_REGISTER_CVR_SLACK_NOTIFICATION_FAILED")
|
||||||
->and($openApiContent)->not->toBeFalse()
|
->and($openApiContent)->not->toBeFalse()
|
||||||
->and($openApiContent)->toContain('/slack/config')
|
->and($openApiContent)->toContain('/slack/config')
|
||||||
|
->and($openApiContent)->toContain('/slack/config/test')
|
||||||
->and($openApiContent)->toContain('SlackConfigListResponse')
|
->and($openApiContent)->toContain('SlackConfigListResponse')
|
||||||
|
->and($openApiContent)->toContain('SlackConfigTestResponse')
|
||||||
->and($openApiContent)->toContain('SlackConfigEntry');
|
->and($openApiContent)->toContain('SlackConfigEntry');
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -0,0 +1,86 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
app_require('classes/slack.php');
|
||||||
|
|
||||||
|
use classes\slack;
|
||||||
|
|
||||||
|
final class SlackCustomerRegistrationWebhookFake extends slack
|
||||||
|
{
|
||||||
|
public array $messages = [];
|
||||||
|
|
||||||
|
public function __construct(
|
||||||
|
private readonly string $webhook,
|
||||||
|
private readonly string $sendResult = 'Message sent successfully. Response: ok'
|
||||||
|
) {
|
||||||
|
// Skip parent config loading for unit isolation.
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function get_customer_registration_webhook_url(): string
|
||||||
|
{
|
||||||
|
return $this->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');
|
||||||
|
});
|
||||||
@@ -591,6 +591,34 @@ namespace {
|
|||||||
assert_true(count(\classes\slack::$customer_registration_notifications) === 1, 'Duplicate create recovery must notify Slack once.');
|
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',
|
'name' => 'Fresh create mismatch returns conflict without local bootstrap or email',
|
||||||
'params' => $baseParams,
|
'params' => $baseParams,
|
||||||
|
|||||||
Reference in New Issue
Block a user