icingaweb2-module-director/test/php/library/Director/Objects/MigrateCommandTest.php
Ravi Srinivasa 8abacb694f
test: Add test suite and documentation for custom variable and dictionary support
Tests covering:
- CustomVariable / CustomVariables: rendering, config string generation,
  dictionary and array type behaviour
- CustomVariableDictionaryRendering: Icinga 2 DSL output for nested dicts
- IcingaConfigHelper: macro validation and structured variable rendering
- DirectorProperty: CRUD, inheritance, field assignment
- IcingaHost / IcingaService / IcingaServiceApplyFor: object-level
  custom variable assignment and apply-for rule evaluation
- BasketSnapshotCustomVariable: round-trip basket import/export
- MigrateCommand: migration logic, --delete, --dry-run, skipped fields
- CustomVariableForm / CustomVariablesForm / DeleteCustomVariableForm:
  form submission and validation
- Rendered output fixtures updated for service5/6/7 and new
  host_dynamic_dict, service_apply_for_array, service_apply_for_dict

Documentation:
- Dictionary-Support-Changes.md: full feature overview and API reference
- custom-variables-demo.sh: end-to-end shell demo script
2026-06-01 12:32:15 +02:00

507 lines
17 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<?php
// SPDX-FileCopyrightText: 2026 Icinga GmbH <https://icinga.com>
// SPDX-License-Identifier: GPL-3.0-or-later
namespace Tests\Icinga\Module\Director\Objects;
use Icinga\Module\Director\Db;
use Icinga\Module\Director\Db\DbUtil;
use Icinga\Module\Director\Objects\DirectorDatafield;
use Icinga\Module\Director\Objects\DirectorDatafieldCategory;
use Icinga\Module\Director\Objects\DirectorDatalist;
use Icinga\Module\Director\Test\BaseTestCase;
use Ramsey\Uuid\Uuid;
use Tests\Icinga\Module\Director\Objects\Lib\TestableMigrateCommand;
class MigrateCommandTest extends BaseTestCase
{
private const PREFIX = '___TEST___';
// Migratable datafield varnames
private const VAR_ENV = self::PREFIX . 'env';
private const VAR_HTTP_VHOSTS = self::PREFIX . 'http_vhosts';
private const VAR_CHECK_INTERVAL = self::PREFIX . 'check_interval';
private const VAR_ENV_CHOICES = self::PREFIX . 'env_choices';
private const VAR_ENV_SUGGEST = self::PREFIX . 'env_suggest';
// Non-migratable datafield varnames
private const VAR_SQL_QUERY = self::PREFIX . 'sql_query_field';
private const VAR_CATEGORIZED = self::PREFIX . 'categorized_field';
private const VAR_HIDDEN = self::PREFIX . 'hidden_field';
private const VAR_DUP = self::PREFIX . 'dup_field';
private const LIST_NAME = self::PREFIX . 'migrate_list';
private const CAT_NAME = self::PREFIX . 'migrate_category';
private const MIGRATABLE = [
self::VAR_ENV,
self::VAR_HTTP_VHOSTS,
self::VAR_CHECK_INTERVAL,
self::VAR_ENV_CHOICES,
self::VAR_ENV_SUGGEST,
];
private const ALL_TEST_VARS = [
self::VAR_ENV,
self::VAR_HTTP_VHOSTS,
self::VAR_CHECK_INTERVAL,
self::VAR_ENV_CHOICES,
self::VAR_ENV_SUGGEST,
self::VAR_SQL_QUERY,
self::VAR_CATEGORIZED,
self::VAR_HIDDEN,
self::VAR_DUP,
];
public function testDryRunPrintsWhatWouldMigrateWithoutWriting(): void
{
if ($this->skipForMissingDb()) {
return;
}
$db = $this->getDb();
$this->createAllFixtures($db);
$cmd = new TestableMigrateCommand($db, ['--dry-run']);
$output = $cmd->runDatafields();
foreach (self::MIGRATABLE as $varname) {
$this->assertStringContainsString(
$varname,
$output,
"Dry-run output must list '$varname' as migratable"
);
}
$dba = $db->getDbAdapter();
foreach (self::MIGRATABLE as $varname) {
$count = $dba->fetchOne(
$dba->select()->from('director_property', ['cnt' => 'COUNT(*)'])->where('key_name = ?', $varname)
);
$this->assertEquals(0, (int) $count, "Dry-run must not create director_property for '$varname'");
}
}
public function testLiveMigrationCreatesDirectorPropertyRows(): void
{
if ($this->skipForMissingDb()) {
return;
}
$db = $this->getDb();
$this->createAllFixtures($db);
$cmd = new TestableMigrateCommand($db);
$cmd->runDatafields();
$dba = $db->getDbAdapter();
foreach (self::MIGRATABLE as $varname) {
$count = $dba->fetchOne(
$dba->select()->from('director_property', ['cnt' => 'COUNT(*)'])->where('key_name = ?', $varname)
);
$this->assertEquals(1, (int) $count, "Migration must create director_property for '$varname'");
}
}
public function testArrayDatafieldMigratesAsDynamicArray(): void
{
if ($this->skipForMissingDb()) {
return;
}
$db = $this->getDb();
$this->createAllFixtures($db);
$cmd = new TestableMigrateCommand($db);
$cmd->runDatafields();
$dba = $db->getDbAdapter();
$row = $dba->fetchRow(
$dba->select()->from('director_property', ['value_type'])->where('key_name = ?', self::VAR_HTTP_VHOSTS)
);
$this->assertNotFalse($row, 'http_vhosts property must be created');
$this->assertEquals('dynamic-array', $row->value_type);
}
public function testDatalistStrictMigratesCorrectly(): void
{
if ($this->skipForMissingDb()) {
return;
}
$db = $this->getDb();
$this->createAllFixtures($db);
$cmd = new TestableMigrateCommand($db);
$cmd->runDatafields();
$dba = $db->getDbAdapter();
$row = $dba->fetchRow(
$dba->select()->from('director_property', ['value_type'])->where('key_name = ?', self::VAR_ENV_CHOICES)
);
$this->assertNotFalse($row, 'env_choices property must be created');
$this->assertEquals('datalist-strict', $row->value_type);
}
public function testDatalistNonStrictMigratesCorrectly(): void
{
if ($this->skipForMissingDb()) {
return;
}
$db = $this->getDb();
$this->createAllFixtures($db);
$cmd = new TestableMigrateCommand($db);
$cmd->runDatafields();
$dba = $db->getDbAdapter();
$row = $dba->fetchRow(
$dba->select()->from('director_property', ['value_type'])->where('key_name = ?', self::VAR_ENV_SUGGEST)
);
$this->assertNotFalse($row, 'env_suggest property must be created');
$this->assertEquals('datalist-non-strict', $row->value_type);
}
public function testDeleteOptionRemovesMigratedDatafields(): void
{
if ($this->skipForMissingDb()) {
return;
}
$db = $this->getDb();
$this->createAllFixtures($db);
$cmd = new TestableMigrateCommand($db, ['--delete']);
$cmd->runDatafields();
$dba = $db->getDbAdapter();
foreach (self::MIGRATABLE as $varname) {
$dfCount = $dba->fetchOne(
$dba->select()->from('director_datafield', ['cnt' => 'COUNT(*)'])->where('varname = ?', $varname)
);
$this->assertEquals(0, (int) $dfCount, "--delete must remove director_datafield for '$varname'");
$propCount = $dba->fetchOne(
$dba->select()->from('director_property', ['cnt' => 'COUNT(*)'])->where('key_name = ?', $varname)
);
$this->assertEquals(1, (int) $propCount, "director_property must survive --delete for '$varname'");
}
}
public function testDeleteIsSkippedOnDryRun(): void
{
if ($this->skipForMissingDb()) {
return;
}
$db = $this->getDb();
$this->createAllFixtures($db);
$cmd = new TestableMigrateCommand($db, ['--dry-run', '--delete']);
$cmd->runDatafields();
$dba = $db->getDbAdapter();
foreach (self::MIGRATABLE as $varname) {
$count = $dba->fetchOne(
$dba->select()->from('director_datafield', ['cnt' => 'COUNT(*)'])->where('varname = ?', $varname)
);
$this->assertEquals(
1,
(int) $count,
"--dry-run --delete must not remove director_datafield for '$varname'"
);
}
}
public function testCategorizedDatafieldIsSkipped(): void
{
if ($this->skipForMissingDb()) {
return;
}
$db = $this->getDb();
$this->createAllFixtures($db);
$cmd = new TestableMigrateCommand($db);
$cmd->runDatafields();
$dba = $db->getDbAdapter();
$count = $dba->fetchOne(
$dba->select()->from(
'director_property',
['cnt' => 'COUNT(*)']
)->where('key_name = ?', self::VAR_CATEGORIZED)
);
$this->assertEquals(0, (int) $count, 'Categorized datafield must not be migrated');
}
public function testProtectedStringFieldIsSkipped(): void
{
if ($this->skipForMissingDb()) {
return;
}
$db = $this->getDb();
$this->createAllFixtures($db);
$cmd = new TestableMigrateCommand($db);
$cmd->runDatafields();
$dba = $db->getDbAdapter();
$count = $dba->fetchOne(
$dba->select()->from('director_property', ['cnt' => 'COUNT(*)'])->where('key_name = ?', self::VAR_HIDDEN)
);
$this->assertEquals(0, (int) $count, 'Protected (hidden visibility) datafield must not be migrated');
}
public function testUnsupportedTypeIsSkipped(): void
{
if ($this->skipForMissingDb()) {
return;
}
$db = $this->getDb();
$this->createAllFixtures($db);
$cmd = new TestableMigrateCommand($db);
$cmd->runDatafields();
$dba = $db->getDbAdapter();
$count = $dba->fetchOne(
$dba->select()->from('director_property', ['cnt' => 'COUNT(*)'])->where('key_name = ?', self::VAR_SQL_QUERY)
);
$this->assertEquals(0, (int) $count, 'SqlQuery datafield must not be migrated (unsupported type)');
}
public function testDuplicateNamesAreSkipped(): void
{
if ($this->skipForMissingDb()) {
return;
}
$db = $this->getDb();
$this->createAllFixtures($db);
$cmd = new TestableMigrateCommand($db);
$cmd->runDatafields();
$dba = $db->getDbAdapter();
$count = $dba->fetchOne(
$dba->select()->from('director_property', ['cnt' => 'COUNT(*)'])->where('key_name = ?', self::VAR_DUP)
);
$this->assertEquals(0, (int) $count, 'Duplicate-named datafield must not be migrated');
}
public function testExistingCustomPropertyBlocksMigration(): void
{
if ($this->skipForMissingDb()) {
return;
}
$db = $this->getDb();
$this->createAllFixtures($db);
// Pre-create a director_property with key_name matching VAR_ENV
$db->insert('director_property', [
'uuid' => DbUtil::quoteBinaryCompat(Uuid::uuid4()->getBytes(), $db->getDbAdapter()),
'key_name' => self::VAR_ENV,
'value_type' => 'string',
]);
$cmd = new TestableMigrateCommand($db);
$cmd->runDatafields();
$dba = $db->getDbAdapter();
$count = $dba->fetchOne(
$dba->select()->from('director_property', ['cnt' => 'COUNT(*)'])->where('key_name = ?', self::VAR_ENV)
);
$this->assertEquals(1, (int) $count, 'Pre-existing custom property must not be duplicated by migration');
}
protected function tearDown(): void
{
if ($this->hasDb()) {
$db = $this->getDb();
$this->deleteTestProperties($db);
$this->deleteTestDatafields($db);
$this->deleteTestCategory($db);
$this->deleteTestDatalist($db);
}
parent::tearDown();
}
// -------------------------------------------------------------------------
// Fixture helpers
// -------------------------------------------------------------------------
private function createAllFixtures(Db $db): void
{
if (! DirectorDatalist::exists(self::LIST_NAME, $db)) {
DirectorDatalist::create(['list_name' => self::LIST_NAME, 'owner' => 'test'], $db)->store();
}
$datalist = DirectorDatalist::load(self::LIST_NAME, $db);
$datalistId = $datalist->get('id');
if (! DirectorDatafieldCategory::exists(self::CAT_NAME, $db)) {
DirectorDatafieldCategory::create(['category_name' => self::CAT_NAME], $db)->store();
}
$category = DirectorDatafieldCategory::load(self::CAT_NAME, $db);
$categoryId = $category->get('id');
$this->deleteTestDatafields($db);
// 1. env — string
DirectorDatafield::create([
'varname' => self::VAR_ENV,
'caption' => 'Environment',
'datatype' => 'Icinga\Module\Director\DataType\DataTypeString',
], $db)->store();
// 2. http_vhosts — array
DirectorDatafield::create([
'varname' => self::VAR_HTTP_VHOSTS,
'caption' => 'HTTP Vhosts',
'datatype' => 'Icinga\Module\Director\DataType\DataTypeArray',
], $db)->store();
// 3. check_interval — number
DirectorDatafield::create([
'varname' => self::VAR_CHECK_INTERVAL,
'caption' => 'Check Interval',
'datatype' => 'Icinga\Module\Director\DataType\DataTypeNumber',
], $db)->store();
// 4. env_choices — datalist-strict
$field = DirectorDatafield::create([
'varname' => self::VAR_ENV_CHOICES,
'caption' => 'Environment Choices',
'datatype' => 'Icinga\Module\Director\DataType\DataTypeDatalist',
], $db);
$field->set('behavior', 'strict');
$field->set('data_type', 'string');
$field->set('datalist_id', $datalistId);
$field->store();
// 5. env_suggest — datalist-non-strict
$field = DirectorDatafield::create([
'varname' => self::VAR_ENV_SUGGEST,
'caption' => 'Environment Suggest',
'datatype' => 'Icinga\Module\Director\DataType\DataTypeDatalist',
], $db);
$field->set('behavior', 'suggest');
$field->set('data_type', 'string');
$field->set('datalist_id', $datalistId);
$field->store();
// 6. sql_query_field — unsupported type
DirectorDatafield::create([
'varname' => self::VAR_SQL_QUERY,
'caption' => 'SQL Query',
'datatype' => 'Icinga\Module\Director\DataType\DataTypeSqlQuery',
], $db)->store();
// 7. categorized_field — has a category (skip)
$field = DirectorDatafield::create([
'varname' => self::VAR_CATEGORIZED,
'caption' => 'Categorized Field',
'datatype' => 'Icinga\Module\Director\DataType\DataTypeString',
'category_id' => $categoryId,
], $db);
$field->store();
// 8. hidden_field — protected string (visibility=hidden, skip)
$field = DirectorDatafield::create([
'varname' => self::VAR_HIDDEN,
'caption' => 'Hidden Field',
'datatype' => 'Icinga\Module\Director\DataType\DataTypeString',
], $db);
$field->set('visibility', 'hidden');
$field->store();
// 9. dup_field × 2 — duplicate varname (skip both)
// DirectorDatafield has no uniqueness constraint on varname, so raw insert is safe.
$dba = $db->getDbAdapter();
$dba->insert('director_datafield', [
'uuid' => DbUtil::quoteBinaryCompat(Uuid::uuid4()->getBytes(), $dba),
'varname' => self::VAR_DUP,
'caption' => 'Dup A',
'datatype' => 'Icinga\Module\Director\DataType\DataTypeString',
]);
$dba->insert('director_datafield', [
'uuid' => DbUtil::quoteBinaryCompat(Uuid::uuid4()->getBytes(), $dba),
'varname' => self::VAR_DUP,
'caption' => 'Dup B',
'datatype' => 'Icinga\Module\Director\DataType\DataTypeString',
]);
}
private function deleteTestDatafields(Db $db): void
{
$dba = $db->getDbAdapter();
foreach (self::ALL_TEST_VARS as $varname) {
$rows = $dba->fetchAll(
$dba->select()->from('director_datafield', ['id'])->where('varname = ?', $varname)
);
foreach ($rows as $row) {
$dba->delete('director_datafield_setting', $dba->quoteInto('datafield_id = ?', $row->id));
}
$dba->delete('director_datafield', $dba->quoteInto('varname = ?', $varname));
}
}
private function deleteTestProperties(Db $db): void
{
$dba = $db->getDbAdapter();
foreach (self::ALL_TEST_VARS as $varname) {
$rows = $dba->fetchAll(
$dba->select()->from('director_property', ['uuid'])->where('key_name = ?', $varname)
);
foreach ($rows as $row) {
$dba->delete(
'director_property',
$dba->quoteInto(
'parent_uuid = ?',
DbUtil::quoteBinaryCompat(DbUtil::binaryResult($row->uuid), $dba)
)
);
}
$dba->delete('director_property', $dba->quoteInto('key_name = ?', $varname));
}
}
private function deleteTestCategory(Db $db): void
{
if (DirectorDatafieldCategory::exists(self::CAT_NAME, $db)) {
$db->getDbAdapter()->delete(
'director_datafield_category',
$db->getDbAdapter()->quoteInto('category_name = ?', self::CAT_NAME)
);
}
}
private function deleteTestDatalist(Db $db): void
{
if (DirectorDatalist::exists(self::LIST_NAME, $db)) {
DirectorDatalist::load(self::LIST_NAME, $db)->delete();
}
}
}