Files
fossbilling-mailgateway/tests/tracking.php
T

195 lines
10 KiB
PHP

<?php
declare(strict_types=1);
require __DIR__ . '/replies.php';
require dirname(__DIR__) . '/MessageTracker.php';
$connection = \Doctrine\DBAL\DriverManager::getConnection(['driver' => 'pdo_sqlite', 'memory' => true]);
$connection->executeStatement('CREATE TABLE mailgateway_message (message_key TEXT PRIMARY KEY, mailbox TEXT, message_id TEXT, payload_hash TEXT, ticket_id INTEGER, result_json TEXT)');
$connection->executeStatement('CREATE TABLE test_messages (id INTEGER PRIMARY KEY AUTOINCREMENT, ticket_id INTEGER)');
$trackingService = new class($connection) extends \Box\Mod\Mailgateway\Service {
public bool $fail = false;
public bool $mismatch = false;
public int $notifications = 0;
public function __construct(private \Doctrine\DBAL\Connection $connection) {}
public function import(array $data): array
{
$this->connection->insert('test_messages', ['ticket_id' => 41]);
if ($this->fail) {
throw new RuntimeException('Failure after ticket write');
}
return ['ticket_id' => 41, 'client_id' => 7];
}
public function reply(array $data): array
{
if ($data['ticket_id'] === 999) {
throw new \FOSSBilling\InformationException('Ticket not found');
}
if ($this->mismatch) {
throw new \FOSSBilling\InformationException('Sender does not own this ticket');
}
$this->connection->insert('test_messages', ['ticket_id' => $data['ticket_id']]);
return ['ticket_id' => $data['ticket_id'], 'message_id' => 12];
}
public function notifySenderMismatch(string $sender, int $ticketId): void { ++$this->notifications; }
};
$trackingDi = new \Pimple\Container();
$trackingDi['em'] = new class {
public int $clears = 0;
public function clear(): void { ++$this->clears; }
};
$trackingDi['mod_service'] = $trackingDi->protect(fn ($module) => new class {
public function getTicketById($id): void {}
});
$trackingService->setDi($trackingDi);
$tracker = new \Box\Mod\Mailgateway\MessageTracker($connection);
$data = ['mailbox' => 'support@dev.sebs.space', 'message_id' => '<initial@example.com>',
'sender_email' => 'customer@example.com', 'subject' => 'Test', 'content' => 'Test content'];
$initial = $tracker->process($data, $trackingService);
$duplicate = $tracker->process($data, $trackingService);
if ($initial['action'] !== 'ticket' || $initial['duplicate'] || !$duplicate['duplicate']
|| (int) $connection->fetchOne('SELECT COUNT(*) FROM test_messages') !== 1) {
throw new RuntimeException('Duplicate created another ticket');
}
$expectRejected = function (array $input) use ($tracker, $trackingService): void {
try {
$tracker->process($input, $trackingService);
throw new RuntimeException('Invalid tracked message accepted');
} catch (\FOSSBilling\InformationException) {}
};
$expectRejected(array_replace($data, ['content' => 'Changed contents']));
foreach (['bad-id', "<id@example.com>\r\nInjected", '<id example.com>'] as $id) {
$expectRejected(array_replace($data, ['message_id' => $id]));
}
$replyData = array_replace($data, ['message_id' => '<reply@example.com>', 'subject' => 'Re: [Ticket #41] Test']);
$result = $tracker->process($replyData, $trackingService);
if ($result['action'] !== 'reply' || $result['ticket_id'] !== 41) {
throw new RuntimeException('Subject failed to match ticket');
}
foreach ([
'Re: [Ticket #41] Updated subject',
'Fwd: [ticket #41] Test',
'[Ticket #41] Re: [Ticket #41] Test',
] as $index => $subject) {
$result = $tracker->process(array_replace($data, ['message_id' => '<subject-' . $index . '@example.com>', 'subject' => $subject]), $trackingService);
if ($result['action'] !== 'reply' || $result['ticket_id'] !== 41) {
throw new RuntimeException('Valid subject format failed');
}
}
foreach (['[Ticket #0]', '[Ticket #999999999999999999999999]', '[Ticket #41] [Ticket #42]', '[Ticket #bad]', '[Ticket #41', '[Ticket #999]', "Bad\r\nSubject"] as $index => $subject) {
$expectRejected(array_replace($data, ['message_id' => '<invalid-subject-' . $index . '@example.com>', 'subject' => $subject]));
}
$unthreaded = $tracker->process(array_replace($data, [
'message_id' => '<reference-removed@example.com>', 'subject' => 'A new request',
'in_reply_to' => '<initial@example.com>', 'references' => '<unknown@example.com>',
]), $trackingService);
if ($unthreaded['action'] !== 'ticket') {
throw new RuntimeException('Removed subject reference did not create a new request');
}
$duplicateReply = $tracker->process($replyData, $trackingService);
if (!$duplicateReply['duplicate'] || $duplicateReply['action'] !== 'reply') {
throw new RuntimeException('Repeated reply was not deduplicated');
}
$isolation = $tracker->process(array_replace($data, ['mailbox' => 'different@example.com']), $trackingService);
if ($isolation['duplicate']) {
throw new RuntimeException('Separate mailboxes incorrectly share deduplication state');
}
$count = (int) $connection->fetchOne('SELECT COUNT(*) FROM test_messages');
$trackingService->fail = true;
$failed = array_replace($data, ['message_id' => '<retry@example.com>']);
try {
$tracker->process($failed, $trackingService);
throw new LogicException('Simulated failure was swallowed');
} catch (RuntimeException $error) {
if ($error->getMessage() !== 'Failure after ticket write') {
throw $error;
}
}
if ((int) $connection->fetchOne('SELECT COUNT(*) FROM test_messages') !== $count) {
throw new RuntimeException('Partial write survived rollback');
}
$trackingService->fail = false;
$tracker->process($failed, $trackingService);
$tracker->process($failed, $trackingService);
if ((int) $connection->fetchOne('SELECT COUNT(*) FROM test_messages') !== $count + 1) {
throw new RuntimeException('Retry created duplicate writes');
}
$trackingService->mismatch = true;
$expectRejected(array_replace($replyData, ['message_id' => '<mismatch@example.com>']));
if ($trackingService->notifications !== 1 || $connection->isTransactionActive()) {
throw new RuntimeException('Mismatch notification or transaction cleanup failed');
}
echo "Passed persistent deduplication, conflicting payload rejection, subject matching, mailbox isolation, malformed/conflicting/missing ticket rejection, removed references, rollback/retry, and mismatch notifications.\n";
// Exercise the real rejection path with a queue spy that cannot be rolled back.
$rejectionConnection = \Doctrine\DBAL\DriverManager::getConnection(['driver' => 'pdo_sqlite', 'memory' => true]);
$rejectionConnection->executeStatement('CREATE TABLE mailgateway_message (message_key TEXT PRIMARY KEY, mailbox TEXT, message_id TEXT, payload_hash TEXT, ticket_id INTEGER, result_json TEXT)');
$rejectionConnection->executeStatement('CREATE TABLE mailgateway_notification (sender_key TEXT PRIMARY KEY, last_notified_at INTEGER NOT NULL)');
$rejectionConnection->executeStatement('CREATE TABLE client (id INTEGER PRIMARY KEY, email TEXT)');
$rejectionDi = new \Pimple\Container();
$rejectionDi['dbal'] = $rejectionConnection;
$rejectionEm = new class {
public int $clears = 0;
public function clear(): void { ++$this->clears; }
};
$rejectionDi['em'] = $rejectionEm;
$rejectionDi['logger'] = new class {
public function warning(...$args): void {}
};
$rejectionEmail = new class {
public array $attempts = [];
public bool $fail = false;
public function sendTemplate($data): bool {
$this->attempts[] = $data;
if ($this->fail) throw new RuntimeException('Queue unavailable');
return true;
}
};
$rejectionDi['mod_service'] = $rejectionDi->protect(fn ($module) => $module === 'email' ? $rejectionEmail : $support);
$rejectionLimiter = new class($rejectionConnection, $rejectionEm) {
public array $entries = [];
public function __construct(private $connection, private $em) {}
public function run($sender, $queue): bool {
$this->entries[] = ['active' => $this->connection->isTransactionActive(),
'tracked' => (int) $this->connection->fetchOne('SELECT COUNT(*) FROM mailgateway_message'),
'clears' => $this->em->clears];
return (new \Box\Mod\Mailgateway\NotificationCooldown($this->connection))->run($sender, $queue);
}
};
$rejectionDi['mailgateway_notification_cooldown'] = $rejectionLimiter;
$rejectionService = new \Box\Mod\Mailgateway\Service();
$rejectionService->setDi($rejectionDi);
$rejectionTracker = new \Box\Mod\Mailgateway\MessageTracker($rejectionConnection);
foreach (['guest', 'customer', 'ambiguous', 'queue-failure'] as $index => $case) {
$ticket->setClientId($case === 'guest' || $case === 'queue-failure' ? null : 7);
$ticket->setAuthorEmail('owner@example.com');
$rejectionConnection->executeStatement('DELETE FROM client');
$rejectionConnection->executeStatement('DELETE FROM mailgateway_notification');
$rejectionConnection->insert('client', ['id' => 8, 'email' => 'other@example.com']);
if ($case === 'ambiguous') {
$rejectionConnection->insert('client', ['id' => 7, 'email' => 'other@example.com']);
}
$rejectionEmail->fail = $case === 'queue-failure';
$input = ['mailbox' => 'support@example.com', 'message_id' => "<rejection-$index@example.com>",
'sender_email' => 'other@example.com', 'subject' => '[Ticket #4] Reply', 'content' => 'Rejected reply'];
try {
$rejectionTracker->process($input, $rejectionService);
throw new RuntimeException('Real mismatched reply accepted');
} catch (\FOSSBilling\InformationException $error) {
$expected = $ticket->isGuestTicket() ? 'Sender does not own this ticket' : 'Sender does not uniquely match the ticket owner';
if ($error->getMessage() !== $expected) throw $error;
}
if (count($rejectionEmail->attempts) !== $index + 1
|| count($rejectionLimiter->entries) !== $index + 1
|| $rejectionLimiter->entries[$index] !== ['active' => false, 'tracked' => 0, 'clears' => $index + 1]
|| $rejectionConnection->isTransactionActive()
|| (int) $rejectionConnection->fetchOne('SELECT COUNT(*) FROM mailgateway_notification') !== ($rejectionEmail->fail ? 0 : 1)
|| $rejectionEmail->attempts[$index] !== ['to' => 'other@example.com',
'code' => 'mailgateway_sender_mismatch', 'sender_email' => 'other@example.com', 'ticket_id' => 4]) {
throw new RuntimeException('Rejected reply did not attempt exactly one safe notification after rollback');
}
}
echo "Passed real guest/customer/ambiguous rejection paths, single post-rollback notification attempts, and queue-failure cleanup.\n";