diff --git a/library/Notifications/Integrations/Incident.php b/library/Notifications/Integrations/Incident.php new file mode 100644 index 000000000..28b0824c6 --- /dev/null +++ b/library/Notifications/Integrations/Incident.php @@ -0,0 +1,240 @@ + +// SPDX-License-Identifier: GPL-3.0-or-later + +namespace Icinga\Module\Notifications\Integrations; + +use DateTime; +use Exception; +use Generator; +use Icinga\Module\Notifications\Common\Database; +use Icinga\Module\Notifications\Model\Contact; +use Icinga\Module\Notifications\Model\Incident as IncidentModel; +use Icinga\Module\Notifications\Model\IncidentContact; +use ipl\Stdlib\Filter; + +class Incident +{ + protected IncidentModel $incident; + + public function __construct(IncidentModel $incident) + { + $this->incident = $incident; + } + + /** + * Add the contact as manager of the incident + * + * @param Contact $contact + * + * @return $this + */ + public function addManager(Contact $contact): static + { + $this->setRole($contact, 'manager'); + + return $this; + } + + /** + * Change the contact's role from manager to subscriber + * + * This has no effect if the contact is not a manager of the incident + * + * @param Contact $contact + * + * @return $this + */ + public function removeManager(Contact $contact): static + { + $existing = $this->fetchIncidentContact($contact); + if ($existing?->role !== 'manager') { + return $this; + } + + $this->setRole($contact, 'subscriber', $existing); + return $this; + } + + /** + * Add the contact as a subscriber + * + * This has no effect if the contact is already a subscriber or a manager + * + * @param Contact $contact + * + * @return $this + */ + public function addSubscriber(Contact $contact): static + { + $existing = $this->fetchIncidentContact($contact); + if ($existing?->role === 'subscriber' || $existing?->role === 'manager') { + return $this; + } + + $this->setRole($contact, 'subscriber', $existing); + return $this; + } + + /** + * Remove the contact as a subscriber + * + * This will delete the incident_contact row. Has no effect if the contact is not a subscriber. + * + * @param Contact $contact + * + * @return $this + */ + public function removeSubscriber(Contact $contact): static + { + $db = Database::get(); + $existing = $this->fetchIncidentContact($contact); + if ($existing === null || $existing->role !== 'subscriber') { + return $this; + } + + $db->beginTransaction(); + try { + $db->delete('incident_contact', [ + 'incident_id = ?' => $this->incident->id, + 'contact_id = ?' => $contact->id, + 'role = ?' => 'subscriber' + ]); + + $this->insertHistory($contact->id, 'subscriber', null); + } catch (Exception $e) { + $db->rollBackTransaction(); + throw $e; + } + + $db->commitTransaction(); + return $this; + } + + /** + * Get all managers of the incident + * + * @return Generator + */ + public function getManagers(): Generator + { + yield from $this->fetchContactsByRole('manager'); + } + + /** + * Get all subscribers of the incident + * + * @return Generator + */ + public function getSubscribers(): Generator + { + yield from $this->fetchContactsByRole('subscriber'); + } + + /** + * Get all contacts that were notified about this incident + * + * @return Generator + */ + public function getNotifiedContacts(): Generator + { + $query = Contact::on(Database::get()) + ->filter(Filter::all( + Filter::equal('incident_history.incident_id', $this->incident->id), + Filter::equal('incident_history.type', 'notified') + )); + $query->getSelectBase()->distinct(); + + yield from $query; + } + + public function isMuted(): bool + { + return $this->incident->mute_reason !== null; + } + + protected function setRole(Contact $contact, string $role, ?IncidentContact $existing = null): void + { + $db = Database::get(); + if ($existing === null) { + $existing = $this->fetchIncidentContact($contact); + } + + $oldRole = $existing?->role; + + if ($oldRole === $role) { + return; + } + + $db->beginTransaction(); + try { + if ($existing !== null) { + $db->update( + 'incident_contact', + ['role' => $role], + [ + 'incident_id = ?' => $this->incident->id, + 'contact_id = ?' => $contact->id + ] + ); + } else { + $db->insert('incident_contact', [ + 'incident_id' => $this->incident->id, + 'contact_id' => $contact->id, + 'role' => $role + ]); + } + + $this->insertHistory($contact->id, $oldRole, $role); + } catch (Exception $e) { + $db->rollBackTransaction(); + throw $e; + } + + $db->commitTransaction(); + } + + protected function fetchIncidentContact(Contact $contact): ?IncidentContact + { + /** @var ?IncidentContact $entry */ + $entry = IncidentContact::on(Database::get()) + ->filter(Filter::all( + Filter::equal('incident_id', $this->incident->id), + Filter::equal('contact_id', $contact->id) + )) + ->first(); + + return $entry; + } + + /** + * Fetch all contacts that have the given role + * + * @return Generator + */ + protected function fetchContactsByRole(string $role): Generator + { + yield from IncidentContact::on(Database::get()) + ->filter(Filter::all( + Filter::equal('incident_id', $this->incident->id), + Filter::equal('role', $role) + )); + } + + protected function insertHistory(int $contactId, ?string $oldRole, ?string $newRole): void + { + $now = new DateTime(); + Database::get()->insert( + 'incident_history', + [ + 'incident_id' => $this->incident->id, + 'contact_id' => $contactId, + 'type' => 'recipient_role_changed', + 'new_recipient_role' => $newRole, + 'old_recipient_role' => $oldRole, + 'time' => (int) $now->format('Uv') + ] + ); + } +} diff --git a/library/Notifications/Integrations/Incidents.php b/library/Notifications/Integrations/Incidents.php new file mode 100644 index 000000000..ec9e1a7a4 --- /dev/null +++ b/library/Notifications/Integrations/Incidents.php @@ -0,0 +1,97 @@ + +// SPDX-License-Identifier: GPL-3.0-or-later + +namespace Icinga\Module\Notifications\Integrations; + +use Generator; +use Icinga\Module\Notifications\Common\Database; +use Icinga\Module\Notifications\Model\Incident as IncidentModel; +use InvalidArgumentException; +use ipl\Orm\Query; +use ipl\Orm\ResultSet; +use ipl\Stdlib\Filter; +use IteratorAggregate; + +class Incidents implements IteratorAggregate +{ + /** @var string Hex-encoded SHA256 hash of the identifying source/tags */ + protected string $objectId; + + protected ?ResultSet $resultSet = null; + + /** + * Create new Incidents + * + * @param int $sourceId The id of the source that owns the object + * @param array $tags The complete identifying tags of the object + */ + public function __construct(int $sourceId, array $tags) + { + $this->objectId = self::objectId($sourceId, $tags); + } + + public function hasIncident(): bool + { + return $this->incidents()->hasResult(); + } + + /** + * @return Generator + */ + public function getIterator(): Generator + { + foreach ($this->incidents() as $incident) { + yield new Incident($incident); + } + } + + protected function incidents(): ResultSet + { + if ($this->resultSet === null) { + $this->resultSet = $this->buildQuery()->execute(); + } + + return $this->resultSet; + } + + protected function buildQuery(): Query + { + return IncidentModel::on(Database::get()) + ->with('object') + ->filter(Filter::equal('object_id', $this->objectId)); + } + + /** + * Compute the object id for a given source and tags + * + * Mirrors the daemon's ID function in icinga-notifications/internal/object/object.go so the + * resulting hash matches the value stored in object.id. + * + * @param int $sourceId + * @param array $tags + * + * @return string + */ + public static function objectId(int $sourceId, array $tags): string + { + if ($sourceId < 0) { + throw new InvalidArgumentException(sprintf('source id %d is negative', $sourceId)); + } + + $payload = pack('J', $sourceId); + + ksort($tags); + + // A minor bug in the daemon adds these bytes, but fixing it would break all existing object_id's + // so we reproduce it here. See: https://github.com/Icinga/icinga-notifications/issues/421 + $payload .= str_repeat("\0\0", count($tags)); + + foreach ($tags as $key => $value) { + $payload .= $key . "\0" . $value . "\0"; + } + + return hash('sha256', $payload); + } +} diff --git a/test/php/library/Notifications/Integrations/IncidentTest.php b/test/php/library/Notifications/Integrations/IncidentTest.php new file mode 100644 index 000000000..1b9f79bb5 --- /dev/null +++ b/test/php/library/Notifications/Integrations/IncidentTest.php @@ -0,0 +1,497 @@ + +// SPDX-License-Identifier: GPL-3.0-or-later + +namespace Tests\Icinga\Module\Notifications\Integrations; + +use Generator; +use Icinga\Module\Notifications\Common\Database; +use Icinga\Module\Notifications\Integrations\Incident; +use Icinga\Module\Notifications\Model\Contact; +use Icinga\Module\Notifications\Model\Incident as IncidentModel; +use Icinga\Module\Notifications\Model\IncidentContact; +use ipl\Sql\Connection; +use PDOStatement; +use PHPUnit\Framework\TestCase; +use ReflectionClass; +use RuntimeException; + +class IncidentTest extends TestCase +{ + private ?Connection $previousDatabaseInstance = null; + + /** + * Snapshots the {@see Database} singleton so each test can swap in a mock {@see Connection} via + * {@see self::injectDatabase()} without leaking state into the next test. + */ + protected function setUp(): void + { + $instance = (new ReflectionClass(Database::class))->getProperty('instance'); + $this->previousDatabaseInstance = $instance->getValue(); + } + + /** + * Restores the {@see Database} singleton to whatever it was before this test ran. + */ + protected function tearDown(): void + { + $instance = (new ReflectionClass(Database::class))->getProperty('instance'); + $instance->setValue(null, $this->previousDatabaseInstance); + $this->previousDatabaseInstance = null; + } + + public function testAddManagerInsertsIncidentContactWhenContactHasNoExistingRow(): void + { + $db = $this->createMock(Connection::class); + $db->expects($this->once())->method('beginTransaction'); + $db->expects($this->never())->method('update'); + $db->expects($this->exactly(2)) + ->method('insert') + ->willReturnCallback(function (string $table, array $data) { + $this->assertSame(42, $data['incident_id']); + $this->assertSame(7, $data['contact_id']); + + if ($table === 'incident_contact') { + $this->assertSame('manager', $data['role']); + } elseif ($table === 'incident_history') { + $this->assertSame('recipient_role_changed', $data['type']); + $this->assertSame('manager', $data['new_recipient_role']); + $this->assertNull($data['old_recipient_role']); + } else { + $this->fail(sprintf('Unexpected insert into %s', $table)); + } + + return $this->createStub(PDOStatement::class); + }); + $db->expects($this->once())->method('commitTransaction'); + $db->expects($this->never())->method('rollBackTransaction'); + + $this->injectDatabase($db); + + $this->incidentFor(self::incidentWithId(42), null) + ->addManager(self::contact(7)); + } + + public function testAddManagerUpdatesExistingIncidentContactRole(): void + { + $db = $this->createMock(Connection::class); + $db->expects($this->once())->method('beginTransaction'); + $db->expects($this->once()) + ->method('update') + ->willReturnCallback(function (string $table, array $data, array $where) { + $this->assertSame('incident_contact', $table); + $this->assertSame('manager', $data['role']); + $this->assertContains(42, $where); + $this->assertContains(7, $where); + + return $this->createStub(PDOStatement::class); + }); + $db->expects($this->once()) + ->method('insert') + ->willReturnCallback(function (string $table, array $data) { + $this->assertSame('incident_history', $table); + $this->assertSame(42, $data['incident_id']); + $this->assertSame(7, $data['contact_id']); + $this->assertSame('subscriber', $data['old_recipient_role']); + $this->assertSame('manager', $data['new_recipient_role']); + + return $this->createStub(PDOStatement::class); + }); + $db->expects($this->once())->method('commitTransaction'); + + $this->injectDatabase($db); + + $this->incidentFor(self::incidentWithId(42), self::incidentContact('subscriber', 7)) + ->addManager(self::contact(7)); + } + + public function testAddManagerIsNoopWhenContactIsAlreadyManager(): void + { + $db = $this->createMock(Connection::class); + $db->expects($this->never())->method('beginTransaction'); + $db->expects($this->never())->method('insert'); + $db->expects($this->never())->method('update'); + $db->expects($this->never())->method('commitTransaction'); + + $this->injectDatabase($db); + + $this->incidentFor(self::incidentWithId(42), self::incidentContact('manager', 7)) + ->addManager(self::contact(7)); + } + + public function testAddManagerRollsBackOnException(): void + { + $db = $this->createMock(Connection::class); + $db->expects($this->once())->method('beginTransaction'); + $db->expects($this->once()) + ->method('insert') + ->willThrowException(new RuntimeException('boom')); + $db->expects($this->once())->method('rollBackTransaction'); + $db->expects($this->never())->method('commitTransaction'); + + $this->injectDatabase($db); + + $this->expectException(RuntimeException::class); + + $this->incidentFor(self::incidentWithId(42), null) + ->addManager(self::contact(7)); + } + + public function testAddSubscriberInsertsRowWithSubscriberRole(): void + { + $db = $this->createMock(Connection::class); + $db->expects($this->once())->method('beginTransaction'); + $db->expects($this->exactly(2)) + ->method('insert') + ->willReturnCallback(function (string $table, array $data) { + $this->assertSame(42, $data['incident_id']); + $this->assertSame(7, $data['contact_id']); + + if ($table === 'incident_contact') { + $this->assertSame('subscriber', $data['role']); + } elseif ($table === 'incident_history') { + $this->assertSame('recipient_role_changed', $data['type']); + $this->assertSame('subscriber', $data['new_recipient_role']); + $this->assertNull($data['old_recipient_role']); + } + + return $this->createStub(PDOStatement::class); + }); + $db->expects($this->once())->method('commitTransaction'); + + $this->injectDatabase($db); + + $this->incidentFor(self::incidentWithId(42), null) + ->addSubscriber(self::contact(7)); + } + + public function testAddSubscriberIsNoopWhenContactIsAlreadySubscriber(): void + { + $db = $this->createMock(Connection::class); + $db->expects($this->never())->method('beginTransaction'); + $db->expects($this->never())->method('insert'); + $db->expects($this->never())->method('update'); + + $this->injectDatabase($db); + + $this->incidentFor(self::incidentWithId(42), self::incidentContact('subscriber', 7)) + ->addSubscriber(self::contact(7)); + } + + public function testAddSubscriberDoesNotDemoteAnExistingManager(): void + { + $db = $this->createMock(Connection::class); + $db->expects($this->never())->method('beginTransaction'); + $db->expects($this->never())->method('insert'); + $db->expects($this->never())->method('update'); + + $this->injectDatabase($db); + + $this->incidentFor(self::incidentWithId(42), self::incidentContact('manager', 7)) + ->addSubscriber(self::contact(7)); + } + + public function testRemoveManagerDemotesManagerToSubscriber(): void + { + $db = $this->createMock(Connection::class); + $db->expects($this->once())->method('beginTransaction'); + $db->expects($this->once()) + ->method('update') + ->willReturnCallback(function (string $table, array $data, array $where) { + $this->assertSame('incident_contact', $table); + $this->assertSame('subscriber', $data['role']); + $this->assertContains(42, $where); + $this->assertContains(7, $where); + + return $this->createStub(PDOStatement::class); + }); + $db->expects($this->once()) + ->method('insert') + ->willReturnCallback(function (string $table, array $data) { + $this->assertSame('incident_history', $table); + $this->assertSame(42, $data['incident_id']); + $this->assertSame(7, $data['contact_id']); + $this->assertSame('manager', $data['old_recipient_role']); + $this->assertSame('subscriber', $data['new_recipient_role']); + + return $this->createStub(PDOStatement::class); + }); + $db->expects($this->once())->method('commitTransaction'); + + $this->injectDatabase($db); + + $this->incidentFor(self::incidentWithId(42), self::incidentContact('manager', 7)) + ->removeManager(self::contact(7)); + } + + public function testRemoveManagerIsNoopWhenContactHasNoIncidentContact(): void + { + $db = $this->createMock(Connection::class); + $db->expects($this->never())->method('beginTransaction'); + $db->expects($this->never())->method('insert'); + $db->expects($this->never())->method('update'); + + $this->injectDatabase($db); + + $this->incidentFor(self::incidentWithId(42), null) + ->removeManager(self::contact(7)); + } + + public function testRemoveManagerIsNoopWhenContactIsSubscriber(): void + { + $db = $this->createMock(Connection::class); + $db->expects($this->never())->method('beginTransaction'); + $db->expects($this->never())->method('insert'); + $db->expects($this->never())->method('update'); + + $this->injectDatabase($db); + + $this->incidentFor(self::incidentWithId(42), self::incidentContact('subscriber', 7)) + ->removeManager(self::contact(7)); + } + + public function testRemoveSubscriberDeletesSubscriberRow(): void + { + $db = $this->createMock(Connection::class); + $db->expects($this->once())->method('beginTransaction'); + $db->expects($this->once()) + ->method('delete') + ->willReturnCallback(function (string $table, array $where) { + $this->assertSame('incident_contact', $table); + $this->assertContains(42, $where); + $this->assertContains(7, $where); + + return $this->createStub(PDOStatement::class); + }); + $db->expects($this->once()) + ->method('insert') + ->willReturnCallback(function (string $table, array $data) { + $this->assertSame('incident_history', $table); + $this->assertSame(42, $data['incident_id']); + $this->assertSame(7, $data['contact_id']); + $this->assertSame('subscriber', $data['old_recipient_role']); + $this->assertNull($data['new_recipient_role']); + + return $this->createStub(PDOStatement::class); + }); + $db->expects($this->once())->method('commitTransaction'); + + $this->injectDatabase($db); + + $this->incidentFor(self::incidentWithId(42), self::incidentContact('subscriber', 7)) + ->removeSubscriber(self::contact(7)); + } + + public function testRemoveSubscriberIsNoopWhenContactIsNotSubscribed(): void + { + $db = $this->createMock(Connection::class); + $db->expects($this->never())->method('beginTransaction'); + $db->expects($this->never())->method('delete'); + $db->expects($this->never())->method('insert'); + + $this->injectDatabase($db); + + $this->incidentFor(self::incidentWithId(42), self::incidentContact('manager', 7)) + ->removeSubscriber(self::contact(7)); + } + + public function testRemoveSubscriberIsNoopWhenContactHasNoIncidentContact(): void + { + $db = $this->createMock(Connection::class); + $db->expects($this->never())->method('beginTransaction'); + $db->expects($this->never())->method('delete'); + + $this->injectDatabase($db); + + $this->incidentFor(self::incidentWithId(42), null) + ->removeSubscriber(self::contact(7)); + } + + public function testIsMutedIsTrueWhenIncidentHasMuteReason(): void + { + $model = new IncidentModel(); + $model->mute_reason = 'down for maintenance'; + + $this->assertTrue((new Incident($model))->isMuted()); + } + + public function testIsMutedIsFalseWhenIncidentHasNoMuteReason(): void + { + $model = new IncidentModel(); + $model->mute_reason = null; + + $this->assertFalse((new Incident($model))->isMuted()); + } + + public function testGetSubscribersAsksFetchContactsByRoleForSubscribers(): void + { + $a = self::incidentContact('subscriber', 1); + $b = self::incidentContact('subscriber', 2); + + $incident = $this->incidentReturning(self::incidentWithId(42), 'subscriber', [$a, $b]); + + $this->assertSame([$a, $b], iterator_to_array($incident->getSubscribers(), false)); + } + + public function testGetSubscribersIsEmptyWhenNoSubscribersExist(): void + { + $incident = $this->incidentReturning(self::incidentWithId(42), 'subscriber', []); + + $this->assertSame([], iterator_to_array($incident->getSubscribers(), false)); + } + + public function testGetSubscribersIncludesContactgroupAndScheduleEntries(): void + { + $contactSub = new IncidentContact(); + $contactSub->role = 'subscriber'; + $contactSub->contact_id = 7; + + $groupSub = new IncidentContact(); + $groupSub->role = 'subscriber'; + $groupSub->contactgroup_id = 8; + + $scheduleSub = new IncidentContact(); + $scheduleSub->role = 'subscriber'; + $scheduleSub->schedule_id = 9; + + $incident = $this->incidentReturning( + self::incidentWithId(42), + 'subscriber', + [$contactSub, $groupSub, $scheduleSub] + ); + + $this->assertCount(3, iterator_to_array($incident->getSubscribers(), false)); + } + + public function testGetManagersAsksFetchContactsByRoleForManagers(): void + { + $a = self::incidentContact('manager', 1); + $b = self::incidentContact('manager', 2); + + $incident = $this->incidentReturning(self::incidentWithId(42), 'manager', [$a, $b]); + + $this->assertSame([$a, $b], iterator_to_array($incident->getManagers(), false)); + } + + public function testGetManagersIsEmptyWhenNoManagersExist(): void + { + $incident = $this->incidentReturning(self::incidentWithId(42), 'manager', []); + + $this->assertSame([], iterator_to_array($incident->getManagers(), false)); + } + + public function testGetManagersIncludesContactgroupAndScheduleEntries(): void + { + $contactMgr = new IncidentContact(); + $contactMgr->role = 'manager'; + $contactMgr->contact_id = 7; + + $groupMgr = new IncidentContact(); + $groupMgr->role = 'manager'; + $groupMgr->contactgroup_id = 8; + + $scheduleMgr = new IncidentContact(); + $scheduleMgr->role = 'manager'; + $scheduleMgr->schedule_id = 9; + + $incident = $this->incidentReturning( + self::incidentWithId(42), + 'manager', + [$contactMgr, $groupMgr, $scheduleMgr] + ); + + $this->assertCount(3, iterator_to_array($incident->getManagers(), false)); + } + + private static function incidentContact(string $role, int $contactId): IncidentContact + { + $entry = new IncidentContact(); + $entry->role = $role; + $entry->contact_id = $contactId; + + return $entry; + } + + private static function contact(int $id): Contact + { + $contact = new Contact(); + $contact->id = $id; + + return $contact; + } + + private static function incidentWithId(int $id): IncidentModel + { + $model = new IncidentModel(); + $model->id = $id; + + return $model; + } + + /** + * Build an Incident integration whose existing-row lookup is bypassed with the supplied IncidentContact. + * + * The override avoids hitting the ORM SELECT path so write-method tests only need to mock + * Connection::insert/update/delete and the transaction methods. Pass null to model "no existing row". + */ + private function incidentFor(IncidentModel $model, ?IncidentContact $existing): Incident + { + return new class ($model, $existing) extends Incident { + private ?IncidentContact $existing; + + public function __construct(IncidentModel $incident, ?IncidentContact $existing) + { + parent::__construct($incident); + $this->existing = $existing; + } + + protected function fetchIncidentContact(Contact $contact): ?IncidentContact + { + return $this->existing; + } + }; + } + + /** + * Build an Incident integration whose role lookup is bypassed with the supplied rows. + * + * The override yields {@see $rows} when {@see Incident::fetchContactsByRole()} is called with + * {@see $expectedRole}, and fails the test for any other role. This avoids the ORM SELECT path so + * reader tests stay self-contained, and verifies the unit-under-test asks for the expected role. + * + * @param IncidentContact[] $rows + */ + private function incidentReturning(IncidentModel $model, string $expectedRole, array $rows): Incident + { + return new class ($model, $expectedRole, $rows) extends Incident { + private string $expectedRole; + /** @var IncidentContact[] */ + private array $rows; + + public function __construct(IncidentModel $incident, string $expectedRole, array $rows) + { + parent::__construct($incident); + $this->expectedRole = $expectedRole; + $this->rows = $rows; + } + + protected function fetchContactsByRole(string $role): Generator + { + TestCase::assertSame($this->expectedRole, $role); + yield from $this->rows; + } + }; + } + + /** + * Replace the {@see Database} singleton with the given connection so the unit under test hits the mock. + * + * Restored by tearDown via the snapshot taken in setUp. + */ + private function injectDatabase(Connection $db): void + { + $instance = (new ReflectionClass(Database::class))->getProperty('instance'); + $instance->setValue(null, $db); + } +} diff --git a/test/php/library/Notifications/Integrations/IncidentsTest.php b/test/php/library/Notifications/Integrations/IncidentsTest.php new file mode 100644 index 000000000..5921af4e4 --- /dev/null +++ b/test/php/library/Notifications/Integrations/IncidentsTest.php @@ -0,0 +1,167 @@ + +// SPDX-License-Identifier: GPL-3.0-or-later + +namespace Tests\Icinga\Module\Notifications\Integrations; + +use Icinga\Module\Notifications\Common\Database; +use Icinga\Module\Notifications\Integrations\Incidents; +use InvalidArgumentException; +use ipl\Orm\Query; +use ipl\Sql\Connection; +use ipl\Stdlib\Filter; +use PHPUnit\Framework\Attributes\DataProvider; +use PHPUnit\Framework\TestCase; +use ReflectionClass; + +class IncidentsTest extends TestCase +{ + private ?Connection $previousDatabaseInstance = null; + + /** + * Snapshots the {@see Database} singleton so wiring tests can inject a stub {@see Connection} + * without leaking state into the next test. + */ + protected function setUp(): void + { + $instance = (new ReflectionClass(Database::class))->getProperty('instance'); + $this->previousDatabaseInstance = $instance->getValue(); + } + + /** + * Restores the {@see Database} singleton to whatever it was before this test ran. + */ + protected function tearDown(): void + { + $instance = (new ReflectionClass(Database::class))->getProperty('instance'); + $instance->setValue(null, $this->previousDatabaseInstance); + $this->previousDatabaseInstance = null; + } + + /** + * Pinned vectors taken from an actual daemon's writes to a notifications database; each + * `(source_id, tags) => hex` row was confirmed against the stored object.id. If a test here + * fails, either the PHP hash function drifted or the daemon's hash function changed — + * investigate which before "fixing" either side, because the stored object.id values in + * production databases depend on this. + * + * @return array, 2: string}> + */ + public static function knownHashVectors(): array + { + return [ + 'single tag (host only)' => [ + 1, + ['host' => 'icinga2'], + 'f2c11c8086bacbc6674b5e9e68fa5e7c9f8200be1e2e50825ee08a86bb301d13' + ], + 'multiple tags (http svc)' => [ + 1, + ['host' => 'icinga2', 'service' => 'http'], + '58a0b5785f2ae8974f1c698ff0eb579b8b63c4110f991ce6286caa60d041c056' + ], + 'multiple tags (procs svc)' => [ + 1, + ['host' => 'icinga2', 'service' => 'procs'], + '7ecdee4e3dd82abc27aab1661b776b611c1a65a02594c423a1868daba8c99e8b' + ], + ]; + } + + /** + * @param array $tags + */ + #[DataProvider('knownHashVectors')] + public function testObjectIdMatchesDaemonHash(int $sourceId, array $tags, string $expectedHex): void + { + $this->assertSame($expectedHex, Incidents::objectId($sourceId, $tags)); + } + + public function testObjectIdIsIndependentOfTagInsertionOrder(): void + { + $a = Incidents::objectId(1, ['host' => 'icinga2', 'service' => 'http']); + $b = Incidents::objectId(1, ['service' => 'http', 'host' => 'icinga2']); + + $this->assertSame($a, $b); + } + + public function testObjectIdThrowsForNegativeSourceId(): void + { + $this->expectException(InvalidArgumentException::class); + + Incidents::objectId(-1, ['host' => 'icinga2']); + } + + public function testBuildQueryFiltersOnObjectIdWithComputedHash(): void + { + $this->injectDatabase($this->createStub(Connection::class)); + + $sourceId = 1; + $tags = ['host' => 'icinga2', 'service' => 'http']; + $expectedHash = Incidents::objectId($sourceId, $tags); + + $query = $this->inspectableIncidents($sourceId, $tags)->exposedBuildQuery(); + $equals = self::flattenEqualRules($query->getFilter()); + + $matched = false; + foreach ($equals as $rule) { + if ($rule->getColumn() === 'object_id' && $rule->getValue() === $expectedHash) { + $matched = true; + break; + } + } + + $this->assertTrue( + $matched, + 'Expected the built query to carry a Filter\Equal on object_id with the computed hash' + ); + } + + /** + * Recursively collect every {@see Filter\Equal} rule below the given rule. + * + * @return Filter\Equal[] + */ + private static function flattenEqualRules(Filter\Rule $rule): array + { + if ($rule instanceof Filter\Equal) { + return [$rule]; + } + + $collected = []; + if ($rule instanceof Filter\Chain) { + foreach ($rule as $child) { + $collected = array_merge($collected, self::flattenEqualRules($child)); + } + } + + return $collected; + } + + /** + * Build an Incidents instance that exposes its protected {@see Incidents::buildQuery()} via a + * public passthrough, so the test can inspect the resulting query without running it. + * + * @param array $tags + */ + private function inspectableIncidents(int $sourceId, array $tags): Incidents + { + return new class ($sourceId, $tags) extends Incidents { + public function exposedBuildQuery(): Query + { + return $this->buildQuery(); + } + }; + } + + /** + * Replace the {@see Database} singleton with the given connection so the unit under test hits + * the stub. Restored by tearDown via the snapshot taken in setUp. + */ + private function injectDatabase(Connection $db): void + { + $instance = (new ReflectionClass(Database::class))->getProperty('instance'); + $instance->setValue(null, $db); + } +}