3
0
mirror of https://github.com/snipe/snipe-it.git synced 2026-08-18 11:15:42 +00:00
Files
snipe-it/tests/Feature/CustomFields/CustomFieldValidationTest.php

348 lines
14 KiB
PHP

<?php
namespace Tests\Feature\CustomFields;
use App\Models\CustomField;
use App\Models\User;
use Illuminate\Foundation\Testing\WithFaker;
use PHPUnit\Framework\Attributes\DataProvider;
use Tests\TestCase;
/**
* Server-side validation of the format/element/encryption/field_values
* matrix on CustomField. These rules live on the model (getRules() +
* static helpers) so every entry point — UI, API, factories, seeders,
* tinker — sees the same enforcement.
*/
class CustomFieldValidationTest extends TestCase
{
use WithFaker;
protected function newField(array $attrs = []): CustomField
{
$field = new CustomField(array_merge([
'name' => 'validation_test_'.$this->faker->uuid(),
'element' => 'text',
], $attrs));
$field->created_by = User::factory()->superuser()->create()->id;
return $field;
}
public static function formatElementMatrix(): array
{
return [
// [format, element, expectedToSave, hint]
'DATE + date_picker is allowed' => ['DATE', 'date_picker', true],
'DATE + text is rejected' => ['DATE', 'text', false],
'DATE + listbox is rejected' => ['DATE', 'listbox', false],
'DATETIME + datetime_picker is allowed' => ['DATETIME', 'datetime_picker', true],
'DATETIME + text is rejected' => ['DATETIME', 'text', false],
'EMAIL + text is allowed' => ['EMAIL', 'text', true],
'EMAIL + listbox is rejected' => ['EMAIL', 'listbox', false],
'IP + text is allowed' => ['IP', 'text', true],
'IP + textarea is rejected' => ['IP', 'textarea', false],
'BOOLEAN + text is allowed' => ['BOOLEAN', 'text', true],
'BOOLEAN + checkbox is allowed' => ['BOOLEAN', 'checkbox', true],
'BOOLEAN + radio is allowed' => ['BOOLEAN', 'radio', true],
'ANY + text is allowed' => ['ANY', 'text', true],
'ANY + listbox is allowed' => ['ANY', 'listbox', true],
'ANY + textarea is allowed' => ['ANY', 'textarea', true],
];
}
#[DataProvider('formatElementMatrix')]
public function test_format_element_matrix_is_enforced_on_save(string $format, string $element, bool $expectedToSave): void
{
$field = $this->newField(['element' => $element]);
$field->format = $format;
// checkbox/radio need field_values to pass the field_values requirement,
// so give them one when we expect the save to succeed.
if (in_array($element, ['checkbox', 'radio', 'listbox'], true)) {
$field->field_values = "one\ntwo";
}
$saved = $field->save();
if ($expectedToSave) {
$this->assertTrue($saved, 'Expected save to succeed. Errors: '.json_encode($field->getErrors()->toArray()));
$this->assertDatabaseHas('custom_fields', ['id' => $field->id]);
} else {
$this->assertFalse($saved, 'Expected save to fail due to incompatible element/format combo.');
$this->assertArrayHasKey('element', $field->getErrors()->toArray());
$this->assertDatabaseMissing('custom_fields', ['name' => $field->name]);
}
}
/**
* Regression for issue 19498. The BOOLEAN format's allowed-element list
* dropped `radio` when the format/element matrix was introduced, which
* broke the pre-existing "yes/no radio group" pattern for boolean custom
* fields. Radio has always been consistent with checkbox everywhere else
* in the code (encryption gate, field_values requirement, editor's
* property-change handler), so this row belongs in the allowed set too.
*/
public function test_boolean_format_allows_radio_element(): void
{
$field = $this->newField([
'element' => 'radio',
'field_values' => "Yes\nNo",
]);
$field->format = 'BOOLEAN';
$this->assertTrue($field->save(), 'Errors: '.json_encode($field->getErrors()->toArray()));
// format persists as the lowercase validation-rule string ('boolean');
// 'BOOLEAN' is the reverse-mapped UI label.
$this->assertDatabaseHas('custom_fields', [
'id' => $field->id,
'element' => 'radio',
'format' => 'boolean',
]);
}
public function test_date_format_rejects_encryption(): void
{
$field = $this->newField(['element' => 'date_picker', 'field_encrypted' => 1]);
$field->format = 'DATE';
$this->assertFalse($field->save());
$this->assertArrayHasKey('field_encrypted', $field->getErrors()->toArray());
}
public function test_datetime_format_rejects_encryption(): void
{
$field = $this->newField(['element' => 'datetime_picker', 'field_encrypted' => 1]);
$field->format = 'DATETIME';
$this->assertFalse($field->save());
$this->assertArrayHasKey('field_encrypted', $field->getErrors()->toArray());
}
public function test_checkbox_element_rejects_encryption(): void
{
$field = $this->newField([
'element' => 'checkbox',
'field_encrypted' => 1,
'field_values' => "one\ntwo",
]);
$field->format = 'ANY';
$this->assertFalse($field->save());
$this->assertArrayHasKey('field_encrypted', $field->getErrors()->toArray());
}
public function test_radio_element_rejects_encryption(): void
{
$field = $this->newField([
'element' => 'radio',
'field_encrypted' => 1,
'field_values' => "one\ntwo",
]);
$field->format = 'ANY';
$this->assertFalse($field->save());
$this->assertArrayHasKey('field_encrypted', $field->getErrors()->toArray());
}
public function test_listbox_element_requires_field_values(): void
{
$field = $this->newField(['element' => 'listbox']);
$field->format = 'ANY';
$this->assertFalse($field->save());
$this->assertArrayHasKey('field_values', $field->getErrors()->toArray());
}
public function test_checkbox_element_requires_field_values(): void
{
$field = $this->newField(['element' => 'checkbox']);
$field->format = 'ANY';
$this->assertFalse($field->save());
$this->assertArrayHasKey('field_values', $field->getErrors()->toArray());
}
public function test_radio_element_requires_field_values(): void
{
$field = $this->newField(['element' => 'radio']);
$field->format = 'ANY';
$this->assertFalse($field->save());
$this->assertArrayHasKey('field_values', $field->getErrors()->toArray());
}
public function test_text_element_does_not_require_field_values(): void
{
$field = $this->newField(['element' => 'text']);
$field->format = 'ANY';
$this->assertTrue($field->save(), 'Errors: '.json_encode($field->getErrors()->toArray()));
}
/**
* Regression test for the wasRecentlyCreated bypass in the `updating` boot
* hook. The `created` hook does a second save on the same instance to
* persist db_column. Without the bypass, that save gets rejected by the
* format-lock check on DATE/DATETIME fields, leaving db_column NULL.
*/
public function test_date_field_creation_persists_db_column(): void
{
$field = $this->newField(['element' => 'date_picker']);
$field->format = 'DATE';
$this->assertTrue($field->save(), 'Errors: '.json_encode($field->getErrors()->toArray()));
$fresh = $field->fresh();
$this->assertNotNull($fresh->db_column, 'db_column must be populated after DATE field creation');
$this->assertStringStartsWith('_snipeit_', $fresh->db_column);
}
public function test_datetime_field_creation_persists_db_column(): void
{
$field = $this->newField(['element' => 'datetime_picker']);
$field->format = 'DATETIME';
$this->assertTrue($field->save(), 'Errors: '.json_encode($field->getErrors()->toArray()));
$fresh = $field->fresh();
$this->assertNotNull($fresh->db_column);
$this->assertStringStartsWith('_snipeit_', $fresh->db_column);
}
/**
* A previous bug could leave db_column NULL on a custom_fields row. The
* deleting hook tried to `ALTER TABLE assets DROP `` ` which errors out
* and blocks the delete. Verify the guard lets orphan rows be cleaned up.
*/
public function test_can_delete_orphan_field_with_empty_db_column(): void
{
$field = CustomField::factory()->create();
// Simulate the orphan state without going through the model
CustomField::where('id', $field->id)->update(['db_column' => null]);
$field = $field->fresh();
$this->assertNull($field->db_column);
$this->assertTrue($field->delete());
$this->assertDatabaseMissing('custom_fields', ['id' => $field->id]);
}
/**
* Static-helper coverage. These methods are the single source of truth
* for both getRules() and the Livewire editor UI; if they drift the
* matrix above should catch it, but a direct test pins them down.
*/
public function test_allowed_element_keys_for_format(): void
{
$this->assertSame(['date_picker'], CustomField::allowedElementKeysForFormat('DATE'));
$this->assertSame(['datetime_picker'], CustomField::allowedElementKeysForFormat('DATETIME'));
$this->assertSame(['text'], CustomField::allowedElementKeysForFormat('EMAIL'));
$this->assertSame(['text'], CustomField::allowedElementKeysForFormat('CUSTOM REGEX'));
$this->assertSame(['text'], CustomField::allowedElementKeysForFormat('regex:/^\d+$/'));
$this->assertSame(['text', 'checkbox', 'radio'], CustomField::allowedElementKeysForFormat('BOOLEAN'));
$this->assertSame(CustomField::ELEMENT_KEYS, CustomField::allowedElementKeysForFormat('ANY'));
$this->assertSame(CustomField::ELEMENT_KEYS, CustomField::allowedElementKeysForFormat(null));
}
public function test_can_encrypt_for(): void
{
$this->assertFalse(CustomField::canEncryptFor('date_picker', 'DATE'));
$this->assertFalse(CustomField::canEncryptFor('datetime_picker', 'DATETIME'));
$this->assertFalse(CustomField::canEncryptFor('checkbox', 'ANY'));
$this->assertFalse(CustomField::canEncryptFor('radio', 'ANY'));
$this->assertTrue(CustomField::canEncryptFor('text', 'ANY'));
$this->assertTrue(CustomField::canEncryptFor('text', 'EMAIL'));
$this->assertTrue(CustomField::canEncryptFor('textarea', 'ANY'));
}
public function test_element_requires_field_values(): void
{
$this->assertTrue(CustomField::elementRequiresFieldValues('listbox'));
$this->assertTrue(CustomField::elementRequiresFieldValues('checkbox'));
$this->assertTrue(CustomField::elementRequiresFieldValues('radio'));
$this->assertFalse(CustomField::elementRequiresFieldValues('text'));
$this->assertFalse(CustomField::elementRequiresFieldValues('textarea'));
$this->assertFalse(CustomField::elementRequiresFieldValues('date_picker'));
$this->assertFalse(CustomField::elementRequiresFieldValues(null));
}
public function test_icon_for_format(): void
{
$this->assertSame('email', CustomField::iconForFormat('EMAIL'));
$this->assertSame('link', CustomField::iconForFormat('URL'));
$this->assertSame('phone', CustomField::iconForFormat('PHONE'));
$this->assertSame('fax', CustomField::iconForFormat('FAX'));
$this->assertNull(CustomField::iconForFormat('NUMERIC'));
$this->assertNull(CustomField::iconForFormat('ANY'));
$this->assertNull(CustomField::iconForFormat('DATE'));
$this->assertNull(CustomField::iconForFormat('CUSTOM REGEX'));
$this->assertNull(CustomField::iconForFormat(null));
}
/**
* DATE / DATETIME are backed by native date columns on the assets
* table, so changing to or from them would need an ALTER TABLE and
* risk unconvertable data. Those changes are hard-blocked.
* Other format changes (all text-backed) are allowed at the DB level;
* the UI warns the user about the validation-rule change.
*/
public function test_cannot_change_format_from_text_to_date(): void
{
$field = $this->newField(['element' => 'text']);
$field->format = 'PHONE';
$this->assertTrue($field->save());
// Set element to date_picker too so we don't trip element/format
// matrix validation first — we want the updating hook's format-lock
// check to be the specific rejection under test.
$field->format = 'DATE';
$field->element = 'date_picker';
$this->assertFalse($field->save(), 'PHONE → DATE conversion must be blocked');
$this->assertArrayHasKey('format', $field->getErrors()->toArray());
// Confirm the persisted format didn't change
$this->assertSame('PHONE', $field->fresh()->format);
}
public function test_cannot_change_format_from_date_to_text(): void
{
$field = $this->newField(['element' => 'date_picker']);
$field->format = 'DATE';
$this->assertTrue($field->save());
$field->format = 'EMAIL';
$field->element = 'text';
$this->assertFalse($field->save(), 'DATE → EMAIL conversion must be blocked');
$this->assertArrayHasKey('format', $field->getErrors()->toArray());
$this->assertSame('DATE', $field->fresh()->format);
}
public function test_can_change_format_between_text_backed_types(): void
{
$field = $this->newField(['element' => 'text']);
$field->format = 'PHONE';
$this->assertTrue($field->save());
$field->format = 'EMAIL';
$this->assertTrue(
$field->save(),
'PHONE → EMAIL is allowed (text-backed, UI warns about validation risk). Errors: '.json_encode($field->getErrors()->toArray())
);
$this->assertSame('EMAIL', $field->fresh()->format);
}
public function test_phone_and_fax_formats_reverse_map_correctly(): void
{
$field = $this->newField();
$field->format = 'PHONE';
$this->assertTrue($field->save(), 'Errors: '.json_encode($field->getErrors()->toArray()));
$this->assertSame('PHONE', $field->fresh()->format, 'PHONE must reverse-map to its label, not ANY');
$field2 = $this->newField();
$field2->format = 'FAX';
$this->assertTrue($field2->save(), 'Errors: '.json_encode($field2->getErrors()->toArray()));
$this->assertSame('FAX', $field2->fresh()->format, 'FAX must reverse-map to its label, not ANY');
}
}