Compare commits

..
12 changed files with 458 additions and 26 deletions
+54 -2
View File
@@ -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.";
}
}
@@ -2,7 +2,8 @@
namespace email\templates;
use email\helpers\email_template;use objects\users_o;
use email\helpers\email_template;
use objects\users_o;
class email_template_new_customer
{
@@ -52,6 +53,7 @@ class email_template_new_customer
*/
public function generate_html(): string
{
$customer_label = htmlspecialchars($this->getCustomerRegistrationLabel(), ENT_QUOTES, 'UTF-8');
ob_start();
# Start of the html
?>
@@ -73,7 +75,7 @@ class email_template_new_customer
<!-- Intro -->
<p class="container-text-md" style="color:#000000;font-size:16px;line-height:1.5;margin:0 0 18px 0;mso-line-height-rule:exactly;">
Tak for din registrering af <?=((new users_o())->getCustomerName((int)$this->customer_number))?><?=(((new users_o())->getCustomerEcocomicData((int)$this->customer_number)->economic_customer->corporateIdentificationNumber) ? ' (' . (new users_o())->getCustomerEcocomicData((int)$this->customer_number)->economic_customer->corporateIdentificationNumber . ')' : '')?> som kunde hos Truck Wash.
Tak for din registrering af <?=$customer_label?> som kunde hos Truck Wash.
</p>
<!-- You can now wash your trucks -->
@@ -185,4 +187,19 @@ class email_template_new_customer
# End of the html
return ob_get_clean();
}
private function getCustomerRegistrationLabel(): string
{
$customer = (new users_o())->getUserByCustomerNumber($this->customer_number);
$customer_name = trim((string)($customer->getCustomerName($this->customer_number) ?? ''));
$customer_label = $customer_name === '' ? 'virksomhed (CVR)' : $customer_name;
$customer->getCustomerEcocomicData($this->customer_number);
$corporate_identification_number = trim((string)($customer->economic_customer->corporateIdentificationNumber ?? ''));
if ($corporate_identification_number !== '') {
$customer_label .= ' (' . $corporate_identification_number . ')';
}
return $customer_label;
}
}
+7 -1
View File
@@ -527,6 +527,10 @@ class users_o extends db
public function getCustomerEcocomicData(?int $customer_number = null): users_o
{
if ($customer_number !== null && !isset($this->id)) {
$this->getUserByCustomerNumber($customer_number);
}
// Check if the customer number is set
if (!isset($this->customer_number) && $customer_number === null) {
return $this;
@@ -538,7 +542,9 @@ class users_o extends db
return $this;
}
$cachedCustomer = $this->getCached('economic_customer');
$cachedCustomer = isset($this->id) && $this->id > 0
? $this->getCached('economic_customer')
: null;
if (is_object($cachedCustomer)) {
$cachedCustomerNumber = (int)($cachedCustomer->customerNumber ?? $cachedCustomer->customer_number ?? 0);
if ($cachedCustomerNumber === $customer_number) {
+33
View File
@@ -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'
+30 -20
View File
@@ -440,13 +440,36 @@ class authRoute
);
}
// Get the CVR company information used for the e-conomic customer payload.
$companyInformation = null;
try {
$companyInformation = (new virkdata())->getCompanyInformation((string)$cvr, '', []);
} catch (Exception $exception) {
$this->logRegisterCvrIssue('AUTH_REGISTER_CVR_LOOKUP_FAILED', [
'phase' => 'cvr_lookup',
'cvr' => (string)$cvr,
'requestedCustomerNumber' => $companyPhone,
'message' => $exception->getMessage(),
]);
$response->error('CVR could not be verified. Please check the CVR number and try again.', 400);
return;
}
$name = trim((string)($companyInformation->name ?? ''));
if ($name === '') {
$this->logRegisterCvrIssue('AUTH_REGISTER_CVR_LOOKUP_INVALID_RESPONSE', [
'phase' => 'cvr_lookup',
'cvr' => (string)$cvr,
'requestedCustomerNumber' => $companyPhone,
]);
$response->error('CVR could not be verified. Please check the CVR number and try again.', 400);
return;
}
if ($localUserExists) {
$response->error('Company phone number already registered', 400);
}
// Get the CVR company information used for the e-conomic customer payload.
$companyInformation = (new virkdata())->getCompanyInformation($cvr, '', []);
$name = (string)($companyInformation->name ?? '');
try {
$result = $economic->createCustomer(
(int)$companyPhone,
@@ -462,8 +485,7 @@ class authRoute
$economic,
(string)$cvr,
$companyPhone,
(string)$invoiceEmail,
$exception
(string)$invoiceEmail
);
if ($recoveredCustomer !== null) {
@@ -788,13 +810,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 +837,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) {
@@ -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');
@@ -2,6 +2,7 @@
return [
['path' => 'tests/auth/CreateTokenUserNotFoundTest.php', 'classification' => 'unit', 'type' => 'script'],
['path' => 'tests/auth/NewCustomerEmailTemplateTest.php', 'classification' => 'unit', 'type' => 'script'],
['path' => 'tests/auth/PasskeyChallengeTest.php', 'classification' => 'unit', 'type' => 'script'],
['path' => 'tests/auth/PemToCoseConversionTest.php', 'classification' => 'unit', 'type' => 'script'],
['path' => 'tests/auth/RegisterCvrTest.php', 'classification' => 'unit', 'type' => 'script'],
@@ -0,0 +1,7 @@
<?php
it('renders the new customer welcome template without unselected user access or leaked output', function (): void {
$result = run_legacy_script('tests/auth/NewCustomerEmailTemplateTest.php');
expect($result['exitCode'])->toBe(0, $result['output']);
});
@@ -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');
});
@@ -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');
});
@@ -0,0 +1,99 @@
<?php
namespace {
if (!defined('WD')) {
define('WD', dirname(__DIR__, 2));
}
}
namespace objects {
class users_o
{
public static array $calls = [];
private bool $selected = false;
public object $economic_customer;
public function getUserByCustomerNumber(int $customer_number): self
{
self::$calls[] = 'select:' . $customer_number;
$this->selected = true;
return $this;
}
public function getCustomerName(int $customer_number): ?string
{
if (!$this->selected) {
throw new \RuntimeException('Customer name requested before local customer selection.');
}
self::$calls[] = 'name:' . $customer_number;
return 'KING FOOD DANMARK A/S';
}
public function getCustomerEcocomicData(?int $customer_number = null): self
{
if (!$this->selected) {
throw new \RuntimeException('Economic customer requested before local customer selection.');
}
self::$calls[] = 'economic:' . (int)$customer_number;
$this->economic_customer = (object)[
'corporateIdentificationNumber' => '12345678',
];
return $this;
}
}
}
namespace {
require_once WD . '/modules/email/helpers/email_template.php';
require_once WD . '/modules/email/templates/email_template_new_customer.php';
function assert_true(bool $condition, string $message): void
{
if (!$condition) {
throw new \RuntimeException($message);
}
}
function cleanup_buffers_to(int $base_level): string
{
$output = '';
while (ob_get_level() > $base_level) {
$output .= (string)ob_get_clean();
}
return $output;
}
$base_level = ob_get_level();
ob_start();
try {
$html = (new \email\templates\email_template_new_customer(
12345678,
'https://truckwash.io/auth/password-reset/mock-token',
))->generate_html();
$leaked_output = cleanup_buffers_to($base_level);
assert_true($leaked_output === '', 'Template generation must not leak buffered HTML output.');
assert_true(
str_contains($html, 'Tak for din registrering af KING FOOD DANMARK A/S (12345678) som kunde hos Truck Wash.'),
'Template must render the selected customer name and CVR in the welcome intro.'
);
assert_true(
\objects\users_o::$calls === ['select:12345678', 'name:12345678', 'economic:12345678'],
'Template must select the local customer before reading customer details.'
);
} catch (\Throwable $exception) {
$leaked_output = cleanup_buffers_to($base_level);
fwrite(STDERR, $leaked_output);
fwrite(STDERR, $exception->getMessage() . PHP_EOL);
exit(1);
}
echo "\033[32m[PASS]\033[0m New customer email template renders without leaked output.\n";
exit(0);
}
@@ -142,9 +142,14 @@ namespace classes {
public static int $mock_zipcode = 2630;
public static string $mock_city = 'Taastrup';
public static string $mock_website = 'https://demo.test';
public static ?\RuntimeException $mock_exception = null;
public function getCompanyInformation($cvr, $endpoint, $data): object
{
if (self::$mock_exception !== null) {
throw self::$mock_exception;
}
$result = new \stdClass();
$result->name = self::$mock_name;
$result->address = self::$mock_address;
@@ -419,6 +424,58 @@ namespace {
'expected_error' => 'Parameter cvr must be at least 8 characters long',
'expected_status' => 400,
],
[
'name' => 'CVR lookup failure returns validation error without creating customer',
'params' => array_merge($baseParams, ['cvr' => '11111112']),
'setup' => static function (): void {
\classes\virkdata::$mock_exception = new \RuntimeException('An error occurred');
},
'expected_error' => 'CVR could not be verified. Please check the CVR number and try again.',
'expected_status' => 400,
'assert' => static function (): void {
assert_true(count(\classes\economic::$create_calls) === 0, 'CVR lookup failures must not create e-conomic customers.');
assert_true(count(\classes\email::$sent) === 0, 'CVR lookup failures must not send welcome emails.');
assert_true(count(\classes\email::$superuser_notifications) === 0, 'CVR lookup failures must not send superuser notifications.');
assert_true(count(\classes\slack::$customer_registration_notifications) === 0, 'CVR lookup failures must not send Slack customer registration notifications.');
assert_true(count(\objects\logs_o::$entries) === 1, 'CVR lookup failures should be logged for diagnostics.');
assert_true(\objects\logs_o::$entries[0]['event'] === 'AUTH_REGISTER_CVR_LOOKUP_FAILED', 'CVR lookup failure should use the lookup failure log event.');
},
],
[
'name' => 'CVR lookup without company name returns validation error without creating customer',
'params' => array_merge($baseParams, ['cvr' => '11111112']),
'setup' => static function (): void {
\classes\virkdata::$mock_name = '';
},
'expected_error' => 'CVR could not be verified. Please check the CVR number and try again.',
'expected_status' => 400,
'assert' => static function (): void {
assert_true(count(\classes\economic::$create_calls) === 0, 'CVR lookup responses without a company name must not create e-conomic customers.');
assert_true(count(\classes\email::$sent) === 0, 'CVR lookup responses without a company name must not send welcome emails.');
assert_true(count(\classes\email::$superuser_notifications) === 0, 'CVR lookup responses without a company name must not send superuser notifications.');
assert_true(count(\classes\slack::$customer_registration_notifications) === 0, 'CVR lookup responses without a company name must not send Slack customer registration notifications.');
assert_true(count(\objects\logs_o::$entries) === 1, 'CVR lookup responses without a company name should be logged for diagnostics.');
assert_true(\objects\logs_o::$entries[0]['event'] === 'AUTH_REGISTER_CVR_LOOKUP_INVALID_RESPONSE', 'CVR lookup response without a company name should use the invalid response log event.');
},
],
[
'name' => 'CVR lookup failure takes precedence over local customer number collision',
'params' => array_merge($baseParams, ['cvr' => '11111112']),
'setup' => static function (): void {
\classes\virkdata::$mock_exception = new \RuntimeException('An error occurred');
\objects\users_o::$mock_existing_customer_numbers = [12345678];
},
'expected_error' => 'CVR could not be verified. Please check the CVR number and try again.',
'expected_status' => 400,
'assert' => static function (): void {
assert_true(count(\classes\economic::$create_calls) === 0, 'CVR lookup failures with local collisions must not create e-conomic customers.');
assert_true(count(\classes\email::$sent) === 0, 'CVR lookup failures with local collisions must not send welcome emails.');
assert_true(count(\classes\email::$superuser_notifications) === 0, 'CVR lookup failures with local collisions must not send superuser notifications.');
assert_true(count(\classes\slack::$customer_registration_notifications) === 0, 'CVR lookup failures with local collisions must not send Slack customer registration notifications.');
assert_true(count(\objects\logs_o::$entries) === 1, 'CVR lookup failures with local collisions should be logged once.');
assert_true(\objects\logs_o::$entries[0]['event'] === 'AUTH_REGISTER_CVR_LOOKUP_FAILED', 'CVR lookup failure should not be masked by the local duplicate check.');
},
],
[
'name' => 'Existing company phone with local customer stays blocked',
'params' => $baseParams,
@@ -591,6 +648,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,
@@ -626,6 +711,7 @@ namespace {
\classes\virkdata::$mock_zipcode = 2630;
\classes\virkdata::$mock_city = 'Taastrup';
\classes\virkdata::$mock_website = 'https://demo.test';
\classes\virkdata::$mock_exception = null;
\objects\users_o::reset();
\objects\logs_o::reset();