` on transient checkout * forms (hardware/checkout, bulk-checkout, kits/checkout, etc.) passes * `$item = null` down to `Helper::checkIfRequired`. Without the null guard * this hit `null::rules()` and threw "Class name must be a valid object or * a string", 500ing the whole page. When there's no bound model to * introspect, we treat the field as not required. */ public function test_check_if_required_returns_false_when_class_is_null() { $this->assertFalse(Helper::checkIfRequired(null, 'name')); } public function test_check_if_required_detects_required_field_on_a_real_model() { // Location::$rules declares 'name' => 'required|max:255|unique_undeleted' $this->assertTrue(Helper::checkIfRequired(new Location, 'name')); } public function test_check_if_required_returns_false_for_a_non_required_field() { // Location's 'address' is 'max:191|nullable' — no required rule. $this->assertFalse(Helper::checkIfRequired(new Location, 'address')); } public function test_check_if_required_returns_false_for_unknown_field() { $this->assertFalse(Helper::checkIfRequired(new Location, 'no_such_field_on_location')); } public function test_default_chart_colors_method_handles_high_values() { $this->assertIsString(Helper::defaultChartColors(1000)); } public function test_default_chart_colors_method_handles_negative_numbers() { $this->assertIsString(Helper::defaultChartColors(-1)); } public function test_parse_currency_method() { $this->settings->set(['default_currency' => 'USD', 'digit_separator' => '1,234.56']); $this->assertSame(12.34, Helper::ParseCurrency('USD 12.34')); $this->assertSame(8888.0, Helper::ParseCurrency('8,888.00')); // US thousands comma $this->assertSame(8888.0, Helper::ParseCurrency('8888.00')); // US plain $this->settings->set(['digit_separator' => '1.234,56']); $this->assertSame(12.34, Helper::ParseCurrency('12,34')); $this->assertSame(8888.0, Helper::ParseCurrency('8.888,00')); // EU thousands dot $this->assertSame(8888.0, Helper::ParseCurrency('8888,00')); // EU plain } public function test_get_redirect_option_method() { $test_data = [ 'Option target: redirect for user assigned to ' => [ 'request' => (object) ['assigned_user' => 22], 'id' => 1, 'checkout_to_type' => 'user', 'redirect_option' => 'target', 'table' => 'Assets', 'route' => route('users.show', 22), ], 'Option target: redirect location assigned to ' => [ 'request' => (object) ['assigned_location' => 10], 'id' => 2, 'checkout_to_type' => 'location', 'redirect_option' => 'target', 'table' => 'Locations', 'route' => route('locations.show', 10), ], 'Option target: redirect back to asset assigned to ' => [ 'request' => (object) ['assigned_asset' => 101], 'id' => 3, 'checkout_to_type' => 'asset', 'redirect_option' => 'target', 'table' => 'Assets', 'route' => route('hardware.show', 101), ], 'Option item: redirect back to asset ' => [ 'request' => (object) ['assigned_asset' => null], 'id' => 999, 'checkout_to_type' => null, 'redirect_option' => 'item', 'table' => 'Assets', 'route' => route('hardware.show', 999), ], 'Option index: redirect back to asset index ' => [ 'request' => (object) ['assigned_asset' => null], 'id' => null, 'checkout_to_type' => null, 'redirect_option' => 'index', 'table' => 'Assets', 'route' => route('hardware.index'), ], 'Option item: redirect back to user ' => [ 'request' => (object) ['assigned_asset' => null], 'id' => 999, 'checkout_to_type' => null, 'redirect_option' => 'item', 'table' => 'Users', 'route' => route('users.show', 999), ], 'Option index: redirect back to user index ' => [ 'request' => (object) ['assigned_asset' => null], 'id' => null, 'checkout_to_type' => null, 'redirect_option' => 'index', 'table' => 'Users', 'route' => route('users.index'), ], 'Option item: redirect back to license ' => [ 'request' => (object) ['assigned_asset' => null], 'id' => 999, 'checkout_to_type' => null, 'redirect_option' => 'item', 'table' => 'Licenses', 'route' => route('licenses.show', 999), ], 'Option index: redirect back to license index ' => [ 'request' => (object) ['assigned_asset' => null], 'id' => null, 'checkout_to_type' => null, 'redirect_option' => 'index', 'table' => 'Licenses', 'route' => route('licenses.index'), ], 'Option item: redirect back to accessory list ' => [ 'request' => (object) ['assigned_asset' => null], 'id' => 999, 'checkout_to_type' => null, 'redirect_option' => 'item', 'table' => 'Accessories', 'route' => route('accessories.show', 999), ], 'Option index: redirect back to accessory index ' => [ 'request' => (object) ['assigned_asset' => null], 'id' => null, 'checkout_to_type' => null, 'redirect_option' => 'index', 'table' => 'Accessories', 'route' => route('accessories.index'), ], 'Option item: redirect back to consumable ' => [ 'request' => (object) ['assigned_asset' => null], 'id' => 999, 'checkout_to_type' => null, 'redirect_option' => 'item', 'table' => 'Consumables', 'route' => route('consumables.show', 999), ], 'Option index: redirect back to consumables index ' => [ 'request' => (object) ['assigned_asset' => null], 'id' => null, 'checkout_to_type' => null, 'redirect_option' => 'index', 'table' => 'Consumables', 'route' => route('consumables.index'), ], 'Option item: redirect back to component ' => [ 'request' => (object) ['assigned_asset' => null], 'id' => 999, 'checkout_to_type' => null, 'redirect_option' => 'item', 'table' => 'Components', 'route' => route('components.show', 999), ], 'Option index: redirect back to component index ' => [ 'request' => (object) ['assigned_asset' => null], 'id' => null, 'checkout_to_type' => null, 'redirect_option' => 'index', 'table' => 'Components', 'route' => route('components.index'), ], ]; foreach ($test_data as $scenario => $data) { Session::put('redirect_option', $data['redirect_option']); Session::put('checkout_to_type', $data['checkout_to_type']); $redirect = Helper::getRedirectOption($data['request'], $data['id'], $data['table']); $this->assertInstanceOf(RedirectResponse::class, $redirect); $this->assertEquals($data['route'], $redirect->getTargetUrl(), $scenario.'failed.'); } } public function test_get_redirect_option_preserves_query_filters_when_returning_to_index() { // #15214: a user editing an asset from `hardware?status_type=Deployed` // should land back on that filtered view, not the plain // hardware.index that drops the side-nav filter context. Session::put('redirect_option', 'index'); Session::put('url.intended', route('hardware.index').'?status_type=Deployed'); $redirect = Helper::getRedirectOption((object) [], null, 'Assets'); $this->assertInstanceOf(RedirectResponse::class, $redirect); $this->assertEquals( route('hardware.index').'?status_type=Deployed', $redirect->getTargetUrl(), ); } public function test_get_redirect_option_preserves_multiple_query_filters() { // Query string with more than one filter round-trips cleanly. Session::put('redirect_option', 'index'); Session::put('url.intended', route('hardware.index').'?status_type=RTD&category_id=3'); $redirect = Helper::getRedirectOption((object) [], null, 'Assets'); $this->assertEquals( route('hardware.index').'?status_type=RTD&category_id=3', $redirect->getTargetUrl(), ); } public function test_get_redirect_option_falls_back_to_plain_index_when_no_referrer_query() { // When there's no filter to preserve, behavior matches the // pre-#15214 code path. Session::put('redirect_option', 'index'); Session::put('url.intended', route('hardware.index')); $redirect = Helper::getRedirectOption((object) [], null, 'Assets'); $this->assertEquals(route('hardware.index'), $redirect->getTargetUrl()); } public function test_get_redirect_option_ignores_referrer_pointing_at_a_different_path() { // If the referrer was the show page or the create form (not the // index they'd be redirected to), don't smuggle it into the // index redirect. Only same-path referrers are preserved. Session::put('redirect_option', 'index'); Session::put('url.intended', route('hardware.show', 42)); $redirect = Helper::getRedirectOption((object) [], null, 'Assets'); $this->assertEquals(route('hardware.index'), $redirect->getTargetUrl()); } public function test_get_redirect_option_ignores_offsite_referrer_when_returning_to_index() { // Off-site protection was already in place for the 'back' // option (via the parse_url host check). Verify it also applies // on the 'index' path so an attacker-controlled url.intended // (see SamlController RelayState) can't smuggle an external URL // into an index redirect. Session::put('redirect_option', 'index'); Session::put('url.intended', 'https://evil.example.com/hardware?status_type=Deployed'); $redirect = Helper::getRedirectOption((object) [], null, 'Assets'); $this->assertEquals(route('hardware.index'), $redirect->getTargetUrl()); } public function test_get_redirect_option_preserves_filters_for_other_entity_types_too() { // The #15214 fix lives in Helper::getRedirectOption so it covers // every entity that routes through the redirect helper, not just // Assets. Users/Licenses/etc. don't have side-nav filters today // but might grow them, and generic ?category_id=... links are // already common. Session::put('redirect_option', 'index'); Session::put('url.intended', route('users.index').'?company_id=5'); $redirect = Helper::getRedirectOption((object) [], null, 'Users'); $this->assertEquals(route('users.index').'?company_id=5', $redirect->getTargetUrl()); } public function test_same_origin_url_returns_null_for_empty_input() { $this->assertNull(Helper::sameOriginUrl(null)); $this->assertNull(Helper::sameOriginUrl('')); } public function test_same_origin_url_returns_relative_url_unchanged() { $this->assertSame('/maintenances?completed=true', Helper::sameOriginUrl('/maintenances?completed=true')); $this->assertSame('home', Helper::sameOriginUrl('home')); } public function test_same_origin_url_accepts_url_pointing_at_app_host() { $url = config('app.url').'/hardware/42'; $this->assertSame($url, Helper::sameOriginUrl($url)); } public function test_same_origin_url_rejects_offsite_host() { $this->assertNull(Helper::sameOriginUrl('https://evil.example.com/steal-session')); } public function test_same_origin_url_rejects_dangerous_schemes() { $this->assertNull(Helper::sameOriginUrl('javascript:alert(1)')); $this->assertNull(Helper::sameOriginUrl('data:text/html,')); $this->assertNull(Helper::sameOriginUrl('file:///etc/passwd')); } public function test_same_origin_url_strips_crlf_to_prevent_header_injection() { // A CR/LF in a Location: header would let an attacker split the // HTTP response and inject arbitrary headers/body. The helper // must strip them before returning. $this->assertSame('/foo', Helper::sameOriginUrl("/foo\r\n")); $this->assertSame('/foo/bar', Helper::sameOriginUrl("/foo\r\n/bar")); } public function test_same_origin_url_rejects_scheme_relative_offsite_url() { // //evil.com/... is a scheme-relative URL that inherits the // current scheme and points at evil.com. Must be rejected. $this->assertNull(Helper::sameOriginUrl('//evil.example.com/steal-session')); } /** * FD-56673 regression coverage: customFieldFormValue collapses the * gate + decrypt + default fallback that every branch of * custom_fields_form.blade.php used to hand-roll (and got wrong on * seven of them). These unit tests pin the four possible states of * the helper without needing to boot a full HTTP request. */ public function test_custom_field_form_value_masks_encrypted_field_when_gate_denies(): void { \App\Models\CustomField::factory()->testEncrypted()->create(); $field = \App\Models\CustomField::where('name', 'Test Encrypted')->first(); // Set the column value via magic-setter rather than mass assignment // because CustomField columns are added at runtime and are not in // Asset's $fillable, so `new Asset([...])` would silently drop them. $asset = new \App\Models\Asset; $asset->{$field->db_column} = \Illuminate\Support\Facades\Crypt::encrypt('very-secret-value'); $model = \App\Models\AssetModel::factory()->make(); $this->actingAs(\App\Models\User::factory()->editAssets()->create()); $this->assertSame( strtoupper(trans('admin/custom_fields/general.encrypted')), Helper::customFieldFormValue($field, $asset, $model) ); } public function test_custom_field_form_value_returns_decrypted_value_when_gate_allows(): void { \App\Models\CustomField::factory()->testEncrypted()->create(); $field = \App\Models\CustomField::where('name', 'Test Encrypted')->first(); $asset = new \App\Models\Asset; $asset->{$field->db_column} = \Illuminate\Support\Facades\Crypt::encrypt('very-secret-value'); $model = \App\Models\AssetModel::factory()->make(); $this->actingAs(\App\Models\User::factory()->superuser()->create()); $this->assertSame( 'very-secret-value', Helper::customFieldFormValue($field, $asset, $model) ); } public function test_custom_field_form_value_returns_raw_value_for_non_encrypted_field(): void { // Non-encrypted fields short-circuit before the gate check, so the caller // permission is irrelevant. Verified with an unauthenticated actor to make // the "gate does not gate this" behavior explicit. \App\Models\CustomField::factory()->ram()->create(); $field = \App\Models\CustomField::where('name', 'RAM')->first(); $asset = new \App\Models\Asset; $asset->{$field->db_column} = '16'; $model = \App\Models\AssetModel::factory()->make(); $this->assertSame('16', Helper::customFieldFormValue($field, $asset, $model)); } public function test_custom_field_form_value_returns_default_when_item_is_null(): void { // Create-form path: no bound $item yet, so the helper returns the // model-scoped default value for the field. \App\Models\CustomField::factory()->ram()->create(); $field = \App\Models\CustomField::where('name', 'RAM')->first(); $model = \App\Models\AssetModel::factory()->create(); $this->assertSame( $field->defaultValue($model->id), Helper::customFieldFormValue($field, null, $model) ); } public function test_custom_field_form_value_masks_default_when_encrypted_field_has_no_item_and_gate_denies(): void { // Create-form path AND encrypted field AND caller can't view keys. // The gate check runs first regardless of whether $item is present, // so the mask should win over the default-value fallback. \App\Models\CustomField::factory()->testEncrypted()->create(); $field = \App\Models\CustomField::where('name', 'Test Encrypted')->first(); $model = \App\Models\AssetModel::factory()->create(); $this->actingAs(\App\Models\User::factory()->editAssets()->create()); $this->assertSame( strtoupper(trans('admin/custom_fields/general.encrypted')), Helper::customFieldFormValue($field, null, $model) ); } }