diff --git a/.github/workflows/tests-mysql.yml b/.github/workflows/tests-mysql.yml index 4869b4600f..b2511b4ae8 100644 --- a/.github/workflows/tests-mysql.yml +++ b/.github/workflows/tests-mysql.yml @@ -28,6 +28,7 @@ jobs: - "8.2" - "8.3" - "8.4" + - "8.5" name: PHP ${{ matrix.php-version }} diff --git a/.github/workflows/tests-postgres.yml b/.github/workflows/tests-postgres.yml index 0b632d2946..752d65350c 100644 --- a/.github/workflows/tests-postgres.yml +++ b/.github/workflows/tests-postgres.yml @@ -24,6 +24,7 @@ jobs: - "8.2" - "8.3" - "8.4" + - "8.5" name: PHP ${{ matrix.php-version }} diff --git a/.github/workflows/tests-sqlite.yml b/.github/workflows/tests-sqlite.yml index 865b0da65f..cf3cfcab1c 100644 --- a/.github/workflows/tests-sqlite.yml +++ b/.github/workflows/tests-sqlite.yml @@ -15,7 +15,7 @@ jobs: fail-fast: false matrix: php-version: - - "8.3" + - "8.5" name: PHP ${{ matrix.php-version }} diff --git a/SECURITY.md b/SECURITY.md index e052527bd1..529f520494 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -10,9 +10,9 @@ however there are times when library dependencies and/or PHP/MySQL dependencies make it impossible to backport security fixes on older versions. | Version | Supported | -|---------| ------------------ | +|---------|--------------------| | 8.x | :white_check_mark: | -| 7.x | :white_check_mark: | +| 7.x | :x: | | 6.x | :x: | | 5.1.x | :x: | | 5.0.x | :x: | @@ -24,7 +24,18 @@ make it impossible to backport security fixes on older versions. Security vulnerabilities should be sent to security@snipeitapp.com. You can typically expect a response within two business days, and we typically have fixes out in under a week from the initial disclosure. -This obviously varies based on the severity of the security issue and the difficulty in remediation, -but those have historically been the timelines we worm around. +This obviously varies based on the severity of the security issue and the difficulty in remediation, but those have +historically been the timelines we work around. + +We do ask that you do not disclose the vulnerability publicly until we have had a chance to address it and tag a release +so that we can protect our users, and we will work +with you to coordinate a public disclosure once we have a fix out. We will also work with you to ensure that you receive +appropriate credit for the discovery of the vulnerability, if you would like to be credited. (Please provide a GitHub +username or other information if you would like to be credited, and please let us know if you would like to remain +anonymous.) + +For responsible disclosure, we ask that you give us at least __90 days__ to address the issue before disclosing it +publicly, +but we will work with you if you need to disclose it sooner than that. For a full breakdown of our security policies, please see https://snipeitapp.com/security. diff --git a/app/Http/Controllers/Account/AcceptanceController.php b/app/Http/Controllers/Account/AcceptanceController.php index 708c419a7f..347d59df69 100644 --- a/app/Http/Controllers/Account/AcceptanceController.php +++ b/app/Http/Controllers/Account/AcceptanceController.php @@ -208,6 +208,30 @@ class AcceptanceController extends Controller 'qty' => $acceptance->qty ?? 1, ]; + // Include asset custom fields that are explicitly allowed in outbound emails/PDFs. + if ($item instanceof Asset && $item->model && $item->model->fieldset) { + $customFields = []; + $fields = $item->model->fieldset->fields + ->where('show_in_email', true) + ->where('field_encrypted', false); + + foreach ($fields as $field) { + $dbColumn = $field->db_column; + $value = $item->{$dbColumn}; + + if (! is_null($value) && $value !== '') { + $customFields[] = [ + 'label' => $field->name, + 'value' => $value, + ]; + } + } + + if (! empty($customFields)) { + $data['custom_fields'] = $customFields; + } + } + if ($request->input('asset_acceptance') === 'accepted') { $pdf_filename = 'accepted-'.$acceptance->checkoutable_id.'-'.$acceptance->display_checkoutable_type.'-eula-'.date('Y-m-d-h-i-s').'.pdf'; diff --git a/app/Http/Controllers/Api/AccessoriesController.php b/app/Http/Controllers/Api/AccessoriesController.php index 4a2bef8072..78382b5496 100644 --- a/app/Http/Controllers/Api/AccessoriesController.php +++ b/app/Http/Controllers/Api/AccessoriesController.php @@ -84,23 +84,23 @@ class AccessoriesController extends Controller } if ($request->filled('category_id')) { - $accessories->where('category_id', '=', $request->input('category_id')); + $accessories->where('accessories.category_id', '=', $request->input('category_id')); } if ($request->filled('manufacturer_id')) { - $accessories->where('manufacturer_id', '=', $request->input('manufacturer_id')); + $accessories->where('accessories.manufacturer_id', '=', $request->input('manufacturer_id')); } if ($request->filled('supplier_id')) { - $accessories->where('supplier_id', '=', $request->input('supplier_id')); + $accessories->where('accessories.supplier_id', '=', $request->input('supplier_id')); } if ($request->filled('location_id')) { - $accessories->where('location_id', '=', $request->input('location_id')); + $accessories->where('accessories.location_id', '=', $request->input('location_id')); } if ($request->filled('notes')) { - $accessories->where('notes', '=', $request->input('notes')); + $accessories->where('accessories.notes', '=', $request->input('notes')); } // Make sure the offset and limit are actually integers and do not exceed system limits diff --git a/app/Http/Controllers/Api/ComponentsController.php b/app/Http/Controllers/Api/ComponentsController.php index 8731140c2f..2f3ea7c988 100644 --- a/app/Http/Controllers/Api/ComponentsController.php +++ b/app/Http/Controllers/Api/ComponentsController.php @@ -10,7 +10,6 @@ use App\Http\Transformers\ActionlogsTransformer; use App\Http\Transformers\ComponentsTransformer; use App\Models\Asset; use App\Models\Component; -use App\Models\ComponentAssignment; use Carbon\Carbon; use Illuminate\Database\Query\Builder; use Illuminate\Http\JsonResponse; @@ -80,7 +79,7 @@ class ComponentsController extends Controller } if ($request->filled('name')) { - $components->where('name', '=', $request->input('name')); + $components->where('components.name', '=', $request->input('name')); } if ($request->filled('company_id')) { @@ -92,27 +91,27 @@ class ComponentsController extends Controller } if ($request->filled('category_id')) { - $components->where('category_id', '=', $request->input('category_id')); + $components->where('components.category_id', '=', $request->input('category_id')); } if ($request->filled('supplier_id')) { - $components->where('supplier_id', '=', $request->input('supplier_id')); + $components->where('components.supplier_id', '=', $request->input('supplier_id')); } if ($request->filled('manufacturer_id')) { - $components->where('manufacturer_id', '=', $request->input('manufacturer_id')); + $components->where('components.manufacturer_id', '=', $request->input('manufacturer_id')); } if ($request->filled('model_number')) { - $components->where('model_number', '=', $request->input('model_number')); + $components->where('components.model_number', '=', $request->input('model_number')); } if ($request->filled('location_id')) { - $components->where('location_id', '=', $request->input('location_id')); + $components->where('components.location_id', '=', $request->input('location_id')); } if ($request->filled('notes')) { - $components->where('notes', '=', $request->input('notes')); + $components->where('components.notes', '=', $request->input('notes')); } // Make sure the offset and limit are actually integers and do not exceed system limits @@ -252,13 +251,11 @@ class ComponentsController extends Controller { $this->authorize('view', Asset::class); - $component_checkouts = ComponentAssignment::where('component_id', $component->id)->with('adminuser')->with('assets'); - $offset = request('offset', 0); $limit = $request->input('limit', 50); if ($request->filled('search')) { - $assets = $component_checkouts->assets() + $assets = $component->assets() ->where(function ($query) use ($request) { $search_str = '%'.$request->input('search').'%'; $query->where('name', 'like', $search_str) diff --git a/app/Http/Controllers/Api/ConsumablesController.php b/app/Http/Controllers/Api/ConsumablesController.php index b41b72a645..82aa1feb7f 100644 --- a/app/Http/Controllers/Api/ConsumablesController.php +++ b/app/Http/Controllers/Api/ConsumablesController.php @@ -67,7 +67,7 @@ class ConsumablesController extends Controller } if ($request->filled('name')) { - $consumables->where('name', '=', $request->input('name')); + $consumables->where('consumables.name', '=', $request->input('name')); } if ($request->filled('company_id')) { @@ -79,27 +79,27 @@ class ConsumablesController extends Controller } if ($request->filled('category_id')) { - $consumables->where('category_id', '=', $request->input('category_id')); + $consumables->where('consumables.category_id', '=', $request->input('category_id')); } if ($request->filled('model_number')) { - $consumables->where('model_number', '=', $request->input('model_number')); + $consumables->where('consumables.model_number', '=', $request->input('model_number')); } if ($request->filled('manufacturer_id')) { - $consumables->where('manufacturer_id', '=', $request->input('manufacturer_id')); + $consumables->where('consumables.manufacturer_id', '=', $request->input('manufacturer_id')); } if ($request->filled('supplier_id')) { - $consumables->where('supplier_id', '=', $request->input('supplier_id')); + $consumables->where('consumables.supplier_id', '=', $request->input('supplier_id')); } if ($request->filled('location_id')) { - $consumables->where('location_id', '=', $request->input('location_id')); + $consumables->where('consumables.location_id', '=', $request->input('location_id')); } if ($request->filled('notes')) { - $consumables->where('notes', '=', $request->input('notes')); + $consumables->where('consumables.notes', '=', $request->input('notes')); } // Make sure the offset and limit are actually integers and do not exceed system limits diff --git a/app/Http/Controllers/Api/DepartmentsController.php b/app/Http/Controllers/Api/DepartmentsController.php index 98ed860016..f95cdacf7d 100644 --- a/app/Http/Controllers/Api/DepartmentsController.php +++ b/app/Http/Controllers/Api/DepartmentsController.php @@ -50,23 +50,23 @@ class DepartmentsController extends Controller } if ($request->filled('name')) { - $departments->where('name', '=', $request->input('name')); + $departments->where('departments.name', '=', $request->input('name')); } if ($request->filled('company_id')) { - $departments->where('company_id', '=', $request->input('company_id')); + $departments->where('departments.company_id', '=', $request->input('company_id')); } if ($request->filled('manager_id')) { - $departments->where('manager_id', '=', $request->input('manager_id')); + $departments->where('departments.manager_id', '=', $request->input('manager_id')); } if ($request->filled('location_id')) { - $departments->where('location_id', '=', $request->input('location_id')); + $departments->where('departments.location_id', '=', $request->input('location_id')); } if ($request->filled('tag_color')) { - $departments->where('tag_color', '=', $request->input('departments.tag_color')); + $departments->where('departments.tag_color', '=', $request->input('tag_color')); } // Make sure the offset and limit are actually integers and do not exceed system limits diff --git a/app/Http/Controllers/Api/MaintenancesController.php b/app/Http/Controllers/Api/MaintenancesController.php index 1f14ffa08a..656d06370b 100644 --- a/app/Http/Controllers/Api/MaintenancesController.php +++ b/app/Http/Controllers/Api/MaintenancesController.php @@ -257,9 +257,7 @@ class MaintenancesController extends Controller public function history(Request $request, Maintenance $maintenance): JsonResponse|array { - $this->authorize('view', Asset::class); - $asset = $maintenance->asset; - $this->authorize('history', $asset); + $this->authorize('history', $maintenance); $historyQuery = $maintenance->getHistory($request); $total = (clone $historyQuery)->count(); $offset = ($request->input('offset') > $total) ? $total : app('api_offset_value'); diff --git a/app/Http/Controllers/Assets/AssetsController.php b/app/Http/Controllers/Assets/AssetsController.php index 3cd79485c4..74d67963ba 100755 --- a/app/Http/Controllers/Assets/AssetsController.php +++ b/app/Http/Controllers/Assets/AssetsController.php @@ -66,7 +66,8 @@ class AssetsController extends Controller public function index(Request $request): View { $this->authorize('index', Asset::class); - $company = Company::find($request->input('company_id')); + $companyId = $request->input('company_id'); + $company = is_scalar($companyId) ? Company::find($companyId) : null; return view('hardware/index')->with('company', $company); } diff --git a/app/Http/Controllers/Auth/LoginController.php b/app/Http/Controllers/Auth/LoginController.php index 8e406d92f8..98ec93d7a4 100644 --- a/app/Http/Controllers/Auth/LoginController.php +++ b/app/Http/Controllers/Auth/LoginController.php @@ -106,15 +106,21 @@ class LoginController extends Controller if ($saml->isEnabled() && ! empty($samlData)) { try { + $user = $saml->samlLogin($samlData); $notValidAfter = new \Carbon\Carbon(@$samlData['assertionNotOnOrAfter']); if (\Carbon::now()->greaterThanOrEqualTo($notValidAfter)) { abort(400, 'Expired SAML Assertion'); } - if (SamlNonce::where('nonce', @$samlData['nonce'])->count() > 0) { - abort(400, 'Assertion has already been used'); + try { + SamlNonce::create([ + 'nonce' => $samlData['nonce'], + 'not_valid_after' => $notValidAfter, + ]); + } catch (\Exception $e) { + \Log::error($e); + abort(400, 'Assertion has already been used.'); } - Log::debug('okay, fine, this is a new nonce then. Good for you.'); if (! is_null($user)) { Auth::login($user); } else { @@ -128,10 +134,6 @@ class LoginController extends Controller $user->last_login = \Carbon::now(); $user->saveQuietly(); } - $s = new SamlNonce; - $s->nonce = @$samlData['nonce']; - $s->not_valid_after = $notValidAfter; - $s->save(); } catch (\Exception $e) { Log::debug('There was an error authenticating the SAML user: '.$e->getMessage()); diff --git a/app/Http/Controllers/MaintenancesController.php b/app/Http/Controllers/MaintenancesController.php index 40a39e5626..a5c88c5e69 100644 --- a/app/Http/Controllers/MaintenancesController.php +++ b/app/Http/Controllers/MaintenancesController.php @@ -3,12 +3,15 @@ namespace App\Http\Controllers; use App\Http\Requests\ImageUploadRequest; +use App\Http\Requests\UploadFileRequest; use App\Models\Asset; use App\Models\Maintenance; use Carbon\Carbon; use Illuminate\Contracts\View\View; use Illuminate\Http\RedirectResponse; use Illuminate\Http\Request; +use Illuminate\Support\Facades\Storage; +use Illuminate\Support\Facades\Validator; /** * This controller handles all actions related to Asset Maintenance for @@ -72,6 +75,7 @@ class MaintenancesController extends Controller public function store(ImageUploadRequest $request): RedirectResponse { $this->authorize('update', Asset::class); + $this->validateUploadedFiles($request); $assets = Asset::whereIn('id', $request->input('selected_assets'))->get(); @@ -102,12 +106,14 @@ class MaintenancesController extends Controller $maintenance->asset_maintenance_time = (int) $completionDate->diffInDays($startDate, true); } - $maintenance = $request->handleImages($maintenance); + $request->handleImages($maintenance); // Was the asset maintenance created? if (! $maintenance->save()) { return redirect()->back()->withInput()->withErrors($maintenance->getErrors()); } + + $this->storeUploadedFiles($request, $maintenance); } return redirect()->route('maintenances.index') @@ -156,6 +162,7 @@ class MaintenancesController extends Controller { $this->authorize('update', Asset::class); $this->authorize('update', $maintenance->asset); + $this->validateUploadedFiles($request); $maintenance->supplier_id = $request->input('supplier_id'); $maintenance->is_warranty = $request->input('is_warranty', 0); @@ -184,9 +191,11 @@ class MaintenancesController extends Controller $completionDate = Carbon::parse($maintenance->completion_date); $maintenance->asset_maintenance_time = (int) $completionDate->diffInDays($startDate, true); } - $maintenance = $request->handleImages($maintenance); + $request->handleImages($maintenance); if ($maintenance->save()) { + $this->storeUploadedFiles($request, $maintenance); + return redirect()->route('maintenances.index') ->with('success', trans('admin/maintenances/message.edit.success')); } @@ -194,6 +203,56 @@ class MaintenancesController extends Controller return redirect()->back()->withInput()->withErrors($maintenance->getErrors()); } + /** + * Stores any generic file uploads submitted from the maintenance form. + */ + private function storeUploadedFiles(ImageUploadRequest $request, Maintenance $maintenance): void + { + if (! $request->hasFile('file')) { + return; + } + + $objectType = 'maintenances'; + $storagePath = self::$map_storage_path[$objectType]; + + if (! Storage::exists($storagePath)) { + Storage::makeDirectory($storagePath, 775); + } + + $uploadFileRequest = app(UploadFileRequest::class); + + foreach ((array) $request->file('file') as $file) { + if (! $file) { + continue; + } + + $fileName = $uploadFileRequest->handleFile( + $storagePath, + self::$map_file_prefix[$objectType].'-'.$maintenance->id, + $file + ); + + $maintenance->logUpload($fileName, $request->input('file_notes')); + } + } + + /** + * Validate generic file uploads with the shared UploadFileRequest rules. + */ + private function validateUploadedFiles(ImageUploadRequest $request): void + { + if (! $request->hasFile('file')) { + return; + } + + $uploadFileRequest = app(UploadFileRequest::class); + + Validator::make( + array_merge($request->all(), ['file' => $request->file('file')]), + $uploadFileRequest->rules() + )->validate(); + } + /** * Delete an asset maintenance * diff --git a/app/Http/Transformers/UploadedFilesTransformer.php b/app/Http/Transformers/UploadedFilesTransformer.php index 4983de03fc..b153a23291 100644 --- a/app/Http/Transformers/UploadedFilesTransformer.php +++ b/app/Http/Transformers/UploadedFilesTransformer.php @@ -6,6 +6,7 @@ use App\Helpers\Helper; use App\Helpers\StorageHelper; use App\Models\Actionlog; use Illuminate\Database\Eloquent\Collection; +use Illuminate\Database\Eloquent\SoftDeletes; use Illuminate\Support\Facades\Gate; use Illuminate\Support\Facades\Storage; @@ -24,6 +25,17 @@ class UploadedFilesTransformer public function transformFile(Actionlog $file) { $snipeModel = $file->item_type; + $item = null; + + if (is_string($snipeModel) && class_exists($snipeModel)) { + $itemQuery = $snipeModel::query(); + + if (in_array(SoftDeletes::class, class_uses_recursive($snipeModel), true)) { + $itemQuery->withTrashed(); + } + + $item = $itemQuery->find($file->item_id); + } $array = [ 'id' => (int) $file->id, @@ -49,7 +61,7 @@ class UploadedFilesTransformer ]; $permissions_array['available_actions'] = [ - 'delete' => (Gate::allows('update', $snipeModel) && ($file->deleted_at == '')), + 'delete' => (Gate::allows('update', $item ?? $snipeModel) && ($file->deleted_at == '')), ]; $array += $permissions_array; diff --git a/app/Mail/CheckinAssetMail.php b/app/Mail/CheckinAssetMail.php index 437a6aea8e..2c086cd89e 100644 --- a/app/Mail/CheckinAssetMail.php +++ b/app/Mail/CheckinAssetMail.php @@ -58,10 +58,26 @@ class CheckinAssetMail extends BaseMailable { $this->item->load('status'); $fields = []; + $customFields = []; // Check if the item has custom fields associated with it if (($this->item->model) && ($this->item->model->fieldset)) { $fields = $this->item->model->fieldset->fields; + + foreach ($fields as $field) { + if (! $field->show_in_email || $field->field_encrypted == '1') { + continue; + } + + $value = $this->item->{$field->db_column_name()}; + + if (! is_null($value) && $value !== '') { + $customFields[] = [ + 'label' => $field->name, + 'value' => $value, + ]; + } + } } return new Content( @@ -73,6 +89,7 @@ class CheckinAssetMail extends BaseMailable 'note' => $this->note, 'target' => $this->target, 'fields' => $fields, + 'custom_fields' => $customFields, 'expected_checkin' => $this->expected_checkin, ], ); diff --git a/app/Mail/CheckoutAssetMail.php b/app/Mail/CheckoutAssetMail.php index 7231598a4d..0396d41da2 100644 --- a/app/Mail/CheckoutAssetMail.php +++ b/app/Mail/CheckoutAssetMail.php @@ -75,6 +75,7 @@ class CheckoutAssetMail extends BaseMailable $eula = method_exists($this->item, 'getEula') ? $this->item->getEula() : ''; $req_accept = $this->requiresAcceptance(); $fields = []; + $customFields = []; $name = null; if ($this->target instanceof User) { @@ -88,6 +89,21 @@ class CheckoutAssetMail extends BaseMailable // Check if the item has custom fields associated with it if (($this->item->model) && ($this->item->model->fieldset)) { $fields = $this->item->model->fieldset->fields; + + foreach ($fields as $field) { + if (! $field->show_in_email || $field->field_encrypted == '1') { + continue; + } + + $value = $this->item->{$field->db_column_name()}; + + if (! is_null($value) && $value !== '') { + $customFields[] = [ + 'label' => $field->name, + 'value' => $value, + ]; + } + } } $accept_url = is_null($this->acceptance) ? null : route('account.accept.item', $this->acceptance); @@ -101,6 +117,7 @@ class CheckoutAssetMail extends BaseMailable 'note' => $this->note, 'target' => $name, 'fields' => $fields, + 'custom_fields' => $customFields, 'eula' => $eula, 'req_accept' => $req_accept, 'accept_url' => $accept_url, diff --git a/app/Models/Actionlog.php b/app/Models/Actionlog.php index 18a01ece2a..18c4ad79e5 100755 --- a/app/Models/Actionlog.php +++ b/app/Models/Actionlog.php @@ -9,9 +9,11 @@ use App\Presenters\ActionlogPresenter; use App\Presenters\Presentable; use Carbon\Carbon; use Illuminate\Database\Eloquent\Factories\HasFactory; +use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Relations\MorphTo; use Illuminate\Database\Eloquent\Relations\Relation; use Illuminate\Database\Eloquent\SoftDeletes; +use Illuminate\Support\Facades\Schema; use Illuminate\Support\Str; /** @@ -53,6 +55,13 @@ class Actionlog extends SnipeModel use Searchable; + /** + * Cache whether a model table has a company_id column. + * + * @var array + */ + protected static array $companyColumnCache = []; + /** * The attributes that should be included when searching the model. * @@ -116,25 +125,81 @@ class Actionlog extends SnipeModel public static function boot() { parent::boot(); - static::creating( - function (self $actionlog) { - // If the admin is a superadmin, let's see if the target instead has a company. - if (auth()->user() && auth()->user()->isSuperUser()) { - if ($actionlog->target) { - $actionlog->company_id = $actionlog->target->company_id; - } elseif ($actionlog->item) { - $actionlog->company_id = $actionlog->item->company_id; - } - } elseif (auth()->user() && auth()->user()->company) { - $actionlog->company_id = auth()->user()->company_id; - } - - if ($actionlog->action_date == '') { - $actionlog->action_date = Carbon::now(); - } - + static::creating(function (self $actionlog): void { + // Only resolve company_id if it was never explicitly set by the caller. + // Using array_key_exists on getRawOriginal() / getAttributes() lets us + // distinguish "was set to null intentionally" from "was never set at all". + if (! array_key_exists('company_id', $actionlog->getAttributes())) { + $actionlog->company_id = static::resolveCompanyIdFromAttributes( + $actionlog->target_type, + $actionlog->target_id, + $actionlog->item_type, + $actionlog->item_id, + ); } - ); + + if ($actionlog->action_date == '') { + $actionlog->action_date = Carbon::now(); + } + }); + } + + /** + * Resolve the company_id for a new action log by querying the item model + * directly, bypassing all global scopes to avoid FMCS filtering issues. + * + * We intentionally prefer the item (asset, license, etc.) over the target + * (user, location) because FMCS visibility is based on who *owns* the item, + * not who it was checked out to. If the item has no company_id we fall back + * to the target so that logs on unowned items still get a company stamp where + * possible. + * + * This has to include an exception for the asset models table, since they are + * not company-constrained (on purpose.) + */ + protected static function resolveCompanyIdFromAttributes( + ?string $targetType, + ?int $targetId, + ?string $itemType, + ?int $itemId, + ): ?int { + // Prefer the item (the thing being acted upon) for FMCS ownership. + $companyId = static::resolveCompanyIdFromModelClass($itemType, $itemId); + + if ($companyId !== null) { + return $companyId; + } + + // Fall back to target only when the item has no company_id. + return static::resolveCompanyIdFromModelClass($targetType, $targetId); + + } + + /** + * Resolve company_id from a model class and ID, but only if that model's + * table has a company_id column. + */ + protected static function resolveCompanyIdFromModelClass(?string $modelClass, ?int $id): ?int + { + if (! $modelClass || ! $id || ! class_exists($modelClass) || ! is_subclass_of($modelClass, Model::class)) { + return null; + } + + /** @var Model $instance */ + $instance = app($modelClass); + $table = $instance->getTable(); + + $hasCompanyColumn = static::$companyColumnCache[$table] + ??= Schema::hasColumn($table, 'company_id'); + + if (! $hasCompanyColumn) { + return null; + } + + return $modelClass::withoutGlobalScopes() + ->whereKey($id) + ->value('company_id'); + } /** diff --git a/app/Models/Asset.php b/app/Models/Asset.php index e04db03175..3d68d2136a 100644 --- a/app/Models/Asset.php +++ b/app/Models/Asset.php @@ -228,6 +228,8 @@ class Asset extends Depreciable protected $searchableRelationAliases = [ 'status_label' => 'status', 'assigned_to' => 'assignedTo', + 'model_number' => 'model', + 'rtd_location' => 'defaultLoc', ]; protected static function booted(): void diff --git a/app/Models/CheckoutAcceptance.php b/app/Models/CheckoutAcceptance.php index 8ead42a0a3..0922552665 100644 --- a/app/Models/CheckoutAcceptance.php +++ b/app/Models/CheckoutAcceptance.php @@ -243,13 +243,27 @@ class CheckoutAcceptance extends Model if ($data['item_serial'] != null) { $pdf->writeHTML(trans('admin/hardware/form.serial').': '.e($data['item_serial']), true, 0, true, 0, ''); } + if (!empty($data['custom_fields']) && is_iterable($data['custom_fields'])) { + foreach ($data['custom_fields'] as $customField) { + $label = $customField['label'] ?? null; + $value = $customField['value'] ?? null; + + if (($label !== null) && ($value !== null) && ($value !== '')) { + $pdf->writeHTML(e((string) $label) . ': ' . e((string) $value), true, 0, true, 0, ''); + } + } + } + if (($data['qty'] != null) && ($data['qty'] > 1)) { $pdf->writeHTML(trans('general.qty').': '.e($data['qty']), true, 0, true, 0, ''); } + $pdf->Ln(); + $pdf->writeHTML('
', true, 0, true, 0, ''); $pdf->writeHTML(trans('general.assignee').': '.e($data['assigned_to']).($data['employee_num'] ? ' ('.$data['employee_num'].')' : ''), true, 0, true, 0, ''); if ($data['email'] != null) { $pdf->writeHTML(trans('general.email').': '.e($data['email']), true, 0, true, 0, ''); } + $pdf->Ln(); $pdf->writeHTML('
', true, 0, true, 0, ''); diff --git a/app/Models/Group.php b/app/Models/Group.php index 408236f38f..589c980b1c 100755 --- a/app/Models/Group.php +++ b/app/Models/Group.php @@ -81,6 +81,12 @@ class Group extends SnipeModel return $this->belongsToMany(User::class, 'users_groups'); } + /* this is just a shim for SCIM to work */ + public function members() + { + return $this->users(); + } + /** * Decode JSON permissions into array * diff --git a/app/Models/Maintenance.php b/app/Models/Maintenance.php index 66b04b2050..236b5fc3fa 100644 --- a/app/Models/Maintenance.php +++ b/app/Models/Maintenance.php @@ -32,6 +32,8 @@ class Maintenance extends SnipeModel implements ICompanyableChild protected $presenter = MaintenancesPresenter::class; + protected $with = ['asset', 'asset.company']; + protected $table = 'maintenances'; protected $rules = [ diff --git a/app/Models/SCIMUser.php b/app/Models/SCIMUser.php index e893638cc1..125f3dd737 100644 --- a/app/Models/SCIMUser.php +++ b/app/Models/SCIMUser.php @@ -13,4 +13,12 @@ class SCIMUser extends User $attributes['password'] = $this->noPassword(); parent::__construct($attributes); } -} + + // Have to re-define this here because Eloquent will try to 'guess' a foreign key of s_c_i_m_user_id + // from SCIMUser + public function groups() + { + return $this->belongsToMany(\App\Models\Group::class, 'users_groups', 'user_id', 'group_id'); + } + +} \ No newline at end of file diff --git a/app/Models/SnipeSCIMConfig.php b/app/Models/SnipeSCIMConfig.php index 411e44678e..0b5718ecd8 100644 --- a/app/Models/SnipeSCIMConfig.php +++ b/app/Models/SnipeSCIMConfig.php @@ -2,248 +2,487 @@ namespace App\Models; -use ArieTimmerman\Laravel\SCIMServer\Attribute\AttributeMapping; +use ArieTimmerman\Laravel\SCIMServer\Exceptions\SCIMException; +use ArieTimmerman\Laravel\SCIMServer\Helper; +use ArieTimmerman\Laravel\SCIMServer\Parser\Path; use ArieTimmerman\Laravel\SCIMServer\SCIM\Schema; -use ArieTimmerman\Laravel\SCIMServer\SCIMConfig; +use ArieTimmerman\Laravel\SCIMServer\Attribute\Attribute; +use ArieTimmerman\Laravel\SCIMServer\Attribute\Collection; +use ArieTimmerman\Laravel\SCIMServer\Attribute\Complex; +use ArieTimmerman\Laravel\SCIMServer\Attribute\Constant; +use ArieTimmerman\Laravel\SCIMServer\Attribute\Eloquent; +use ArieTimmerman\Laravel\SCIMServer\Attribute\JSONCollection; +use ArieTimmerman\Laravel\SCIMServer\Attribute\Meta; +use ArieTimmerman\Laravel\SCIMServer\Attribute\MutableCollection; +use ArieTimmerman\Laravel\SCIMServer\Attribute\Schema as AttributeSchema; use Illuminate\Database\Eloquent\Model; +use ArieTimmerman\Laravel\SCIMServer\Attribute\AttributeMapping; +use ArieTimmerman\Laravel\SCIMServer\SCIMConfig; -class SnipeSCIMConfig extends SCIMConfig +function a($name = null): Attribute { + return new Attribute($name); +} + +function complex($name = null): Complex +{ + return new Complex($name); +} + +function eloquent($name, $attribute = null): Attribute +{ + return new Eloquent($name, $attribute); +} + +class EloquentWithRemove extends Eloquent +{ + public function remove($value, Model &$object, Path $path = null) + { + $object->{$this->attribute} = null; + } + +} + +class MappedTable extends Attribute +{ + public function __construct( + private string $scim_attribute_name, + private string $relationship_name, + private string $relationship_class, + private string $relationship_id_field, + private string $relationship_field) + { + parent::__construct($this->scim_attribute_name); + } + + protected function doRead(&$object, $attributes = []) + { + return $object->{$this->relationship_name}?->{$this->relationship_field}; + } + + public function add($value, Model &$object) + { + $object->{$this->relationship_id_field} = $value ? $this->relationship_class::firstOrCreate([$this->relationship_field => $value])->id : null; + } + + public function replace($value, Model &$object, $path = null, $removeIfNotSet = false) + { + $object->{$this->relationship_id_field} = $value ? $this->relationship_class::firstOrCreate([$this->relationship_field => $value])->id : null; + } + + public function patch($operation, $value, Model &$object, Path $path = null, $removeIfNotSet = false) + { + $object->{$this->relationship_id_field} = $value ? $this->relationship_class::firstOrCreate([$this->relationship_field => $value])->id : null; + } + +} + +class UpdatableComplex extends Complex +{ + + public function doWrite($operation, $subop, $value, Model &$object, Path $path = null, $removeIfNotSet = false) + { + throw new \Exception("doWrite is not implemented yet for Operation: $operation " . ($subop ? "($subop)" : "") . "on attribute " . $this->getFullKey()); + } + + public function add($value, Model &$object) + { + $this->doWrite("add", null, $value, $object); + } + + public function replace($value, Model &$object, Path $path = null, $removeIfNotSet = false) + { + $this->doWrite("replace", null, $value, $object, $path, $removeIfNotSet); + } + + public function patch($operation, $value, Model &$object, Path $path = null, $removeIfNotSet = false) + { + $this->doWrite("patch", $operation, $value, $object, $path, $removeIfNotSet); + } + + public function remove($value, Model &$object, Path $path = null) + { + $this->doWrite("remove", null, null, $object, $path); + } +} + + +class SnipeSCIMConfig +{ + public function __construct() + { + } + + public function getConfigForResource($name) + { + $result = $this->getConfig(); + return @$result[$name]; + } + + public function getGroupClass() + { + return Group::class; + } + + const ENTERPRISE = 'urn:ietf:params:scim:schemas:extension:enterprise:2.0:User'; + const GROKABILITY = 'urn:ietf:params:scim:schemas:extension:grokability:2.0:User'; + public function getUserConfig() { - // Much of this is copied verbatim from the library, then adjusted for our needs - - /* - more snipe-it attributes I'd like to check out (to map to 'enterprise' maybe?): - - website - - notes? - - remote??? - - location_id ? - - company_id to "organization?" - */ - - $user_prefix = 'urn:ietf:params:scim:schemas:core:2.0:User:'; - $enterprise_prefix = 'urn:ietf:params:scim:schemas:extension:enterprise:2.0:User:'; - return [ // Set to 'null' to make use of auth.providers.users.model (App\User::class) 'class' => SCIMUser::class, - - 'validations' => [ - $user_prefix.'userName' => 'required', - $user_prefix.'displayName' => 'nullable|string', - $user_prefix.'name.givenName' => 'required', - $user_prefix.'name.familyName' => 'nullable|string', - $user_prefix.'externalId' => 'nullable|string', - $user_prefix.'emails' => 'nullable|array', - $user_prefix.'emails.*.value' => 'nullable|email', - $user_prefix.'active' => 'boolean', - $user_prefix.'phoneNumbers' => 'nullable|array', - $user_prefix.'phoneNumbers.*.value' => 'nullable|string', - $user_prefix.'addresses' => 'nullable|array', - $user_prefix.'addresses.*.streetAddress' => 'nullable|string', - $user_prefix.'addresses.*.locality' => 'nullable|string', - $user_prefix.'addresses.*.region' => 'nullable|string', - $user_prefix.'addresses.*.postalCode' => 'nullable|string', - $user_prefix.'addresses.*.country' => 'nullable|string', - $user_prefix.'title' => 'nullable|string', - $user_prefix.'preferredLanguage' => 'nullable|string', - - // Enterprise validations: - $enterprise_prefix.'employeeNumber' => 'nullable|string', - $enterprise_prefix.'department' => 'nullable|string', - $enterprise_prefix.'manager' => 'nullable', - $enterprise_prefix.'manager.value' => 'nullable|string', - ], - 'singular' => 'User', - 'schema' => [Schema::SCHEMA_USER], // eager loading 'withRelations' => [], - 'map_unmapped' => false, - // 'unmapped_namespace' => 'urn:ietf:params:scim:schemas:laravel:unmapped', 'description' => 'User Account', - // Map a SCIM attribute to an attribute of the object. - 'mapping' => [ - - 'id' => (new AttributeMapping)->setRead( - function (&$object) { - return (string) $object->id; + 'map' => complex()->withSubAttributes( + new class ('schemas', [ + "urn:ietf:params:scim:schemas:core:2.0:User", + self::ENTERPRISE, + self::GROKABILITY + ]) extends Constant { + public function replace($value, &$object, $path = null) + { + // do nothing + $this->dirty = true; + } + }, + (new class ('id', null) extends Constant { // TODO - this 'id' is in the same namespace for objects OR groups? + protected function doRead(&$object, $attributes = []) + { + return (string)$object->id; } - )->disableWrite(), - 'externalId' => AttributeMapping::eloquent('scim_externalid'), // FIXME - I have a PR that changes a lot of this. - - 'meta' => [ - 'created' => AttributeMapping::eloquent('created_at')->disableWrite(), - 'lastModified' => AttributeMapping::eloquent('updated_at')->disableWrite(), - - 'location' => (new AttributeMapping)->setRead( - function ($object) { - return route( - 'scim.resource', - [ - 'resourceType' => 'Users', - 'resourceObject' => $object->id, - ] - ); + public function remove($value, &$object, $path = null) + { + // do nothing + } + } + ), + new Meta('Users'), + (new AttributeSchema(Schema::SCHEMA_USER, true))->withSubAttributes( + eloquent('userName', 'username')->ensure('required'), + (new class ('active', 'activated') extends Eloquent { + protected function doRead(&$object, $attributes = []) + { + return (bool)$object->activated; // need this extension to force boolean-ness } - )->disableWrite(), + }), + complex('name')->withSubAttributes( + eloquent('givenName', 'first_name')->ensure('required'), + eloquent('familyName', 'last_name'), + ), // ->ensure('required'), It *is* a bit weird, but I would've thought 'name' is required since 'givenName' is required? But apparently not? + eloquent('displayName', 'display_name'), //yes, this is *not* under 'name' - that's the spec + //eloquent('password')->ensure('nullable')->setReturned('never'), + eloquent('externalId', 'scim_externalid'), - 'resourceType' => AttributeMapping::constant('User'), - ], - - 'schemas' => AttributeMapping::constant( - [ - 'urn:ietf:params:scim:schemas:core:2.0:User', - 'urn:ietf:params:scim:schemas:extension:enterprise:2.0:User', - ] - )->ignoreWrite(), - - 'urn:ietf:params:scim:schemas:core:2.0:User' => [ - - 'userName' => AttributeMapping::eloquent('username'), - - 'name' => [ - 'formatted' => (new AttributeMapping)->ignoreWrite()->setRead( - function (&$object) { - return $object->getFullNameAttribute(); - } - ), - 'familyName' => AttributeMapping::eloquent('last_name'), - 'givenName' => AttributeMapping::eloquent('first_name'), - 'middleName' => null, - 'honorificPrefix' => null, - 'honorificSuffix' => null, - ], - - 'displayName' => AttributeMapping::eloquent('display_name'), - 'nickName' => null, - 'profileUrl' => null, - 'title' => AttributeMapping::eloquent('jobtitle'), - 'userType' => null, - 'preferredLanguage' => AttributeMapping::eloquent('locale'), // Section 5.3.5 of [RFC7231] - 'locale' => null, // see RFC5646 - 'timezone' => null, // see RFC6557 - 'active' => (new AttributeMapping)->setAdd( - function ($value, &$object) { - $object->activated = $value; + // Email chonk + (new class ('emails') extends UpdatableComplex { + protected function doRead(&$object, $attributes = []) + { + return collect([$object->email])->map(function ($email) { + return [ + 'value' => $email, + 'type' => 'work', //TODO - is this how we always have done it? + 'primary' => true + ]; + })->toArray(); } - )->setReplace( - function ($value, &$object) { - $object->activated = $value; - } - )->setRead( - // this works as specified. - function (&$object) { - return (bool) $object->activated; - } - ), - 'password' => AttributeMapping::eloquent('password')->disableRead(), - // Multi-Valued Attributes - 'emails' => [[ - 'value' => AttributeMapping::eloquent('email'), - 'display' => null, - 'type' => AttributeMapping::constant('work')->ignoreWrite(), - 'primary' => AttributeMapping::constant(true)->ignoreWrite(), - ]], - - 'phoneNumbers' => [[ - 'value' => AttributeMapping::eloquent('phone'), - 'display' => null, - 'type' => AttributeMapping::constant('work')->ignoreWrite(), - 'primary' => AttributeMapping::constant(true)->ignoreWrite(), - ]], - - 'ims' => [[ - 'value' => null, - 'display' => null, - 'type' => null, - 'primary' => null, - ]], // Instant messaging addresses for the User - - 'photos' => [[ - 'value' => null, - 'display' => null, - 'type' => null, - 'primary' => null, - ]], - - 'addresses' => [[ - 'type' => AttributeMapping::constant('work')->ignoreWrite(), - 'formatted' => AttributeMapping::constant('n/a')->ignoreWrite(), // TODO - is this right? This doesn't look right. - 'streetAddress' => AttributeMapping::eloquent('address'), - 'locality' => AttributeMapping::eloquent('city'), - 'region' => AttributeMapping::eloquent('state'), - 'postalCode' => AttributeMapping::eloquent('zip'), - 'country' => AttributeMapping::eloquent('country'), - 'primary' => AttributeMapping::constant(true)->ignoreWrite(), // this isn't in the example? - ]], - - 'groups' => [[ - 'value' => null, - '$ref' => null, - 'display' => null, - 'type' => null, - ]], - - 'entitlements' => null, - 'roles' => null, - 'x509Certificates' => null, - ], - - 'urn:ietf:params:scim:schemas:extension:enterprise:2.0:User' => [ - 'employeeNumber' => AttributeMapping::eloquent('employee_num'), - 'department' => (new AttributeMapping)->setAdd( // FIXME parent? - function ($value, &$object) { - $department = Department::where('name', $value)->first(); - if ($department) { - $object->department_id = $department->id; + public function doWrite($operation, $subop, $value, Model &$object, Path $path = null, $removeIfNotSet = false) + { + if ($value) { + $object->email = $value[0]['value']; + } else { + $object->email = null; } } - )->setReplace( - function ($value, &$object) { - $department = Department::where('name', $value)->first(); - if ($department) { - $object->department_id = $department->id; + })->withSubAttributes( + eloquent('value', 'email')->ensure('email', 'nullable'), //Weird, this 'needs' nullable to work? + new Constant('type', 'work'), + (new Constant('primary', true))->ensure('boolean') + )->ensure('array') + ->setMultiValued(true), + + // phone chonk + (new class ('phoneNumbers') extends UpdatableComplex { + protected function doRead(&$object, $attributes = []) + { + $phones = []; + if ($object->phone) { + $phones[] = [ + 'value' => $object->phone, + 'type' => 'work' + ]; + } + if ($object->mobile) { + $phones[] = [ + 'value' => $object->mobile, + 'type' => 'mobile' + ]; + } + return $phones; } - )->setRead( - function (&$object) { - return $object->department ? $object->department->name : null; - } - ), - 'manager' => [ - // FIXME - manager writes are disabled. This kinda works but it leaks errors all over the place. Not cool. - // '$ref' => (new AttributeMapping())->ignoreWrite()->ignoreRead(), - // 'displayName' => (new AttributeMapping())->ignoreWrite()->ignoreRead(), - // NOTE: you could probably do a 'plain' Eloquent mapping here, but we don't for future-proofing - 'value' => (new AttributeMapping)->setAdd( - function ($value, &$object) { - $manager = User::find($value); - if ($manager) { - $object->manager_id = $manager->id; + + public function doWrite($operation, $subop, $value, Model &$object, Path $path = null, $removeIfNotSet = false) + { + \Log::debug("Phones 'value' is: " . print_r($value, true)); + if ($operation == "patch") { + if ($path->getValuePathFilter() != null) { + if ((string)$path == 'phoneNumbers[type eq "mobile"].value') { + $object->mobile = $value; //I don't know why the value is the raw value, but it is? + return; + } + if ((string)$path == 'phoneNumbers[type eq "work"].value') { + $object->phone = $value; //similar, don't know why, but it is + return; + } + } + parent::patch($subop, $value, $object, $path, $removeIfNotSet); + return; + } + foreach ($value as $phone) { + switch ($phone['type']) { + case 'work': + $object->phone = $phone['value']; + break; + + case 'mobile': + $object->mobile = $phone['value']; + break; + + default: + throw new SCIMException("Unknown phone type '" . @$phone['type'] . "'", 400); } } - )->setReplace( - function ($value, &$object) { - $manager = User::find($value); - if ($manager) { - $object->manager_id = $manager->id; + } + + })->withSubAttributes( // TODO: I suspect these 'sub-attributes' aren't being checked at all + (new Constant('value', 'email'))->ensure('string'), // TODO - this is WRONG, but it works somehow? Probably because it's ignored + new Constant('type', 'other'), // TODO uh, *also* wrong? but, again, seems to be ignored + )->ensure('array') + ->setMultiValued(true), + + // addresses chonk + (new class ('addresses') extends UpdatableComplex { + static $addressmap = [ + 'streetAddress' => 'address', + 'locality' => 'city', + 'region' => 'state', + 'postalCode' => 'zip', + 'country' => 'country' + ]; + + protected function doRead(&$object, $attributes = []) + { + $address = []; + foreach (self::$addressmap as $scim_field => $db_field) { + if ($object->{$db_field}) { + $address[$scim_field] = $object->{$db_field}; } } - )->setRead( - function (&$object) { - return $object->manager_id; + if (count($address) > 0) { + $address['type'] = 'work'; + $address['primary'] = true; } - ), - ], - ], - ], + return $address; + } + + public function doWrite($operation, $subop, $value, Model &$object, Path $path = null, $removeIfNotSet = false) + { + // TODO - this is validated *just* for 'patch' operations, so this may not work in other write contexts + if ($path->getValuePathFilter() != null) { + \Log::debug("path for update $path"); + // get the part of the $path that we actually care about - something like: + // addresses[type eq "work"] + $matches = null; + if (!preg_match('/^.+\[type eq "([a-zA-Z]+)"](?:\.([a-zA-Z]+))?$/', (string)$path, $matches)) { + throw new SCIMException("Unknown path type '$path'")->setCode(422); + } + $type = $matches[1]; + if ($type != 'work') { + throw new SCIMException("Unknown object type '$type'")->setCode(422); + } + $attribute = array_key_exists(2, $matches) ? $matches[2] : null; + if (array_key_exists($attribute, self::$addressmap)) { + $object->{self::$addressmap[$attribute]} = $value; + return; + } + + + throw new SCIMException("Could not handle path for update $path")->setCode(422); + } + } + + })->withSubAttributes( + eloquent('streetAddress', 'address'), + eloquent('locality', 'city'), + eloquent('region', 'state'), + eloquent('postalCode', 'zip'), + eloquent('country', 'country'), + new Constant('type', 'other'), + (new Constant('primary', true))->ensure('boolean') + )->ensure('array') + ->setMultiValued(true), + + eloquent('title', 'jobtitle'), + eloquent('preferredLanguage', 'locale'), + (new Collection('groups'))->withSubAttributes( + eloquent('value', 'id'), + (new class ('$ref') extends Eloquent { + protected function doRead(&$object, $attributes = []) + { + return route( + 'scim.resource', + [ + 'resourceType' => 'Group', + 'resourceObject' => $object->id ?? "not-saved" + ] + ); + } + }), + eloquent('display', 'name') + ), + (new JSONCollection('roles'))->withSubAttributes( // TODO - what is this? + eloquent('value')->ensure('required', 'min:3', 'alpha_dash:ascii'), + eloquent('display')->ensure('nullable', 'min:3', 'alpha_dash:ascii'), + eloquent('type')->ensure('nullable', 'min:3', 'alpha_dash:ascii'), + eloquent('primary')->ensure('boolean')->default(false) + )->ensure('nullable', 'array', 'max:20') + ), + (new AttributeSchema(self::ENTERPRISE, false))->withSubAttributes( + eloquent('employeeNumber', 'employee_num')->ensure('nullable'), + new MappedTable('department', 'department', Department::class, 'department_id', 'name'), + (new class('manager') extends UpdatableComplex { + protected function doRead(&$object, $attributes = []) + { + if (!$object->manager) { + return null; + } + return [ + 'value' => $object->manager->id, //TODO - ID's aren't unique like they're supposed to be :/ + '$ref' => route('scim.resource', ['resourceType' => 'User', 'resourceObject' => $object->manager->id]), + 'displayName' => $object->manager->display_name, + ]; + } + + public function doWrite($operation, $subop, $value, Model &$object, $path = null, $removeIfNotSet = false) + { + \Log::debug("What type of value is value? " . gettype($value)); + $manager_id = null; + if (is_scalar($value)) { + \Log::debug("Weird Microsoft mode - set manager to the \$value and move on with life?"); + $manager_id = $value; + } elseif (array_key_exists('$ref', $value)) { + // Here's the spec: https://datatracker.ietf.org/doc/html/rfc7643#section-4.3 + + // according to the spec it's _recommended_ to do: + // $ref - which should be the URI of the manager + + // extract ID from URL, jam it in? + $url = $value['$ref']; + $users_prefix = route('scim.resources', ['resourceType' => 'User']) . '/'; + if (string_starts_with($url, $users_prefix)) { + $manager_id = substr($url, strlen($users_prefix)); + } + } elseif (array_key_exists('value', $value)) { + // this is _Snipe-IT_'s ID being passed as 'value' I believe? + // if you use the 'managerId' field in Okta, you get: + // [value] => 9999999 + // that, at least, is the spec - but *what* ID is that?! It's supposed to be a Snipe-IT one! + $manager_id = $value['value']; + } + \Log::debug("Non-Microsoft - Trying to '$operation' for manager with value: " . print_r($value, true)); + if ($manager_id && User::find($manager_id)) { + $object->manager_id = $manager_id; + return; + } + throw new SCIMException("No manager given, or manager doesn't exist", 400); + } + }) // ->withSubAttributes() ... -> ensure() ? + ), + (new AttributeSchema(self::GROKABILITY, false))->withSubAttributes( + new MappedTable('location', 'location', Location::class, 'location_id', 'name'), + new MappedTable('company', 'company', Company::class, 'company_id', 'name'), + ) + ), + ]; + } + + public function getGroupConfig() + { + return [ + + 'class' => $this->getGroupClass(), + 'singular' => 'Group', + + //eager loading + 'withRelations' => [], + 'description' => 'Group', + + 'map' => complex()->withSubAttributes( + new class ('schemas', [ + "urn:ietf:params:scim:schemas:core:2.0:Group", + ]) extends Constant { + public function replace($value, &$object, $path = null) + { + // do nothing + $this->dirty = true; + } + }, + (new class ('id', null) extends Constant { + protected function doRead(&$object, $attributes = []) + { + return (string)$object->id; + } + + public function remove($value, &$object, $path = null) + { + // do nothing + } + } + ), + new EloquentWithRemove('externalId', 'scim_externalid'), + new Meta('Groups'), + (new AttributeSchema(Schema::SCHEMA_GROUP, true))->withSubAttributes( + eloquent('displayName', 'name')->ensure('required', 'min:3', function ($attribute, $value, $fail) { + // check if group does not exist or if it exists, it is the same group + $group = $this->getGroupClass()::where('name', $value)->first(); + if ($group && (request()->route('resourceObject') == null || $group->id != request()->route('resourceObject')->id)) { + $fail('The name has already been taken.'); + } + }), + (new MutableCollection('members'))->withSubAttributes( + eloquent('value', 'id')->ensure('required'), + (new class ('$ref') extends Eloquent { + protected function doRead(&$object, $attributes = []) + { + return route( + 'scim.resource', + [ + 'resourceType' => 'Users', + 'resourceObject' => $object->id ?? "not-saved" + ] + ); + } + }), + eloquent('display', 'name') + )->ensure('nullable', 'array') + ) + ), + ]; + } + + public function getConfig() + { + return [ + 'Users' => $this->getUserConfig(), + 'Groups' => $this->getGroupConfig(), ]; } } diff --git a/app/Models/Traits/Loggable.php b/app/Models/Traits/Loggable.php index a97a978f29..5ae4fdca07 100644 --- a/app/Models/Traits/Loggable.php +++ b/app/Models/Traits/Loggable.php @@ -4,6 +4,7 @@ namespace App\Models\Traits; use App\Models\Actionlog; use App\Models\Asset; +use App\Models\ICompanyableChild; use App\Models\License; use App\Models\LicenseSeat; use App\Models\Location; @@ -177,6 +178,7 @@ trait Loggable $log->note = $note; $log->action_date = $action_date; $log->quantity = $quantity; + $log->company_id = $this->resolveLoggableCompanyId(); $changed = []; $array_to_flip = array_keys($fields_array); @@ -221,6 +223,37 @@ trait Loggable return $log; } + /** + * Resolve the company_id that should be stamped on an action log entry. + * + * LicenseSeat does not carry a company_id directly — it belongs to a License, + * so we fetch the parent license's company_id in that case. All other models + * that use the Loggable trait have a company_id column directly. + */ + private function resolveLoggableCompanyId(): ?int + { + if (static::class === LicenseSeat::class) { + return $this->license?->company_id; + } + + if (isset($this->company_id)) { + return $this->company_id; + } + + // Companyable children (like Maintenance) inherit company visibility from parents. + if ($this instanceof ICompanyableChild) { + foreach ((array) $this->getCompanyableParents() as $parentRelation) { + $parent = $this->{$parentRelation} ?? null; + + if (isset($parent?->company_id)) { + return $parent->company_id; + } + } + } + + return null; + } + /** * @author Daniel Meltzer * @@ -267,6 +300,7 @@ trait Loggable $log->location_id = null; $log->note = $note; $log->action_date = $action_date; + $log->company_id = $this->resolveLoggableCompanyId(); if (! $action_date) { $log->action_date = date('Y-m-d H:i:s'); @@ -383,6 +417,8 @@ trait Loggable $log->created_by = auth()->id(); $log->filename = $filename; $log->action_date = date('Y-m-d H:i:s'); + // Explicitly stamp company_id from the item being audited so FMCS scoping works correctly. + $log->company_id = $this->resolveLoggableCompanyId(); $log->logaction('audit'); $params = [ @@ -468,6 +504,7 @@ trait Loggable $log->action_date = date('Y-m-d H:i:s'); $log->note = $note; $log->created_by = $created_by; + $log->company_id = $this->resolveLoggableCompanyId(); $log->logaction('create'); $log->save(); @@ -494,6 +531,7 @@ trait Loggable $log->created_by = auth()->id(); $log->note = $note; $log->target_id = null; + $log->company_id = $this->resolveLoggableCompanyId(); $log->created_at = date('Y-m-d H:i:s'); $log->action_date = date('Y-m-d H:i:s'); $log->filename = $filename; diff --git a/app/Models/Traits/Searchable.php b/app/Models/Traits/Searchable.php index 8e2ba663fa..1021d59583 100644 --- a/app/Models/Traits/Searchable.php +++ b/app/Models/Traits/Searchable.php @@ -56,9 +56,12 @@ trait Searchable $preparedSearch = $this->prepareSearchInput((string) $search); $terms = $preparedSearch['terms']; $filters = $preparedSearch['filters']; + $filterOperator = $preparedSearch['filter_operator']; if (! empty($filters)) { - return $this->applySearchFilters($query, $filters); + // Structured advanced-search filters are mutually exclusive with free-text terms. + // Once we detect structured payloads, we avoid the broad OR-based free-text path. + return $this->applySearchFilters($query, $filters, $filterOperator); } /** @@ -101,15 +104,27 @@ trait Searchable return [ 'terms' => [], 'filters' => $parsedFilters, + 'filter_operator' => $this->resolveStructuredFilterOperator(), ]; } return [ 'terms' => $this->prepeareSearchTerms($search), 'filters' => [], + 'filter_operator' => 'and', ]; } + /** + * Resolve the structured advanced-search operator from the current request. + */ + private function resolveStructuredFilterOperator(): string + { + $operator = strtolower((string) request()->input('filter_operator', 'and')); + + return $operator === 'or' ? 'or' : 'and'; + } + /** * Normalize a structured filter payload into scalar string filters. */ @@ -122,6 +137,7 @@ trait Searchable $payload = $search; if (str_starts_with($search, 'filter:')) { + // Some callers send filter payloads with an explicit "filter:" prefix. $payload = substr($search, 7); } elseif (! (str_starts_with($search, '{') && str_ends_with($search, '}'))) { return null; @@ -147,6 +163,7 @@ trait Searchable $normalizedValue = trim((string) ($value ?? '')); if ($normalizedValue === '') { + // Ignore empty fields so clearing an input does not create noisy no-op filters. continue; } @@ -174,83 +191,280 @@ trait Searchable * * @param array $filters */ - private function applySearchFilters(Builder $query, array $filters): Builder + private function applySearchFilters(Builder $query, array $filters, string $filterOperator = 'and'): Builder { + if ($filterOperator === 'or') { + $query->where(function (Builder $filterQuery) use ($filters) { + foreach ($filters as $filterKey => $filterValue) { + $this->applySingleSearchFilter($filterQuery, $filterKey, $filterValue, 'or'); + } + }); + + return $query; + } + + foreach ($filters as $filterKey => $filterValue) { + $this->applySingleSearchFilter($query, $filterKey, $filterValue); + } + + return $query; + } + + /** + * Parse a raw filter value for an optional negation, null-check, or exact-match prefix. + * + * Supported syntax: + * - "!flarb" → operator = not_like, value = "flarb" + * - "not:flarb" → operator = not_like, value = "flarb" + * - "is:null" → operator = is_null, value = "" (reserved token) + * - "is:not_null" → operator = is_not_null, value = "" (reserved token) + * - "is:flarb" → operator = exact, value = "flarb" (exact equality) + * + * `is:null` and `is:not_null` are checked before the generic `is:` prefix so they always + * resolve to their dedicated null-check operators regardless of casing. + * + * The legacy `negate` boolean is preserved alongside `operator` so that + * existing callers that only check `negate` still work correctly. + * + * @return array{value: string, negate: bool, operator: string} + */ + private function parseFilterValue(string $raw): array + { + $lower = strtolower($raw); + + if ($lower === 'is:null') { + // Reserved token: interpreted as null-check operator, not exact match string. + return ['value' => '', 'negate' => false, 'operator' => 'is_null']; + } + + if ($lower === 'is:not_null') { + // Reserved token: interpreted as non-null check operator. + return ['value' => '', 'negate' => false, 'operator' => 'is_not_null']; + } + + if (str_starts_with($lower, 'is:')) { + // Generic exact-match prefix. This is checked after reserved is:null/is:not_null tokens. + $exactValue = substr($raw, 3); + + return ['value' => $exactValue, 'negate' => false, 'operator' => 'exact']; + } + + if (str_starts_with($raw, '!')) { + return ['value' => substr($raw, 1), 'negate' => true, 'operator' => 'not_like']; + } + + if (str_starts_with($lower, 'not:')) { + return ['value' => substr($raw, 4), 'negate' => true, 'operator' => 'not_like']; + } + + return ['value' => $raw, 'negate' => false, 'operator' => 'like']; + } + + /** + * Apply a single structured filter using the provided boolean operator. + * + * Negation: if the filter value is prefixed with "!" or "not:", the filter + * uses NOT LIKE (for attributes/custom fields) or whereDoesntHave (for + * relations), effectively excluding records matching the value. + * + * For relation filters, negation uses NOT LIKE inside whereHas, meaning + * "has a related record where the column does NOT contain the value". + * Records with no related record (e.g. unassigned assets) are excluded; + * use a plain empty-string filter if you need to match NULLs. + */ + private function applySingleSearchFilter(Builder $query, string $filterKey, string $filterValue, string $boolean = 'and'): Builder + { + $parsed = $this->parseFilterValue($filterValue); + $value = $parsed['value']; + $negate = $parsed['negate']; + $operator = $parsed['operator']; + + // IS NULL / IS NOT NULL are handled before value-based filtering, + // because there is no meaningful value to pass to LIKE for them. + if ($operator === 'is_null' || $operator === 'is_not_null') { + return $this->applyNullFilter($query, $filterKey, $operator === 'is_null', $boolean); + } + + // Skip gracefully if stripping the prefix leaves an empty value. + if ($value === '') { + return $query; + } + $searchableAttributes = $this->getSearchableAttributes(); $searchableCounts = $this->getSearchableCounts(); $searchableRelations = $this->getSearchableRelations(); $table = $this->getTable(); + $whereMethod = $boolean === 'or' ? 'orWhere' : 'where'; + $likeOperator = $negate ? 'NOT LIKE' : 'LIKE'; - foreach ($filters as $filterKey => $filterValue) { - if (in_array($filterKey, $searchableAttributes, true)) { - $query->where($table.'.'.$filterKey, 'LIKE', '%'.$filterValue.'%'); - - continue; + if (in_array($filterKey, $searchableAttributes, true)) { + if ($operator === 'exact') { + $query->{$whereMethod}($table.'.'.$filterKey, '=', $value); + } else { + $query->{$whereMethod}($table.'.'.$filterKey, $likeOperator, '%'.$value.'%'); } - if (in_array($filterKey, $searchableCounts, true)) { - $query = $this->applyCountAliasFilter($query, $filterKey, $filterValue); + return $query; + } - continue; - } + // Handle virtual columns — keys that are not real DB columns but map to a set + // of real columns searched via CONCAT (e.g. "name" → first_name + last_name on User). + $virtualColumns = $this->getSearchableVirtualColumns(); - // Check if this is a custom field (only for Assets - for *now*). - // Only db_column keys (e.g. "_snipeit_cpu_4") are accepted to avoid - // collisions with standard attributes or relation filter keys. - if ($this instanceof Asset) { - $dbColumn = $this->resolveCustomFieldDbColumn($filterKey); + if (array_key_exists($filterKey, $virtualColumns)) { + $qualifiedColumns = array_map( + fn ($col) => $table.'.'.$col, + $virtualColumns[$filterKey] + ); - if ($dbColumn !== null) { - $query->where($table.'.'.$dbColumn, 'LIKE', '%'.$filterValue.'%'); + if ($operator === 'exact') { + // Exact match on the full CONCAT'd value, e.g. "John Smith" matches only + // users whose first_name + ' ' + last_name equals exactly "John Smith". + $concatSql = $this->buildMultipleColumnSearch($qualifiedColumns); + // buildMultipleColumnSearch intentionally returns a fragment ending in "LIKE ?"; + // for exact matches we rewrite only the operator and keep the same SQL scaffold. + $concatSql = str_replace(' LIKE ?', ' = ?', $concatSql); + $rawMethod = $boolean === 'or' ? 'orWhereRaw' : 'whereRaw'; + $query->{$rawMethod}($concatSql, [$value]); + } else { + $concatSql = $this->buildMultipleColumnSearch($qualifiedColumns); - continue; - } - } - - $resolvedRelationKey = $this->resolveSearchableRelationKey($filterKey, $searchableRelations); - - if ($resolvedRelationKey === null) { - continue; - } - - if ($this->isAssignedToRelationKey($resolvedRelationKey)) { - $query = $this->applyAssignedToRelationFilter($query, $resolvedRelationKey, $filterValue); - - continue; - } - - $relationColumns = (array) $searchableRelations[$resolvedRelationKey]; - - $query->whereHas($resolvedRelationKey, function (Builder $relationQuery) use ($resolvedRelationKey, $relationColumns, $filterValue) { - $relationTable = $this->getRelationTable($resolvedRelationKey); - $firstConditionAdded = false; - - foreach ($relationColumns as $relationColumn) { - if (! $firstConditionAdded) { - $relationQuery->where($relationTable.'.'.$relationColumn, 'LIKE', '%'.$filterValue.'%'); - $firstConditionAdded = true; - - continue; - } - - $relationQuery->orWhere($relationTable.'.'.$relationColumn, 'LIKE', '%'.$filterValue.'%'); + if ($negate) { + $concatSql = str_replace(' LIKE ?', ' NOT LIKE ?', $concatSql); } - if (($resolvedRelationKey === 'adminuser') || ($resolvedRelationKey === 'user')) { - $relationQuery->orWhereRaw( - $this->buildMultipleColumnSearch( - [ + $rawMethod = $boolean === 'or' ? 'orWhereRaw' : 'whereRaw'; + $query->{$rawMethod}($concatSql, ['%'.$value.'%']); + } + + return $query; + } + + if (in_array($filterKey, $searchableCounts, true)) { + return $this->applyCountAliasFilter($query, $filterKey, $value, $boolean, $negate); + } + + // Check if this is a custom field (only for Assets - for *now*). + // Only db_column keys (e.g. "_snipeit_cpu_4") are accepted to avoid + // collisions with standard attributes or relation filter keys. + if ($this instanceof Asset) { + $dbColumn = $this->resolveCustomFieldDbColumn($filterKey); + + if ($dbColumn !== null) { + // Structured custom-field filters currently support LIKE/NOT LIKE semantics only. + // (No exact/is:null operators for custom fields yet.) + $query->{$whereMethod}($table.'.'.$dbColumn, $likeOperator, '%'.$value.'%'); + + return $query; + } + } + + $resolvedRelationKey = $this->resolveSearchableRelationKey($filterKey, $searchableRelations); + + if ($resolvedRelationKey === null) { + return $query; + } + + if ($this->isAssignedToRelationKey($resolvedRelationKey)) { + return $this->applyAssignedToRelationFilter($query, $resolvedRelationKey, $value, $boolean, $negate); + } + + $relationColumns = $this->getStructuredFilterRelationColumns( + filterKey: $filterKey, + resolvedRelationKey: $resolvedRelationKey, + searchableRelations: $searchableRelations, + ); + + // For negated relation filters (e.g. location: !dam), include rows with + // no related record as well as rows with related records that do not match. + // This aligns advanced-search behavior with user expectation for "not X". + if ($operator !== 'exact' && $likeOperator === 'NOT LIKE') { + $compoundMethod = $boolean === 'or' ? 'orWhere' : 'where'; + + $query->{$compoundMethod}(function (Builder $compoundQuery) use ($resolvedRelationKey, $relationColumns, $value): void { + // Critical behavior: "not X" on relations should include records with no relation. + // Example: location=!dam should include users without a location. + $compoundQuery->doesntHave($resolvedRelationKey) + ->orWhereHas($resolvedRelationKey, function (Builder $relationQuery) use ($resolvedRelationKey, $relationColumns, $value): void { + $relationTable = $this->getRelationTable($resolvedRelationKey); + $firstConditionAdded = false; + + foreach ($relationColumns as $relationColumn) { + if (! $firstConditionAdded) { + $relationQuery->where($relationTable.'.'.$relationColumn, 'NOT LIKE', '%'.$value.'%'); + $firstConditionAdded = true; + + continue; + } + + // For negation we AND the NOT LIKE conditions so all columns must not match. + $relationQuery->where($relationTable.'.'.$relationColumn, 'NOT LIKE', '%'.$value.'%'); + } + + if (($resolvedRelationKey === 'adminuser') || ($resolvedRelationKey === 'user')) { + $concatSql = $this->buildMultipleColumnSearch([ 'users.first_name', 'users.last_name', 'users.display_name', - ] - ), - ["%{$filterValue}%"] - ); - } + ]); + + $relationQuery->whereRaw(str_replace('LIKE', 'NOT LIKE', $concatSql), ["%{$value}%"]); + } + }); }); + + return $query; } + $relationMethod = $boolean === 'or' ? 'orWhereHas' : 'whereHas'; + + $query->{$relationMethod}($resolvedRelationKey, function (Builder $relationQuery) use ($resolvedRelationKey, $relationColumns, $value, $likeOperator, $operator) { + $relationTable = $this->getRelationTable($resolvedRelationKey); + $firstConditionAdded = false; + + foreach ($relationColumns as $relationColumn) { + if (! $firstConditionAdded) { + if ($operator === 'exact') { + $relationQuery->where($relationTable.'.'.$relationColumn, '=', $value); + } else { + $relationQuery->where($relationTable.'.'.$relationColumn, $likeOperator, '%'.$value.'%'); + } + $firstConditionAdded = true; + + continue; + } + + if ($operator === 'exact') { + // For exact matches across multiple columns, OR them — any column matching + // the exact value is sufficient (e.g. name OR slug). + $relationQuery->orWhere($relationTable.'.'.$relationColumn, '=', $value); + } elseif ($likeOperator === 'NOT LIKE') { + // For negation we AND the NOT LIKE conditions so all columns must not match. + $relationQuery->where($relationTable.'.'.$relationColumn, $likeOperator, '%'.$value.'%'); + } else { + // For normal LIKE we OR them so any column matching is sufficient. + $relationQuery->orWhere($relationTable.'.'.$relationColumn, $likeOperator, '%'.$value.'%'); + } + } + + if (($resolvedRelationKey === 'adminuser') || ($resolvedRelationKey === 'user')) { + $concatSql = $this->buildMultipleColumnSearch([ + 'users.first_name', + 'users.last_name', + 'users.display_name', + ]); + + if ($operator === 'exact') { + $concatSql = str_replace(' LIKE ?', ' = ?', $concatSql); + $relationQuery->orWhereRaw($concatSql, [$value]); + } elseif ($likeOperator === 'NOT LIKE') { + $relationQuery->whereRaw(str_replace('LIKE', 'NOT LIKE', $concatSql), ["%{$value}%"]); + } else { + $relationQuery->orWhereRaw($concatSql, ["%{$value}%"]); + } + } + }); + return $query; } @@ -302,8 +516,13 @@ trait Searchable /** * Apply filters for assignees with type-specific searchable columns. + * + * When $negate is true, NOT LIKE is used inside whereHasMorph, so results + * are records that have an assignee whose columns do NOT contain $filterValue. + * (Records with no assignee are excluded; they do not satisfy "has an assignee + * where column NOT LIKE '%value%'".) */ - private function applyAssignedToRelationFilter(Builder $query, string $relationKey, string $filterValue): Builder + private function applyAssignedToRelationFilter(Builder $query, string $relationKey, string $filterValue, string $boolean = 'and', bool $negate = false): Builder { $relationName = $this->resolveAssignedToRelationName(); @@ -311,10 +530,13 @@ trait Searchable return $query; } - return $query->whereHasMorph( + $likeOperator = $negate ? 'NOT LIKE' : 'LIKE'; + $relationMethod = $boolean === 'or' ? 'orWhereHasMorph' : 'whereHasMorph'; + + return $query->{$relationMethod}( $relationName, [User::class, Asset::class, Location::class], - function (Builder $assigneeQuery, string $assigneeType) use ($filterValue) { + function (Builder $assigneeQuery, string $assigneeType) use ($filterValue, $likeOperator, $negate) { $columns = $this->getAssigneeColumnsByType($assigneeType); if (empty($columns)) { @@ -326,20 +548,25 @@ trait Searchable foreach ($columns as $column) { if (! $firstConditionAdded) { - $assigneeQuery->where($table.'.'.$column, 'LIKE', '%'.$filterValue.'%'); + $assigneeQuery->where($table.'.'.$column, $likeOperator, '%'.$filterValue.'%'); $firstConditionAdded = true; continue; } - $assigneeQuery->orWhere($table.'.'.$column, 'LIKE', '%'.$filterValue.'%'); + // For negation, AND the conditions (all columns must not match). + // For normal LIKE, OR them (any column matching is sufficient). + $negate + ? $assigneeQuery->where($table.'.'.$column, $likeOperator, '%'.$filterValue.'%') + : $assigneeQuery->orWhere($table.'.'.$column, $likeOperator, '%'.$filterValue.'%'); } if ($assigneeType === User::class) { - $assigneeQuery->orWhereRaw( - $this->buildMultipleColumnSearch(['users.first_name', 'users.last_name']), - ["%{$filterValue}%"] - ); + $concatSql = $this->buildMultipleColumnSearch(['users.first_name', 'users.last_name']); + + $negate + ? $assigneeQuery->whereRaw(str_replace('LIKE', 'NOT LIKE', $concatSql), ["%{$filterValue}%"]) + : $assigneeQuery->orWhereRaw($concatSql, ["%{$filterValue}%"]); } } ); @@ -384,13 +611,98 @@ trait Searchable /** * Apply filtering on computed count aliases (for example withCount aliases). */ - private function applyCountAliasFilter(Builder $query, string $countAlias, string $filterValue): Builder + private function applyCountAliasFilter(Builder $query, string $countAlias, string $filterValue, string $boolean = 'and', bool $negate = false): Builder { + $havingMethod = $boolean === 'or' ? 'orHaving' : 'having'; + if (is_numeric($filterValue)) { - return $query->having($countAlias, '=', (int) $filterValue); + $operator = $negate ? '!=' : '='; + + return $query->{$havingMethod}($countAlias, $operator, (int) $filterValue); } - return $query->having($countAlias, 'LIKE', '%'.$filterValue.'%'); + $likeOperator = $negate ? 'NOT LIKE' : 'LIKE'; + + return $query->{$havingMethod}($countAlias, $likeOperator, '%'.$filterValue.'%'); + } + + /** + * Apply an IS NULL / IS NOT NULL filter for the given filter key. + * + * Supported targets: + * + * Direct attributes → WHERE col IS [NOT] NULL + * + * Virtual columns → IS NULL: all constituent columns must be null + * IS NOT NULL: at least one constituent column must not be null + * + * Relation keys → IS NULL: doesntHave (no related record) + * IS NOT NULL: whereHas (has a related record) + * + * Any unrecognised key is silently ignored. + */ + private function applyNullFilter(Builder $query, string $filterKey, bool $isNull, string $boolean = 'and'): Builder + { + $table = $this->getTable(); + $searchableAttributes = $this->getSearchableAttributes(); + + // Direct attribute column. + if (in_array($filterKey, $searchableAttributes, true)) { + $method = match (true) { + $isNull && $boolean === 'or' => 'orWhereNull', + $isNull => 'whereNull', + $boolean === 'or' => 'orWhereNotNull', + default => 'whereNotNull', + }; + + $query->{$method}($table.'.'.$filterKey); + + return $query; + } + + // Virtual columns (e.g. 'name' → ['first_name', 'last_name'] on User). + $virtualColumns = $this->getSearchableVirtualColumns(); + + if (array_key_exists($filterKey, $virtualColumns)) { + $qualifiedColumns = array_map( + fn ($col) => $table.'.'.$col, + $virtualColumns[$filterKey] + ); + + if ($isNull) { + // All constituent columns must be null (= no name at all). + foreach ($qualifiedColumns as $col) { + $query->whereNull($col); + } + } else { + // At least one constituent column must have a value. + $query->where(function (Builder $sub) use ($qualifiedColumns): void { + foreach ($qualifiedColumns as $col) { + $sub->orWhereNotNull($col); + } + }); + } + + return $query; + } + + // Relation key: no related record = "null", has a related record = "not null". + $searchableRelations = $this->getSearchableRelations(); + $resolvedRelationKey = $this->resolveSearchableRelationKey($filterKey, $searchableRelations); + + if ($resolvedRelationKey !== null && ! $this->isAssignedToRelationKey($resolvedRelationKey)) { + if ($isNull) { + $method = $boolean === 'or' ? 'orDoesntHave' : 'doesntHave'; + $query->{$method}($resolvedRelationKey); + } else { + $method = $boolean === 'or' ? 'orWhereHas' : 'whereHas'; + $query->{$method}($resolvedRelationKey); + } + + return $query; + } + + return $query; } /** @@ -653,6 +965,20 @@ trait Searchable return $this->searchableCounts ?? []; } + /** + * Get virtual column aliases defined on the model. + * + * These are filter keys that map to a set of real columns searched via + * CONCAT — for example, "name" → ['first_name', 'last_name'] on User, + * because "name" is not a real database column on that table. + * + * @return array> + */ + private function getSearchableVirtualColumns(): array + { + return $this->searchableVirtualColumns ?? []; + } + /** * Get the relation aliases defined on the model. * @@ -672,6 +998,36 @@ trait Searchable return $this->searchableRelationAliases ?? []; } + /** + * Get structured-filter relation columns for a given filter key. + * + * By default, this uses all configured searchable relation columns for the + * resolved relation key. Models can narrow specific advanced-search fields + * via $searchableRelationFilterColumns, keyed by the incoming filter key + * shown in the UI/API (for example: 'location' => ['name']). + * + * @param array> $searchableRelations + * @return array + */ + private function getStructuredFilterRelationColumns(string $filterKey, string $resolvedRelationKey, array $searchableRelations): array + { + $defaultColumns = (array) ($searchableRelations[$resolvedRelationKey] ?? []); + + $overrides = $this->searchableRelationFilterColumns ?? []; + + if (! array_key_exists($filterKey, $overrides)) { + return $defaultColumns; + } + + $overrideColumns = array_values(array_filter((array) $overrides[$filterKey], 'is_string')); + + // Keep only columns that are actually searchable on the resolved relation, + // so model-level overrides cannot accidentally reference unknown columns. + $validColumns = array_values(array_intersect($overrideColumns, $defaultColumns)); + + return $validColumns !== [] ? $validColumns : $defaultColumns; + } + /** * Get the table name of a relation. * @@ -729,6 +1085,9 @@ trait Searchable */ private function buildMultipleColumnSearch(array $columns): string { + // This method deliberately returns only an SQL fragment ending with "LIKE ?" + // so callers can reuse it and swap operators (NOT LIKE / =) without duplicating + // driver-specific CONCAT syntax. $mappedColumns = collect($columns)->map(fn ($column) => DB::getTablePrefix().$column)->toArray(); $driver = config('database.connections.'.config('database.default').'.driver'); diff --git a/app/Models/User.php b/app/Models/User.php index fe1062da3b..b4fff56ae8 100644 --- a/app/Models/User.php +++ b/app/Models/User.php @@ -181,6 +181,44 @@ class User extends SnipeModel implements AuthenticatableContract, AuthorizableCo 'manages_locations_count', ]; + /** + * Virtual column aliases that map a single filter key to a set of real columns + * searched via CONCAT (SQL) so that, for example, filtering by "name" searches + * across both first_name and last_name together. + * + * Because "name" is not a real column on the users table we cannot add it to + * $searchableAttributes; this map bridges that gap for structured filter queries. + * + * @var array> + */ + protected $searchableVirtualColumns = [ + 'name' => ['first_name', 'last_name'], + ]; + + /** + * Maps filter/API keys to the actual Eloquent relation names used in + * $searchableRelations. The User model uses "userloc" as its location + * relation name (to avoid a collision with the framework's own "location" + * magic), but every consumer — UI and API alike — sends the key "location". + * + * @var array + */ + protected $searchableRelationAliases = [ + 'location' => 'userloc', + ]; + + /** + * Narrow structured-filter relation columns for specific UI/API filter keys. + * + * The advanced-search "location" field represents the location name, so + * structured filters should target only userloc.name (not address/city/etc). + * + * @var array> + */ + protected $searchableRelationFilterColumns = [ + 'location' => ['name'], + ]; + /** * This sets the name property on the user. It's not a real field in the database * (since we use first_name and last_name), but the Laravel mailable method diff --git a/app/Notifications/AcceptanceItemAcceptedNotification.php b/app/Notifications/AcceptanceItemAcceptedNotification.php index b169f5dbe6..5ef940918f 100644 --- a/app/Notifications/AcceptanceItemAcceptedNotification.php +++ b/app/Notifications/AcceptanceItemAcceptedNotification.php @@ -33,6 +33,7 @@ class AcceptanceItemAcceptedNotification extends Notification $this->file = $params['file'] ?? null; $this->qty = $params['qty'] ?? null; $this->note = $params['note'] ?? null; + $this->custom_fields = $params['custom_fields'] ?? []; } @@ -76,6 +77,7 @@ class AcceptanceItemAcceptedNotification extends Notification 'assigned_to' => $this->assigned_to, 'company_name' => $this->company_name, 'qty' => $this->qty, + 'custom_fields' => $this->custom_fields, 'intro_text' => trans('mail.acceptance_accepted_greeting', ['user' => $this->assigned_to, 'item' => $this->item_name]), ]) ->subject('✅ '.trans('mail.acceptance_accepted', ['user' => $this->assigned_to, 'item' => $this->item_name])) diff --git a/app/Notifications/AcceptanceItemAcceptedToUserNotification.php b/app/Notifications/AcceptanceItemAcceptedToUserNotification.php index 29ddf099dc..865539b303 100644 --- a/app/Notifications/AcceptanceItemAcceptedToUserNotification.php +++ b/app/Notifications/AcceptanceItemAcceptedToUserNotification.php @@ -34,6 +34,7 @@ class AcceptanceItemAcceptedToUserNotification extends Notification $this->settings = Setting::getSettings(); $this->file = $params['file'] ?? null; $this->qty = $params['qty'] ?? null; + $this->custom_fields = $params['custom_fields'] ?? []; } /** @@ -72,6 +73,7 @@ class AcceptanceItemAcceptedToUserNotification extends Notification 'assigned_to' => $this->assigned_to, 'company_name' => $this->company_name, 'qty' => $this->qty, + 'custom_fields' => $this->custom_fields, 'intro_text' => trans_choice('mail.acceptance_asset_accepted_to_user', $this->qty, ['qty' => $this->qty, 'site_name' => $this->settings->site_name]), ]) ->attach($pdf_path) diff --git a/app/Observers/MaintenanceObserver.php b/app/Observers/MaintenanceObserver.php index 47462d10cd..392cb6bcd7 100644 --- a/app/Observers/MaintenanceObserver.php +++ b/app/Observers/MaintenanceObserver.php @@ -13,8 +13,25 @@ class MaintenanceObserver * * @return void */ - public function updated(Maintenance $maintenance) + public function updating(Maintenance $maintenance) { + $changed = []; + + foreach ($maintenance->getRawOriginal() as $key => $value) { + if (array_key_exists($key, $maintenance->getAttributes()) + && $maintenance->getRawOriginal()[$key] != $maintenance->getAttributes()[$key] + ) { + $changed[$key] = [ + 'old' => $maintenance->getRawOriginal()[$key], + 'new' => $maintenance->getAttributes()[$key], + ]; + } + } + + if (empty($changed)) { + return; + } + $logAction = new Actionlog; $logAction->item_type = Maintenance::class; $logAction->item_id = $maintenance->id; @@ -23,6 +40,7 @@ class MaintenanceObserver $logAction->created_at = date('Y-m-d H:i:s'); $logAction->action_date = date('Y-m-d H:i:s'); $logAction->created_by = auth()->id(); + $logAction->log_meta = json_encode($changed); if ($maintenance->imported) { $logAction->setActionSource('importer'); } diff --git a/app/Policies/MaintenancePolicy.php b/app/Policies/MaintenancePolicy.php new file mode 100644 index 0000000000..152045d2b9 --- /dev/null +++ b/app/Policies/MaintenancePolicy.php @@ -0,0 +1,97 @@ +hasAccess('assets.view'); + } + + /** + * Determine whether the user can view a specific maintenance record. + * Allowed if the user can edit the associated asset. + */ + public function view(User $user, Maintenance $maintenance): bool + { + return Gate::allows('update', $maintenance->asset); + } + + /** + * Determine whether the user can create a maintenance record. + * When checking against the class (no instance), fall back to assets.edit. + * When an asset instance is provided via context, check update on that asset. + */ + public function create(User $user, ?Asset $asset = null): bool + { + if ($asset instanceof Asset) { + return Gate::allows('update', $asset); + } + + return $user->hasAccess('assets.edit'); + } + + /** + * Determine whether the user can update a maintenance record. + * Allowed if the user can edit the associated asset. + */ + public function update(User $user, Maintenance $maintenance): bool + { + return Gate::allows('update', $maintenance->asset); + } + + /** + * Determine whether the user can delete a maintenance record. + * Allowed if the user can edit the associated asset and the record is not soft-deleted. + */ + public function delete(User $user, Maintenance $maintenance): bool + { + return empty($maintenance->deleted_at) + && Gate::allows('update', $maintenance->asset); + } + + /** + * Determine whether the user can upload or manage files attached to a maintenance record. + * Allowed if the user can edit the associated asset. + */ + public function files(User $user, Maintenance $maintenance): bool + { + return Gate::allows('update', $maintenance->asset); + } + + /** + * Determine whether the user can view history for a maintenance record. + * Allowed when the user can view the maintenance itself, or has global activity view permission. + */ + public function history(User $user, Maintenance $maintenance): bool + { + return Gate::allows('view', $maintenance->asset) + || Gate::allows('view', $maintenance) + || $user->hasAccess('activity.view'); + } +} diff --git a/app/Presenters/CustomFieldPresenter.php b/app/Presenters/CustomFieldPresenter.php new file mode 100644 index 0000000000..131cd97fac --- /dev/null +++ b/app/Presenters/CustomFieldPresenter.php @@ -0,0 +1,58 @@ +display_checkout) { + $label = e(trans('admin/custom_fields/general.display_checkout')); + $icons[] = ''.$label.''; + } + + if ($field->display_checkin) { + $label = e(trans('admin/custom_fields/general.display_checkin')); + $icons[] = ''.$label.''; + } + + if ($field->display_audit) { + $label = e(trans('admin/custom_fields/general.display_audit')); + $icons[] = ''.$label.''; + } + + if ($field->display_in_user_view) { + $label = e(trans('admin/custom_fields/general.display_in_user_view_table')); + $icons[] = ''.$label.''; + } + + if ($field->show_in_listview) { + $label = e(trans('admin/custom_fields/general.show_in_listview_short')); + $icons[] = ''.$label.''; + } + + if ($field->show_in_email) { + $label = e(trans('admin/custom_fields/general.show_in_email_short')); + $icons[] = ''.$label.''; + } + + if ($field->show_in_requestable_list) { + $label = e(trans('admin/custom_fields/general.show_in_requestable_list_short')); + $icons[] = ''.$label.''; + } + + return $icons; + } + + public static function visibilityIcons(CustomField $field): string + { + return implode(' ', self::visibilityIconsArray($field)); + } +} diff --git a/app/Providers/AuthServiceProvider.php b/app/Providers/AuthServiceProvider.php index 1ece49a070..498ac1f03b 100644 --- a/app/Providers/AuthServiceProvider.php +++ b/app/Providers/AuthServiceProvider.php @@ -15,6 +15,7 @@ use App\Models\Department; use App\Models\Depreciation; use App\Models\License; use App\Models\Location; +use App\Models\Maintenance; use App\Models\Manufacturer; use App\Models\PredefinedKit; use App\Models\Statuslabel; @@ -33,6 +34,7 @@ use App\Policies\DepartmentPolicy; use App\Policies\DepreciationPolicy; use App\Policies\LicensePolicy; use App\Policies\LocationPolicy; +use App\Policies\MaintenancePolicy; use App\Policies\ManufacturerPolicy; use App\Policies\PredefinedKitPolicy; use App\Policies\StatuslabelPolicy; @@ -68,6 +70,7 @@ class AuthServiceProvider extends ServiceProvider Depreciation::class => DepreciationPolicy::class, License::class => LicensePolicy::class, Location::class => LocationPolicy::class, + Maintenance::class => MaintenancePolicy::class, PredefinedKit::class => PredefinedKitPolicy::class, Statuslabel::class => StatuslabelPolicy::class, Supplier::class => SupplierPolicy::class, diff --git a/composer.json b/composer.json index 7626daf56b..d363d45198 100644 --- a/composer.json +++ b/composer.json @@ -25,7 +25,7 @@ "ext-mbstring": "*", "ext-pdo": "*", "alek13/slack": "^2.0", - "arietimmerman/laravel-scim-server": "dev-bump_to_laravel_12", + "arietimmerman/laravel-scim-server": "dev-upstream_master", "bacon/bacon-qr-code": "^2.0", "doctrine/cache": "^1.10", "doctrine/dbal": "^3.1", diff --git a/composer.lock b/composer.lock index c120fa2e6d..edc5657c78 100644 --- a/composer.lock +++ b/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "67212462613747ee572922f09d81f562", + "content-hash": "6dbe361f8555681a027fca2d2c2e61fd", "packages": [ { "name": "alek13/slack", @@ -74,29 +74,30 @@ }, { "name": "arietimmerman/laravel-scim-server", - "version": "dev-bump_to_laravel_12", + "version": "dev-upstream_master", "source": { "type": "git", "url": "https://github.com/grokability/laravel-scim-server.git", - "reference": "ad5b8a706eeade1c042118c2ec3a4b3a749991e9" + "reference": "da40db79d76cf3b4c7e57cde41df6ecf8119afb7" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/grokability/laravel-scim-server/zipball/ad5b8a706eeade1c042118c2ec3a4b3a749991e9", - "reference": "ad5b8a706eeade1c042118c2ec3a4b3a749991e9", + "url": "https://api.github.com/repos/grokability/laravel-scim-server/zipball/da40db79d76cf3b4c7e57cde41df6ecf8119afb7", + "reference": "da40db79d76cf3b4c7e57cde41df6ecf8119afb7", "shasum": "" }, "require": { - "illuminate/console": "^6.0|^7.0|^8.0|^9.0|^10.0|^11.0|^12.0", - "illuminate/database": "^6.0|^7.0|^8.0|^9.0|^10.0|^11.0|^12.0", - "illuminate/support": "^6.0|^7.0|^8.0|^9.0|^10.0|^11.0|^12.0", - "php": "^7.0|^8.0", + "illuminate/console": "^10.0|^11.0|^12.0", + "illuminate/database": "^10.0|^11.0|^12.0", + "illuminate/support": "^10.0|^11.0|^12.0", + "php": "^8.0", "tmilos/scim-filter-parser": "^1.3", "tmilos/scim-schema": "^0.1.0" }, "require-dev": { + "friendsofphp/php-cs-fixer": "^3.66", "laravel/legacy-factories": "*", - "orchestra/testbench": "^4.0|^5.0|^6.0|^7.0|^8.0" + "orchestra/testbench": "^6.0|^7.0|^8.0|^9.0|^10.0" }, "type": "library", "extra": { @@ -130,9 +131,9 @@ ], "description": "Laravel Package for creating a SCIM server", "support": { - "source": "https://github.com/grokability/laravel-scim-server/tree/bump_to_laravel_12" + "source": "https://github.com/grokability/laravel-scim-server/tree/upstream_master" }, - "time": "2026-03-17T14:09:21+00:00" + "time": "2025-08-28T19:24:40+00:00" }, { "name": "aws/aws-crt-php", @@ -1746,12 +1747,12 @@ "version": "v6.11.1", "source": { "type": "git", - "url": "https://github.com/firebase/php-jwt.git", + "url": "https://github.com/googleapis/php-jwt.git", "reference": "d1e91ecf8c598d073d0995afa8cd5c75c6e19e66" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/firebase/php-jwt/zipball/d1e91ecf8c598d073d0995afa8cd5c75c6e19e66", + "url": "https://api.github.com/repos/googleapis/php-jwt/zipball/d1e91ecf8c598d073d0995afa8cd5c75c6e19e66", "reference": "d1e91ecf8c598d073d0995afa8cd5c75c6e19e66", "shasum": "" }, diff --git a/config/version.php b/config/version.php index 1a0a927bad..0e7bdf4645 100644 --- a/config/version.php +++ b/config/version.php @@ -1,11 +1,11 @@ 'v8.4.1', - 'full_app_version' => 'v8.4.1 - build 22183-g5898205480', - 'build_version' => '22183', + 'app_version' => 'v8.5.0-pre', + 'full_app_version' => 'v8.5.0-pre - build 22392-g5014b1c459', + 'build_version' => '22392', 'prerelease_version' => '', - 'hash_version' => 'g5898205480', - 'full_hash' => 'v8.4.1-901-g5898205480', + 'hash_version' => 'g5014b1c459', + 'full_hash' => 'v8.5.0-pre-207-g5014b1c459', 'branch' => 'develop', ]; diff --git a/database/migrations/2025_09_25_124321_add_external_id_to_groups.php b/database/migrations/2025_09_25_124321_add_external_id_to_groups.php new file mode 100644 index 0000000000..c6e58c0cd1 --- /dev/null +++ b/database/migrations/2025_09_25_124321_add_external_id_to_groups.php @@ -0,0 +1,28 @@ +string('scim_externalid')->nullable()->default(null); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::table('permission_groups', function (Blueprint $table) { + $table->dropColumn('scim_externalid'); + }); + } +}; diff --git a/database/migrations/2026_04_20_200000_backfill_action_logs_company_id_from_item.php b/database/migrations/2026_04_20_200000_backfill_action_logs_company_id_from_item.php new file mode 100644 index 0000000000..9a08ea0f8b --- /dev/null +++ b/database/migrations/2026_04_20_200000_backfill_action_logs_company_id_from_item.php @@ -0,0 +1,74 @@ +updateAssetAuditLogs(DB::getDriverName()); + } + + public function down(): void + { + // This backfill is intentionally non-reversible — we cannot know which + // rows were NULL before the migration ran vs which were backfilled. + } + + /** + * Stamp company_id for legacy audit rows tied to assets. + */ + private function updateAssetAuditLogs(string $driver): void + { + if ($driver === 'mysql' || $driver === 'mariadb') { + // MySQL/MariaDB supports UPDATE ... JOIN directly + DB::statement(' + UPDATE action_logs al + INNER JOIN assets src + ON src.id = al.item_id + AND src.company_id IS NOT NULL + SET al.company_id = src.company_id + WHERE al.action_type = ? + AND al.item_type = ? + AND al.company_id IS NULL + AND al.deleted_at IS NULL + ', [self::AUDIT_ACTION, self::ASSET_CLASS]); + } else { + // SQLite / PostgreSQL: use a correlated subquery update + DB::statement(' + UPDATE action_logs + SET company_id = ( + SELECT src.company_id + FROM assets src + WHERE src.id = action_logs.item_id + AND src.company_id IS NOT NULL + LIMIT 1 + ) + WHERE action_type = ? + AND item_type = ? + AND company_id IS NULL + AND deleted_at IS NULL + AND EXISTS ( + SELECT 1 FROM assets src2 + WHERE src2.id = action_logs.item_id + AND src2.company_id IS NOT NULL + ) + ', [self::AUDIT_ACTION, self::ASSET_CLASS]); + } + } +}; diff --git a/database/migrations/2026_05_05_125206_add_unique_index_to_nonces.php b/database/migrations/2026_05_05_125206_add_unique_index_to_nonces.php new file mode 100644 index 0000000000..76c8335b5a --- /dev/null +++ b/database/migrations/2026_05_05_125206_add_unique_index_to_nonces.php @@ -0,0 +1,33 @@ +dropIndex(['nonce']); + $table->unique('nonce'); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::table('saml_nonces', function (Blueprint $table) { + $table->dropUnique(['nonce']); + $table->index('nonce'); + }); + } +}; diff --git a/resources/lang/en-US/general.php b/resources/lang/en-US/general.php index 897e7078f5..f6af842182 100644 --- a/resources/lang/en-US/general.php +++ b/resources/lang/en-US/general.php @@ -606,7 +606,10 @@ return [ 'action_permission_denied' => 'You do not have permission to :action :item_type ID :id', 'action_permission_generic' => 'You do not have permission to :action this :item_type', 'edit' => 'edit', + 'search_operator' => 'Search operator', + 'and' => 'and', 'action_source' => 'Action Source', + 'search_tip' => 'Searches return a partial match by default. For more specific results, you can use not:value to exclude, is:value for an exact match, is:null for empty values, and is:not_null for non-empty. (In these examples, value is the text you are searching for.)', 'or' => 'or', 'url' => 'URL', 'phone' => 'Phone', diff --git a/resources/views/account/view-assets.blade.php b/resources/views/account/view-assets.blade.php index 6a2f26ce5f..3a9bcc3c73 100755 --- a/resources/views/account/view-assets.blade.php +++ b/resources/views/account/view-assets.blade.php @@ -479,6 +479,9 @@ {{ trans('admin/hardware/table.serial') }} + + {{ trans('general.manufacturer') }} + {{ trans('admin/hardware/form.default_location') }} @@ -552,6 +555,11 @@ {{ $asset->serial }} + + @if (($asset->model) && ($asset->model->manufacturer)) + {!! $asset->model->manufacturer->present()->formattedNameLink !!} + @endif + {!! ($asset->defaultLoc) ? $asset->defaultLoc->present()->formattedNameLink : '' !!} diff --git a/resources/views/auth/login.blade.php b/resources/views/auth/login.blade.php index 52b286537b..2407506f3e 100755 --- a/resources/views/auth/login.blade.php +++ b/resources/views/auth/login.blade.php @@ -57,21 +57,32 @@
-
+ +
-
+

diff --git a/resources/views/hardware/index.blade.php b/resources/views/hardware/index.blade.php index c864cf92bc..b6c5a25eac 100755 --- a/resources/views/hardware/index.blade.php +++ b/resources/views/hardware/index.blade.php @@ -9,7 +9,7 @@ $requestStatusTypeId = request()->input('status_id'); @endphp - @if (($requestCompanyId) && ($company)) + @if (is_scalar($requestCompanyId) && ($company instanceof \App\Models\Company)) {{ $company->name }} @endif @@ -40,7 +40,7 @@ @endif {{ trans('general.assets') }} - @if (Request::has('order_number')) + @if (Request::has('order_number') && is_scalar($requestOrderNumber)) : Order #{{ strval($requestOrderNumber) }} @endif @stop @@ -56,10 +56,12 @@ + array( + 'status_type' => is_scalar($requestStatusType) ? $requestStatusType : null, + 'order_number' => is_scalar($requestOrderNumber) ? strval($requestOrderNumber) : null, + 'company_id' => is_scalar($requestCompanyId) ? $requestCompanyId : null, + 'status_id' => is_scalar($requestStatusTypeId) ? $requestStatusTypeId : null, + ))"/> @stop diff --git a/resources/views/hardware/view.blade.php b/resources/views/hardware/view.blade.php index 4f95e22a4c..02335bdafb 100755 --- a/resources/views/hardware/view.blade.php +++ b/resources/views/hardware/view.blade.php @@ -240,7 +240,7 @@ @endif - @if($asset->purchase_date || $asset->asset_eol_date || $asset->depreciated_date() || $asset->warranty_expires) + @if(($asset->purchase_date && $asset->asset_eol_date) || $asset->depreciated_date() || $asset->warranty_expires) @if($asset->purchase_date && $asset->asset_eol_date) diff --git a/resources/views/layouts/basic.blade.php b/resources/views/layouts/basic.blade.php index f68f787cb9..244af2d488 100644 --- a/resources/views/layouts/basic.blade.php +++ b/resources/views/layouts/basic.blade.php @@ -64,7 +64,18 @@ {{-- Javascript files --}} - + @stack('js') diff --git a/resources/views/mail/markdown/checkin-asset.blade.php b/resources/views/mail/markdown/checkin-asset.blade.php index 9121e1df3a..2f52336739 100644 --- a/resources/views/mail/markdown/checkin-asset.blade.php +++ b/resources/views/mail/markdown/checkin-asset.blade.php @@ -37,11 +37,13 @@ @if (isset($status)) | **{{ trans('general.status') }}** | {{ $status }} | @endif -@foreach($fields as $field) -@if (($item->{ $field->db_column_name() }!='') && ($field->show_in_email) && ($field->field_encrypted=='0')) -| **{{ $field->name }}** | {{ $item->{ $field->db_column_name() } }} | +@if (!empty($custom_fields)) +@foreach($custom_fields as $customField) +@if (!empty($customField['label']) && array_key_exists('value', $customField) && $customField['value'] !== '') +| **{{ $customField['label'] }}** | {{ $customField['value'] }} | @endif @endforeach +@endif @if ($admin) | **{{ trans('general.administrator') }}** | {{ $admin->display_name }} | @endif diff --git a/resources/views/mail/markdown/checkout-asset.blade.php b/resources/views/mail/markdown/checkout-asset.blade.php index d384370840..81a7ab14fb 100644 --- a/resources/views/mail/markdown/checkout-asset.blade.php +++ b/resources/views/mail/markdown/checkout-asset.blade.php @@ -40,11 +40,13 @@ @if ((isset($expected_checkin)) && ($expected_checkin!='')) | **{{ trans('mail.expecting_checkin_date') }}** | {{ $expected_checkin }} | @endif -@foreach($fields as $field) -@if (($item->{ $field->db_column_name() }!='') && ($field->show_in_email) && ($field->field_encrypted=='0')) -| **{{ $field->name }}** | {{ $item->{ $field->db_column_name() } }} | +@if (!empty($custom_fields)) +@foreach($custom_fields as $customField) +@if (!empty($customField['label']) && array_key_exists('value', $customField) && $customField['value'] !== '') +| **{{ $customField['label'] }}** | {{ $customField['value'] }} | @endif @endforeach +@endif @if ($admin) | **{{ trans('general.administrator') }}** | {{ $admin->display_name }} | @endif diff --git a/resources/views/maintenances/edit.blade.php b/resources/views/maintenances/edit.blade.php index 798ec39569..f48a1ca4af 100644 --- a/resources/views/maintenances/edit.blade.php +++ b/resources/views/maintenances/edit.blade.php @@ -182,6 +182,7 @@ @include ('partials.forms.edit.image-upload', ['image_path' => app('maintenances_path')]) + @include ('partials.forms.edit.file-upload', ['input_id' => 'maintenanceFileUpload']) diff --git a/resources/views/maintenances/view.blade.php b/resources/views/maintenances/view.blade.php index f8bbfe0510..10f686d997 100644 --- a/resources/views/maintenances/view.blade.php +++ b/resources/views/maintenances/view.blade.php @@ -22,6 +22,7 @@ use Carbon\Carbon; + @@ -162,6 +163,10 @@ use Carbon\Carbon; + + + + @@ -185,7 +190,7 @@ use Carbon\Carbon; @section('moar_scripts') - @can('files', $maintenance->asset) + @can('files', $maintenance) @include ('modals.upload-file', ['item_type' => 'maintenances', 'item_id' => $maintenance->id]) @endcan diff --git a/resources/views/models/custom_fields_form.blade.php b/resources/views/models/custom_fields_form.blade.php index 7436259b47..861a26c6d3 100644 --- a/resources/views/models/custom_fields_form.blade.php +++ b/resources/views/models/custom_fields_form.blade.php @@ -84,8 +84,19 @@ @endif - @if ($field->help_text!='') -

{{ $field->help_text }}

+ @if (count(\App\Presenters\CustomFieldPresenter::visibilityIconsArray($field)) > 0) + @if ($field->help_text != '') +

+ {{ $field->help_text }} +
{!! \App\Presenters\CustomFieldPresenter::visibilityIcons($field) !!} +

+ @else +
+ {!! \App\Presenters\CustomFieldPresenter::visibilityIcons($field) !!} +
+ @endif + @elseif ($field->help_text != '') +

{{ $field->help_text }}

@endif + @endpush @push('js') @@ -13,6 +14,413 @@ diff --git a/resources/views/partials/forms/edit/file-upload.blade.php b/resources/views/partials/forms/edit/file-upload.blade.php new file mode 100644 index 0000000000..22b55bdc6a --- /dev/null +++ b/resources/views/partials/forms/edit/file-upload.blade.php @@ -0,0 +1,34 @@ +
+ + +
+ + + +

{{ trans('general.upload_filetypes_help', ['allowed_filetypes' => config('filesystems.allowed_upload_extensions'), 'size' => Helper::file_upload_max_size_readable()]) }}

+ + @foreach ($errors->get('file.*') as $messages) + @foreach ($messages as $message) +
+ @endforeach + @endforeach + +
+
+ diff --git a/resources/views/users/index.blade.php b/resources/views/users/index.blade.php index 952a9ab3fa..fd0d6c6f6c 100755 --- a/resources/views/users/index.blade.php +++ b/resources/views/users/index.blade.php @@ -31,13 +31,13 @@ diff --git a/tests/Feature/Accessories/Api/IndexAccessoryTest.php b/tests/Feature/Accessories/Api/IndexAccessoryTest.php index e14fb3a362..28fd52ef3c 100644 --- a/tests/Feature/Accessories/Api/IndexAccessoryTest.php +++ b/tests/Feature/Accessories/Api/IndexAccessoryTest.php @@ -4,7 +4,11 @@ namespace Tests\Feature\Accessories\Api; use App\Models\Accessory; use App\Models\AccessoryCheckout; +use App\Models\Category; use App\Models\Company; +use App\Models\Location; +use App\Models\Manufacturer; +use App\Models\Supplier; use App\Models\User; use Illuminate\Testing\Fluent\AssertableJson; use Tests\Concerns\TestsFullMultipleCompaniesSupport; @@ -96,4 +100,60 @@ class IndexAccessoryTest extends TestCase implements TestsFullMultipleCompaniesS ]) ->assertJson(fn (AssertableJson $json) => $json->has('rows', 1)->where('rows.0.name', 'Accessory With Two Checkouts')->etc()); } + + public function test_can_filter_accessories_by_all_supported_exact_fields() + { + $user = User::factory()->superuser()->create(); + + $targetCompany = Company::factory()->create(); + $otherCompany = Company::factory()->create(); + $targetCategory = Category::factory()->forAccessories()->create(); + $otherCategory = Category::factory()->forAccessories()->create(); + $targetManufacturer = Manufacturer::factory()->create(); + $otherManufacturer = Manufacturer::factory()->create(); + $targetSupplier = Supplier::factory()->create(); + $otherSupplier = Supplier::factory()->create(); + $targetLocation = Location::factory()->create(); + $otherLocation = Location::factory()->create(); + + $targetAccessory = Accessory::factory()->create([ + 'name' => 'Target Accessory', + 'company_id' => $targetCompany->id, + 'order_number' => 'ORDER-A', + 'category_id' => $targetCategory->id, + 'manufacturer_id' => $targetManufacturer->id, + 'supplier_id' => $targetSupplier->id, + 'location_id' => $targetLocation->id, + 'notes' => 'NOTE-A', + ]); + + $otherAccessory = Accessory::factory()->create([ + 'name' => 'Other Accessory', + 'company_id' => $otherCompany->id, + 'order_number' => 'ORDER-B', + 'category_id' => $otherCategory->id, + 'manufacturer_id' => $otherManufacturer->id, + 'supplier_id' => $otherSupplier->id, + 'location_id' => $otherLocation->id, + 'notes' => 'NOTE-B', + ]); + + $filters = [ + 'company_id' => $targetCompany->id, + 'order_number' => 'ORDER-A', + 'category_id' => $targetCategory->id, + 'manufacturer_id' => $targetManufacturer->id, + 'supplier_id' => $targetSupplier->id, + 'location_id' => $targetLocation->id, + 'notes' => 'NOTE-A', + ]; + + foreach ($filters as $filterKey => $filterValue) { + $this->actingAsForApi($user) + ->getJson(route('api.accessories.index', [$filterKey => $filterValue])) + ->assertOk() + ->assertResponseContainsInRows($targetAccessory) + ->assertResponseDoesNotContainInRows($otherAccessory); + } + } } diff --git a/tests/Feature/ActionLogs/ActionlogCompanyIdBackfillTest.php b/tests/Feature/ActionLogs/ActionlogCompanyIdBackfillTest.php new file mode 100644 index 0000000000..4ed45a26d6 --- /dev/null +++ b/tests/Feature/ActionLogs/ActionlogCompanyIdBackfillTest.php @@ -0,0 +1,147 @@ +insertGetId(array_merge([ + 'action_type' => self::AUDIT_ACTION, + 'item_type' => self::ASSET_CLASS, + 'item_id' => null, + 'company_id' => null, + 'created_at' => now(), + 'updated_at' => now(), + ], $attributes)); + } + + /** + * Run the same UPDATE logic the migration uses. + */ + private function runBackfill(): void + { + $driver = DB::getDriverName(); + + if ($driver === 'mysql' || $driver === 'mariadb') { + DB::statement(' + UPDATE action_logs al + INNER JOIN assets src ON src.id = al.item_id AND src.company_id IS NOT NULL + SET al.company_id = src.company_id + WHERE al.action_type = ? + AND al.item_type = ? + AND al.company_id IS NULL + AND al.deleted_at IS NULL + ', [self::AUDIT_ACTION, self::ASSET_CLASS]); + } else { + DB::statement(' + UPDATE action_logs + SET company_id = ( + SELECT src.company_id FROM assets src + WHERE src.id = action_logs.item_id AND src.company_id IS NOT NULL + LIMIT 1 + ) + WHERE action_type = ? + AND item_type = ? + AND company_id IS NULL + AND deleted_at IS NULL + AND EXISTS ( + SELECT 1 FROM assets src2 + WHERE src2.id = action_logs.item_id AND src2.company_id IS NOT NULL + ) + ', [self::AUDIT_ACTION, self::ASSET_CLASS]); + } + } + + // ────────────────────────────────────────────────────────────────────────── + + public function test_backfill_populates_company_id_for_asset_audit(): void + { + $company = Company::factory()->create(); + $asset = Asset::factory()->create(['company_id' => $company->id]); + + $logId = $this->insertLegacyLog(['item_type' => self::ASSET_CLASS, 'item_id' => $asset->id]); + + $this->runBackfill(); + + $this->assertDatabaseHas('action_logs', [ + 'id' => $logId, + 'company_id' => $company->id, + ]); + } + + public function test_backfill_does_not_overwrite_existing_company_id(): void + { + $company = Company::factory()->create(); + $otherCompany = Company::factory()->create(); + $asset = Asset::factory()->create(['company_id' => $otherCompany->id]); + + // Row already has a company_id — the backfill must leave it alone + $logId = $this->insertLegacyLog([ + 'item_type' => self::ASSET_CLASS, + 'item_id' => $asset->id, + 'company_id' => $company->id, + ]); + + $this->runBackfill(); + + $this->assertDatabaseHas('action_logs', [ + 'id' => $logId, + 'company_id' => $company->id, // unchanged + ]); + } + + public function test_backfill_leaves_null_when_item_has_no_company(): void + { + $asset = Asset::factory()->create(['company_id' => null]); + + $logId = $this->insertLegacyLog(['item_type' => self::ASSET_CLASS, 'item_id' => $asset->id]); + + $this->runBackfill(); + + $this->assertDatabaseHas('action_logs', [ + 'id' => $logId, + 'company_id' => null, // item has no company, so log stays null + ]); + } + + public function test_backfill_ignores_non_audit_action_logs(): void + { + $company = Company::factory()->create(); + $asset = Asset::factory()->create(['company_id' => $company->id]); + + $logId = $this->insertLegacyLog([ + 'action_type' => 'checkout', + 'item_type' => self::ASSET_CLASS, + 'item_id' => $asset->id, + ]); + + $this->runBackfill(); + + $this->assertDatabaseHas('action_logs', [ + 'id' => $logId, + 'company_id' => null, + ]); + } +} diff --git a/tests/Feature/ActionLogs/ActionlogCompanyIdTest.php b/tests/Feature/ActionLogs/ActionlogCompanyIdTest.php new file mode 100644 index 0000000000..d76d1802d4 --- /dev/null +++ b/tests/Feature/ActionLogs/ActionlogCompanyIdTest.php @@ -0,0 +1,364 @@ +create(); + $asset = Asset::factory()->create(['company_id' => $company->id]); + $admin = User::factory()->superuser()->create(); + + $this->actingAsForApi($admin) + ->postJson(route('api.asset.audit', $asset), ['note' => 'audit test']) + ->assertStatusMessageIs('success'); + + $this->assertDatabaseHas('action_logs', [ + 'item_type' => Asset::class, + 'item_id' => $asset->id, + 'action_type' => 'audit', + 'company_id' => $company->id, + ]); + } + + public function test_asset_checkout_to_user_log_stores_the_assets_company_id(): void + { + $company = Company::factory()->create(); + $asset = Asset::factory()->create(['company_id' => $company->id]); + $user = User::factory()->create(); + $admin = User::factory()->superuser()->create(); + + $this->actingAsForApi($admin) + ->postJson(route('api.asset.checkout', $asset), [ + 'checkout_to_type' => 'user', + 'assigned_user' => $user->id, + 'status_id' => $asset->status_id, + ]) + ->assertStatusMessageIs('success'); + + $this->assertDatabaseHas('action_logs', [ + 'item_type' => Asset::class, + 'item_id' => $asset->id, + 'action_type' => 'checkout', + 'company_id' => $company->id, + ]); + } + + public function test_asset_checkout_to_location_log_stores_the_assets_company_id(): void + { + $company = Company::factory()->create(); + $asset = Asset::factory()->create(['company_id' => $company->id]); + $location = Location::factory()->create(); + $admin = User::factory()->superuser()->create(); + + $this->actingAsForApi($admin) + ->postJson(route('api.asset.checkout', $asset), [ + 'checkout_to_type' => 'location', + 'assigned_location' => $location->id, + 'status_id' => $asset->status_id, + ]) + ->assertStatusMessageIs('success'); + + $this->assertDatabaseHas('action_logs', [ + 'item_type' => Asset::class, + 'item_id' => $asset->id, + 'action_type' => 'checkout', + 'company_id' => $company->id, + ]); + } + + public function test_asset_checkin_log_stores_the_assets_company_id(): void + { + $company = Company::factory()->create(); + $asset = Asset::factory()->assignedToUser()->create(['company_id' => $company->id]); + $admin = User::factory()->superuser()->create(); + + $this->actingAsForApi($admin) + ->postJson(route('api.asset.checkin', $asset)) + ->assertStatusMessageIs('success'); + + $this->assertDatabaseHas('action_logs', [ + 'item_type' => Asset::class, + 'item_id' => $asset->id, + 'action_type' => 'checkin from', + 'company_id' => $company->id, + ]); + } + + public function test_asset_create_log_stores_the_assets_company_id(): void + { + $company = Company::factory()->create(); + $admin = User::factory()->superuser()->create(); + $model = AssetModel::factory()->create(); + $status = Statuslabel::factory()->readyToDeploy()->create(); + $tag = 'COMPANY-ID-TEST-'.uniqid(); + + $this->actingAsForApi($admin) + ->postJson(route('api.assets.store'), [ + 'asset_tag' => $tag, + 'model_id' => $model->id, + 'status_id' => $status->id, + 'company_id' => $company->id, + ]) + ->assertStatusMessageIs('success'); + + $asset = Asset::where('asset_tag', $tag)->firstOrFail(); + + $this->assertDatabaseHas('action_logs', [ + 'item_type' => Asset::class, + 'item_id' => $asset->id, + 'action_type' => 'create', + 'company_id' => $company->id, + ]); + } + + // ------------------------------------------------------------------------- + // Accessory events + // ------------------------------------------------------------------------- + + public function test_accessory_checkout_log_stores_the_accessorys_company_id(): void + { + $company = Company::factory()->create(); + $accessory = Accessory::factory()->create(['company_id' => $company->id]); + $user = User::factory()->create(); + $admin = User::factory()->superuser()->create(); + + $this->actingAsForApi($admin) + ->postJson(route('api.accessories.checkout', $accessory), [ + 'assigned_user' => $user->id, + 'checkout_to_type' => 'user', + ]) + ->assertStatusMessageIs('success'); + + $this->assertDatabaseHas('action_logs', [ + 'item_type' => Accessory::class, + 'item_id' => $accessory->id, + 'action_type' => 'checkout', + 'company_id' => $company->id, + ]); + } + + public function test_accessory_checkin_log_stores_the_accessorys_company_id(): void + { + $company = Company::factory()->create(); + $accessory = Accessory::factory()->checkedOutToUser()->create(['company_id' => $company->id]); + $admin = User::factory()->superuser()->create(); + + $checkoutRecord = $accessory->checkouts->first(); + + $this->actingAsForApi($admin) + ->postJson(route('api.accessories.checkin', $checkoutRecord)) + ->assertStatusMessageIs('success'); + + $this->assertDatabaseHas('action_logs', [ + 'item_type' => Accessory::class, + 'item_id' => $accessory->id, + 'action_type' => 'checkin from', + 'company_id' => $company->id, + ]); + } + + // ------------------------------------------------------------------------- + // Consumable events + // ------------------------------------------------------------------------- + + public function test_consumable_checkout_log_stores_the_consumables_company_id(): void + { + $company = Company::factory()->create(); + $consumable = Consumable::factory()->create(['company_id' => $company->id]); + $user = User::factory()->create(); + $admin = User::factory()->superuser()->create(); + + $this->actingAsForApi($admin) + ->postJson(route('api.consumables.checkout', $consumable), [ + 'assigned_to' => $user->id, + ]) + ->assertStatusMessageIs('success'); + + $this->assertDatabaseHas('action_logs', [ + 'item_type' => Consumable::class, + 'item_id' => $consumable->id, + 'action_type' => 'checkout', + 'company_id' => $company->id, + ]); + } + + // ------------------------------------------------------------------------- + // Component events + // ------------------------------------------------------------------------- + + public function test_component_checkout_log_stores_the_components_company_id(): void + { + $company = Company::factory()->create(); + $component = Component::factory()->create(['company_id' => $company->id]); + $asset = Asset::factory()->create(); + $admin = User::factory()->superuser()->create(); + + $this->actingAsForApi($admin) + ->postJson(route('api.components.checkout', $component->id), [ + 'assigned_to' => $asset->id, + 'assigned_qty' => 1, + ]) + ->assertStatusMessageIs('success'); + + $this->assertDatabaseHas('action_logs', [ + 'item_type' => Component::class, + 'item_id' => $component->id, + 'action_type' => 'checkout', + 'company_id' => $company->id, + ]); + } + + public function test_component_checkin_log_stores_the_components_company_id(): void + { + $company = Company::factory()->create(); + $component = Component::factory()->create(['company_id' => $company->id]); + $asset = Asset::factory()->create(); + $admin = User::factory()->superuser()->create(); + + // Check out first + $this->actingAsForApi($admin) + ->postJson(route('api.components.checkout', $component->id), [ + 'assigned_to' => $asset->id, + 'assigned_qty' => 1, + ]) + ->assertStatusMessageIs('success'); + + $pivotId = $component->assets()->first()->pivot->id; + + $this->actingAsForApi($admin) + ->postJson(route('api.components.checkin', $pivotId), [ + 'checkin_qty' => 1, + ]) + ->assertStatusMessageIs('success'); + + $this->assertDatabaseHas('action_logs', [ + 'item_type' => Component::class, + 'item_id' => $component->id, + 'action_type' => 'checkin from', + 'company_id' => $company->id, + ]); + } + + // ------------------------------------------------------------------------- + // License events + // ------------------------------------------------------------------------- + + public function test_license_checkout_log_stores_the_licenses_company_id(): void + { + $company = Company::factory()->create(); + $license = License::factory()->create(['company_id' => $company->id]); + $seat = $license->freeSeats()->first(); + $user = User::factory()->create(); + $admin = User::factory()->superuser()->create(); + + $this->actingAsForApi($admin) + ->patchJson(route('api.licenses.seats.update', [$license->id, $seat->id]), [ + 'assigned_to' => $user->id, + ]) + ->assertStatusMessageIs('success'); + + // The log is stored against the License (item_type), not the LicenseSeat + $this->assertDatabaseHas('action_logs', [ + 'item_type' => License::class, + 'item_id' => $license->id, + 'action_type' => 'checkout', + 'company_id' => $company->id, + ]); + } + + public function test_license_checkin_log_stores_the_licenses_company_id(): void + { + $company = Company::factory()->create(); + $license = License::factory()->create(['company_id' => $company->id]); + $seat = $license->freeSeats()->first(); + $user = User::factory()->create(); + $admin = User::factory()->superuser()->create(); + + // Check out first + $seat->assigned_to = $user->id; + $seat->save(); + + $this->actingAsForApi($admin) + ->patchJson(route('api.licenses.seats.update', [$license->id, $seat->id]), [ + 'assigned_to' => null, + ]) + ->assertStatusMessageIs('success'); + + $this->assertDatabaseHas('action_logs', [ + 'item_type' => License::class, + 'item_id' => $license->id, + 'action_type' => 'checkin from', + 'company_id' => $company->id, + ]); + } + + // ------------------------------------------------------------------------- + // Null company_id — items without a company should log null, not an error + // ------------------------------------------------------------------------- + + public function test_asset_audit_log_company_id_is_null_when_asset_has_no_company(): void + { + $asset = Asset::factory()->create(['company_id' => null]); + $admin = User::factory()->superuser()->create(); + + $this->actingAsForApi($admin) + ->postJson(route('api.asset.audit', $asset), ['note' => 'no company']) + ->assertStatusMessageIs('success'); + + $this->assertDatabaseHas('action_logs', [ + 'item_type' => Asset::class, + 'item_id' => $asset->id, + 'action_type' => 'audit', + 'company_id' => null, + ]); + } + + public function test_asset_checkout_log_company_id_is_null_when_asset_has_no_company(): void + { + $asset = Asset::factory()->create(['company_id' => null]); + $user = User::factory()->create(); + $admin = User::factory()->superuser()->create(); + + $this->actingAsForApi($admin) + ->postJson(route('api.asset.checkout', $asset), [ + 'checkout_to_type' => 'user', + 'assigned_user' => $user->id, + 'status_id' => $asset->status_id, + ]) + ->assertStatusMessageIs('success'); + + $this->assertDatabaseHas('action_logs', [ + 'item_type' => Asset::class, + 'item_id' => $asset->id, + 'action_type' => 'checkout', + 'company_id' => null, + ]); + } +} diff --git a/tests/Feature/Assets/Ui/AssetIndexTest.php b/tests/Feature/Assets/Ui/AssetIndexTest.php index c3ed0fbad3..3fcaa5990f 100644 --- a/tests/Feature/Assets/Ui/AssetIndexTest.php +++ b/tests/Feature/Assets/Ui/AssetIndexTest.php @@ -13,4 +13,16 @@ class AssetIndexTest extends TestCase ->get(route('hardware.index')) ->assertOk(); } + + public function test_page_renders_with_array_query_inputs() + { + $this->actingAs(User::factory()->superuser()->create()) + ->get(route('hardware.index', [ + 'status_type' => ['Deleted'], + 'order_number' => [123], + 'company_id' => [1], + 'status_id' => [1], + ])) + ->assertOk(); + } } diff --git a/tests/Feature/CheckoutAcceptances/Ui/AssetAcceptanceTest.php b/tests/Feature/CheckoutAcceptances/Ui/AssetAcceptanceTest.php index 0e4c9eda7b..6deca9920d 100644 --- a/tests/Feature/CheckoutAcceptances/Ui/AssetAcceptanceTest.php +++ b/tests/Feature/CheckoutAcceptances/Ui/AssetAcceptanceTest.php @@ -6,9 +6,12 @@ use App\Events\CheckoutAccepted; use App\Models\Actionlog; use App\Models\Asset; use App\Models\CheckoutAcceptance; +use App\Models\CustomField; use App\Models\Setting; use App\Models\User; +use App\Notifications\AcceptanceItemAcceptedNotification; use Illuminate\Support\Facades\Event; +use Illuminate\Support\Facades\Notification; use Tests\TestCase; class AssetAcceptanceTest extends TestCase @@ -204,6 +207,45 @@ class AssetAcceptanceTest extends TestCase ); } + public function test_acceptance_email_includes_custom_fields_marked_show_in_email_and_not_encrypted(): void + { + Event::fake([CheckoutAccepted::class]); + Notification::fake(); + $this->settings->enableAlertEmail(); + + $customField = CustomField::factory()->create([ + 'name' => 'Cost Center', + 'show_in_email' => '1', + 'field_encrypted' => '0', + ])->fresh(); + + $asset = Asset::factory()->hasMultipleCustomFields([$customField])->create(); + $asset->{$customField->db_column} = 'ENG-42'; + $asset->save(); + + $checkoutAcceptance = CheckoutAcceptance::factory() + ->pending() + ->for($asset, 'checkoutable') + ->create(); + + $this->actingAs($checkoutAcceptance->assignedTo) + ->post(route('account.store-acceptance', $checkoutAcceptance), [ + 'asset_acceptance' => 'accepted', + ]) + ->assertRedirectToRoute('account.accept') + ->assertSessionHas('success'); + + Notification::assertSentTo( + $checkoutAcceptance, + function (AcceptanceItemAcceptedNotification $notification) { + $rendered = $notification->toMail()->render(); + + return str_contains($rendered, 'Cost Center') + && str_contains($rendered, 'ENG-42'); + } + ); + } + public function test_admin_can_complete_sign_in_place_acceptance_and_is_redirected_to_selected_destination() { Event::fake([CheckoutAccepted::class]); diff --git a/tests/Feature/Components/Api/ComponentAssetsTest.php b/tests/Feature/Components/Api/ComponentAssetsTest.php new file mode 100644 index 0000000000..02edcecb2e --- /dev/null +++ b/tests/Feature/Components/Api/ComponentAssetsTest.php @@ -0,0 +1,64 @@ +create(); + + $this->actingAsForApi(User::factory()->create()) + ->getJson(route('api.components.assets', $component)) + ->assertForbidden(); + } + + public function test_can_search_assets_assigned_to_specific_component() + { + $component = Component::factory()->create(); + $user = User::factory()->viewAssets()->create(); + + $matchingAsset = Asset::factory()->create([ + 'name' => 'Laptop 1331', + 'asset_tag' => 'ASSET-1331', + ]); + + $nonMatchingAsset = Asset::factory()->create([ + 'name' => 'Laptop 9999', + 'asset_tag' => 'ASSET-9999', + ]); + + $component->assets()->attach($matchingAsset->id, [ + 'component_id' => $component->id, + 'asset_id' => $matchingAsset->id, + 'assigned_qty' => 2, + 'created_at' => now(), + 'created_by' => $user->id, + ]); + + $component->assets()->attach($nonMatchingAsset->id, [ + 'component_id' => $component->id, + 'asset_id' => $nonMatchingAsset->id, + 'assigned_qty' => 1, + 'created_at' => now(), + 'created_by' => $user->id, + ]); + + $this->actingAsForApi($user) + ->getJson(route('api.components.assets', $component).'?search=1331') + ->assertOk() + ->assertJson(function (AssertableJson $json) use ($matchingAsset) { + $json->where('total', 1) + ->count('rows', 1) + ->where('rows.0.name.id', $matchingAsset->id) + ->where('rows.0.assigned_qty', 2) + ->etc(); + }); + } +} diff --git a/tests/Feature/Components/Api/ComponentIndexTest.php b/tests/Feature/Components/Api/ComponentIndexTest.php index d13c03f5f8..b0794bb948 100644 --- a/tests/Feature/Components/Api/ComponentIndexTest.php +++ b/tests/Feature/Components/Api/ComponentIndexTest.php @@ -2,8 +2,12 @@ namespace Tests\Feature\Components\Api; +use App\Models\Category; use App\Models\Company; use App\Models\Component; +use App\Models\Location; +use App\Models\Manufacturer; +use App\Models\Supplier; use App\Models\User; use Tests\TestCase; @@ -54,4 +58,64 @@ class ComponentIndexTest extends TestCase ->assertResponseDoesNotContainInRows($componentA) ->assertResponseContainsInRows($componentB); } + + public function test_component_index_filters_all_supported_exact_fields() + { + $user = User::factory()->superuser()->create(); + + $targetCompany = Company::factory()->create(); + $otherCompany = Company::factory()->create(); + $targetCategory = Category::factory()->create(); + $otherCategory = Category::factory()->create(); + $targetSupplier = Supplier::factory()->create(); + $otherSupplier = Supplier::factory()->create(); + $targetManufacturer = Manufacturer::factory()->create(); + $otherManufacturer = Manufacturer::factory()->create(); + $targetLocation = Location::factory()->create(); + $otherLocation = Location::factory()->create(); + + $targetComponent = Component::factory()->create([ + 'name' => 'Target Component', + 'company_id' => $targetCompany->id, + 'order_number' => 'COMP-ORDER-A', + 'category_id' => $targetCategory->id, + 'supplier_id' => $targetSupplier->id, + 'manufacturer_id' => $targetManufacturer->id, + 'model_number' => 'COMP-MODEL-A', + 'location_id' => $targetLocation->id, + 'notes' => 'COMP-NOTES-A', + ]); + + $otherComponent = Component::factory()->create([ + 'name' => 'Other Component', + 'company_id' => $otherCompany->id, + 'order_number' => 'COMP-ORDER-B', + 'category_id' => $otherCategory->id, + 'supplier_id' => $otherSupplier->id, + 'manufacturer_id' => $otherManufacturer->id, + 'model_number' => 'COMP-MODEL-B', + 'location_id' => $otherLocation->id, + 'notes' => 'COMP-NOTES-B', + ]); + + $filters = [ + 'name' => 'Target Component', + 'company_id' => $targetCompany->id, + 'order_number' => 'COMP-ORDER-A', + 'category_id' => $targetCategory->id, + 'supplier_id' => $targetSupplier->id, + 'manufacturer_id' => $targetManufacturer->id, + 'model_number' => 'COMP-MODEL-A', + 'location_id' => $targetLocation->id, + 'notes' => 'COMP-NOTES-A', + ]; + + foreach ($filters as $filterKey => $filterValue) { + $this->actingAsForApi($user) + ->getJson(route('api.components.index', [$filterKey => $filterValue])) + ->assertOk() + ->assertResponseContainsInRows($targetComponent) + ->assertResponseDoesNotContainInRows($otherComponent); + } + } } diff --git a/tests/Feature/Consumables/Api/ConsumableIndexTest.php b/tests/Feature/Consumables/Api/ConsumableIndexTest.php index f6d4fae37f..e4316c79e4 100644 --- a/tests/Feature/Consumables/Api/ConsumableIndexTest.php +++ b/tests/Feature/Consumables/Api/ConsumableIndexTest.php @@ -2,8 +2,12 @@ namespace Tests\Feature\Consumables\Api; +use App\Models\Category; use App\Models\Company; use App\Models\Consumable; +use App\Models\Location; +use App\Models\Manufacturer; +use App\Models\Supplier; use App\Models\User; use Tests\TestCase; @@ -79,4 +83,64 @@ class ConsumableIndexTest extends TestCase ]); } + + public function test_consumable_index_filters_all_supported_exact_fields() + { + $user = User::factory()->superuser()->create(); + + $targetCompany = Company::factory()->create(); + $otherCompany = Company::factory()->create(); + $targetCategory = Category::factory()->create(); + $otherCategory = Category::factory()->create(); + $targetManufacturer = Manufacturer::factory()->create(); + $otherManufacturer = Manufacturer::factory()->create(); + $targetSupplier = Supplier::factory()->create(); + $otherSupplier = Supplier::factory()->create(); + $targetLocation = Location::factory()->create(); + $otherLocation = Location::factory()->create(); + + $targetConsumable = Consumable::factory()->create([ + 'name' => 'Target Consumable', + 'company_id' => $targetCompany->id, + 'order_number' => 'CONS-ORDER-A', + 'category_id' => $targetCategory->id, + 'model_number' => 'CONS-MODEL-A', + 'manufacturer_id' => $targetManufacturer->id, + 'supplier_id' => $targetSupplier->id, + 'location_id' => $targetLocation->id, + 'notes' => 'CONS-NOTES-A', + ]); + + $otherConsumable = Consumable::factory()->create([ + 'name' => 'Other Consumable', + 'company_id' => $otherCompany->id, + 'order_number' => 'CONS-ORDER-B', + 'category_id' => $otherCategory->id, + 'model_number' => 'CONS-MODEL-B', + 'manufacturer_id' => $otherManufacturer->id, + 'supplier_id' => $otherSupplier->id, + 'location_id' => $otherLocation->id, + 'notes' => 'CONS-NOTES-B', + ]); + + $filters = [ + 'name' => 'Target Consumable', + 'company_id' => $targetCompany->id, + 'order_number' => 'CONS-ORDER-A', + 'category_id' => $targetCategory->id, + 'model_number' => 'CONS-MODEL-A', + 'manufacturer_id' => $targetManufacturer->id, + 'supplier_id' => $targetSupplier->id, + 'location_id' => $targetLocation->id, + 'notes' => 'CONS-NOTES-A', + ]; + + foreach ($filters as $filterKey => $filterValue) { + $this->actingAsForApi($user) + ->getJson(route('api.consumables.index', [$filterKey => $filterValue])) + ->assertOk() + ->assertResponseContainsInRows($targetConsumable) + ->assertResponseDoesNotContainInRows($otherConsumable); + } + } } diff --git a/tests/Feature/Departments/Api/DepartmentsIndexTest.php b/tests/Feature/Departments/Api/DepartmentsIndexTest.php index a5997b5718..71cd32a06c 100644 --- a/tests/Feature/Departments/Api/DepartmentsIndexTest.php +++ b/tests/Feature/Departments/Api/DepartmentsIndexTest.php @@ -4,6 +4,7 @@ namespace Tests\Feature\Departments\Api; use App\Models\Company; use App\Models\Department; +use App\Models\Location; use App\Models\User; use Illuminate\Testing\Fluent\AssertableJson; use Tests\TestCase; @@ -87,4 +88,47 @@ class DepartmentsIndexTest extends TestCase ->assertResponseDoesNotContainInRows($departmentA) ->assertResponseContainsInRows($departmentB); } + + public function test_department_index_filters_all_supported_exact_fields() + { + $user = User::factory()->superuser()->create(); + $targetCompany = Company::factory()->create(); + $otherCompany = Company::factory()->create(); + $targetManager = User::factory()->create(); + $otherManager = User::factory()->create(); + $targetLocation = Location::factory()->create(); + $otherLocation = Location::factory()->create(); + + $targetDepartment = Department::factory()->create([ + 'name' => 'Target Department', + 'company_id' => $targetCompany->id, + 'manager_id' => $targetManager->id, + 'location_id' => $targetLocation->id, + 'tag_color' => '#AA11AA', + ]); + + $otherDepartment = Department::factory()->create([ + 'name' => 'Other Department', + 'company_id' => $otherCompany->id, + 'manager_id' => $otherManager->id, + 'location_id' => $otherLocation->id, + 'tag_color' => '#11AA11', + ]); + + $filters = [ + 'name' => 'Target Department', + 'company_id' => $targetCompany->id, + 'manager_id' => $targetManager->id, + 'location_id' => $targetLocation->id, + 'tag_color' => '#AA11AA', + ]; + + foreach ($filters as $filterKey => $filterValue) { + $this->actingAsForApi($user) + ->getJson(route('api.departments.index', [$filterKey => $filterValue])) + ->assertOk() + ->assertResponseContainsInRows($targetDepartment) + ->assertResponseDoesNotContainInRows($otherDepartment); + } + } } diff --git a/tests/Feature/History/Api/HistoryTest.php b/tests/Feature/History/Api/IndexHistoryTest.php similarity index 100% rename from tests/Feature/History/Api/HistoryTest.php rename to tests/Feature/History/Api/IndexHistoryTest.php diff --git a/tests/Feature/Maintenances/Api/EditMaintenanceTest.php b/tests/Feature/Maintenances/Api/EditMaintenanceTest.php index 9c566f55af..864c6ee71c 100644 --- a/tests/Feature/Maintenances/Api/EditMaintenanceTest.php +++ b/tests/Feature/Maintenances/Api/EditMaintenanceTest.php @@ -2,6 +2,8 @@ namespace Tests\Feature\Maintenances\Api; +use App\Models\Actionlog; +use App\Models\Company; use App\Models\Maintenance; use App\Models\Supplier; use App\Models\User; @@ -60,5 +62,38 @@ class EditMaintenanceTest extends TestCase ]); $this->assertHasTheseActionLogs($maintenance, ['create', 'update']); + + $updateLog = Actionlog::query() + ->where('item_type', Maintenance::class) + ->where('item_id', $maintenance->id) + ->where('action_type', 'update') + ->latest('id') + ->first(); + + $this->assertNotNull($updateLog); + $this->assertNotNull($updateLog->log_meta); + $this->assertArrayHasKey('name', json_decode($updateLog->log_meta, true)); + } + + public function test_user_cannot_edit_maintenance_for_another_company_when_fmcs_enabled() + { + $this->settings->enableMultipleFullCompanySupport(); + + [$companyA, $companyB] = Company::factory()->count(2)->create(); + + $userInCompanyA = $companyA->users()->save(User::factory()->editAssets()->make()); + $maintenanceForCompanyB = Maintenance::factory()->create(); + $maintenanceForCompanyB->asset->update(['company_id' => $companyB->id]); + + $this->actingAsForApi($userInCompanyA) + ->putJson(route('api.maintenances.update', $maintenanceForCompanyB), [ + 'name' => 'Should Not Update', + ]) + ->assertStatusMessageIs('error'); + + $this->assertDatabaseMissing('maintenances', [ + 'id' => $maintenanceForCompanyB->id, + 'name' => 'Should Not Update', + ]); } } diff --git a/tests/Feature/Maintenances/Api/MaintenanceFileTest.php b/tests/Feature/Maintenances/Api/MaintenanceFileTest.php new file mode 100644 index 0000000000..3b0a92043b --- /dev/null +++ b/tests/Feature/Maintenances/Api/MaintenanceFileTest.php @@ -0,0 +1,79 @@ +create(); + + $user = User::factory() + ->editAssets() + ->create(['company_id' => $company->id]); + + $asset = Asset::factory()->create(['company_id' => $company->id]); + + $maintenance = Maintenance::factory()->create([ + 'asset_id' => $asset->id, + 'created_by' => $user->id, + ]); + + $this->actingAsForApi($user) + ->post(route('api.files.store', ['object_type' => 'maintenances', 'id' => $maintenance->id]), [ + 'file' => [UploadedFile::fake()->create('maintenance-test.pdf', 64)], + ]) + ->assertOk(); + + $uploadedLog = Actionlog::query() + ->where('action_type', 'uploaded') + ->where('item_type', Maintenance::class) + ->where('item_id', $maintenance->id) + ->latest('id') + ->first(); + + $this->assertNotNull($uploadedLog); + $this->assertSame($company->id, $uploadedLog->company_id); + + $this->actingAsForApi($user) + ->getJson(route('api.files.index', ['object_type' => 'maintenances', 'id' => $maintenance->id])) + ->assertOk() + ->assertJsonStructure(['rows', 'total']) + ->assertJsonPath('total', 1); + } + + public function test_user_cannot_list_or_upload_files_for_maintenance_in_another_company_when_fmcs_enabled() + { + $this->settings->enableMultipleFullCompanySupport(); + + [$companyA, $companyB] = Company::factory()->count(2)->create(); + + $userInCompanyA = $companyA->users()->save(User::factory()->editAssets()->make()); + $maintenanceForCompanyB = Maintenance::factory()->create(); + $maintenanceForCompanyB->asset->update(['company_id' => $companyB->id]); + + $this->actingAsForApi($userInCompanyA) + ->getJson(route('api.files.index', ['object_type' => 'maintenances', 'id' => $maintenanceForCompanyB->id])) + ->assertForbidden(); + + $this->actingAsForApi($userInCompanyA) + ->post(route('api.files.store', ['object_type' => 'maintenances', 'id' => $maintenanceForCompanyB->id]), [ + 'file' => [UploadedFile::fake()->create('cross-company.pdf', 64)], + ]) + ->assertForbidden(); + + $this->assertDatabaseMissing('action_logs', [ + 'action_type' => 'uploaded', + 'item_type' => Maintenance::class, + 'item_id' => $maintenanceForCompanyB->id, + ]); + } +} diff --git a/tests/Feature/Maintenances/Ui/CreateMaintenanceTest.php b/tests/Feature/Maintenances/Ui/CreateMaintenanceTest.php index cd16ff67af..4526572947 100644 --- a/tests/Feature/Maintenances/Ui/CreateMaintenanceTest.php +++ b/tests/Feature/Maintenances/Ui/CreateMaintenanceTest.php @@ -2,6 +2,7 @@ namespace Tests\Feature\Maintenances\Ui; +use App\Models\Actionlog; use App\Models\Asset; use App\Models\Maintenance; use App\Models\Supplier; @@ -29,6 +30,7 @@ class CreateMaintenanceTest extends TestCase public function test_can_create_maintenance() { Storage::fake('public'); + Storage::fake('local'); $actor = User::factory()->superuser()->create(); $asset = Asset::factory()->create(); $supplier = Supplier::factory()->create(); @@ -44,6 +46,7 @@ class CreateMaintenanceTest extends TestCase 'is_warranty' => '1', 'cost' => '100.00', 'image' => UploadedFile::fake()->image('test_image.png'), + 'file' => [UploadedFile::fake()->create('maintenance.pdf', 64, 'application/pdf')], 'notes' => 'A note', 'url' => 'https://snipeitapp.com', ]) @@ -56,6 +59,16 @@ class CreateMaintenanceTest extends TestCase // Assert file was stored... Storage::disk('public')->assertExists(app('maintenances_path').$maintenance->image); + $uploadedLog = Actionlog::query() + ->where('action_type', 'uploaded') + ->where('item_type', Maintenance::class) + ->where('item_id', $maintenance->id) + ->latest('id') + ->first(); + + $this->assertNotNull($uploadedLog); + Storage::disk('local')->assertExists('private_uploads/maintenances/'.$uploadedLog->filename); + $this->assertDatabaseHas('maintenances', [ 'asset_id' => $asset->id, 'supplier_id' => $supplier->id, @@ -72,6 +85,6 @@ class CreateMaintenanceTest extends TestCase 'created_by' => $actor->id, ]); - $this->assertHasTheseActionLogs($maintenance, ['create']); + $this->assertHasTheseActionLogs($maintenance, ['create', 'uploaded']); } } diff --git a/tests/Feature/Maintenances/Ui/EditMaintenanceTest.php b/tests/Feature/Maintenances/Ui/EditMaintenanceTest.php index 62397e73ed..d7dcd9cdf8 100644 --- a/tests/Feature/Maintenances/Ui/EditMaintenanceTest.php +++ b/tests/Feature/Maintenances/Ui/EditMaintenanceTest.php @@ -2,7 +2,9 @@ namespace Tests\Feature\Maintenances\Ui; +use App\Models\Actionlog; use App\Models\Asset; +use App\Models\Company; use App\Models\Maintenance; use App\Models\Supplier; use App\Models\User; @@ -21,6 +23,9 @@ class EditMaintenanceTest extends TestCase public function test_can_update_maintenance() { + Storage::fake('public'); + Storage::fake('local'); + $actor = User::factory()->superuser()->create(); $asset = Asset::factory()->create(); $maintenance = Maintenance::factory()->create(['asset_id' => $asset]); @@ -36,6 +41,7 @@ class EditMaintenanceTest extends TestCase 'completion_date' => '2021-01-10', 'is_warranty' => 1, 'image' => UploadedFile::fake()->image('test_image.png'), + 'file' => [UploadedFile::fake()->create('maintenance-update.pdf', 64, 'application/pdf')], 'cost' => '100.99', 'notes' => 'A note', 'url' => 'https://snipeitapp.com', @@ -49,6 +55,16 @@ class EditMaintenanceTest extends TestCase // Assert file was stored... Storage::disk('public')->assertExists(app('maintenances_path').$maintenance->image); + $uploadedLog = Actionlog::query() + ->where('action_type', 'uploaded') + ->where('item_type', Maintenance::class) + ->where('item_id', $maintenance->id) + ->latest('id') + ->first(); + + $this->assertNotNull($uploadedLog); + Storage::disk('local')->assertExists('private_uploads/maintenances/'.$uploadedLog->filename); + $this->assertDatabaseHas('maintenances', [ 'asset_id' => $asset->id, 'supplier_id' => $supplier->id, @@ -63,6 +79,46 @@ class EditMaintenanceTest extends TestCase 'cost' => '100.99', ]); - $this->assertHasTheseActionLogs($maintenance, ['create', 'update']); + $this->assertHasTheseActionLogs($maintenance, ['create', 'update', 'uploaded']); + + $updateLog = Actionlog::query() + ->where('item_type', Maintenance::class) + ->where('item_id', $maintenance->id) + ->where('action_type', 'update') + ->latest('id') + ->first(); + + $this->assertNotNull($updateLog); + $this->assertNotNull($updateLog->log_meta); + $this->assertArrayHasKey('name', json_decode($updateLog->log_meta, true)); + } + + public function test_user_cannot_edit_maintenance_for_another_company_when_fmcs_enabled() + { + $this->settings->enableMultipleFullCompanySupport(); + + [$companyA, $companyB] = Company::factory()->count(2)->create(); + + $userInCompanyA = $companyA->users()->save(User::factory()->editAssets()->make()); + $maintenanceForCompanyB = Maintenance::factory()->create(); + $maintenanceForCompanyB->asset->update(['company_id' => $companyB->id]); + + $this->actingAs($userInCompanyA) + ->get(route('maintenances.edit', $maintenanceForCompanyB)) + ->assertRedirectToRoute('maintenances.index'); + + $this->actingAs($userInCompanyA) + ->put(route('maintenances.update', $maintenanceForCompanyB), [ + 'name' => 'Should Not Update', + 'asset_id' => $maintenanceForCompanyB->asset_id, + 'asset_maintenance_type' => $maintenanceForCompanyB->asset_maintenance_type, + 'start_date' => $maintenanceForCompanyB->start_date, + ]) + ->assertRedirectToRoute('maintenances.index'); + + $this->assertDatabaseMissing('maintenances', [ + 'id' => $maintenanceForCompanyB->id, + 'name' => 'Should Not Update', + ]); } } diff --git a/tests/Feature/Maintenances/Ui/MaintenanceFileTest.php b/tests/Feature/Maintenances/Ui/MaintenanceFileTest.php new file mode 100644 index 0000000000..3add526ceb --- /dev/null +++ b/tests/Feature/Maintenances/Ui/MaintenanceFileTest.php @@ -0,0 +1,100 @@ +create(); + + $user = User::factory() + ->editAssets() + ->create(['company_id' => $company->id]); + + $asset = Asset::factory()->create(['company_id' => $company->id]); + + $maintenance = Maintenance::factory()->create([ + 'asset_id' => $asset->id, + 'created_by' => $user->id, + ]); + + $this->actingAs($user) + ->post(route('ui.files.store', ['object_type' => 'maintenances', 'id' => $maintenance->id]), [ + 'file' => [UploadedFile::fake()->create('maintenance-test.pdf', 64)], + 'notes' => 'UI upload test', + ]) + ->assertStatus(302) + ->assertSessionHasNoErrors(); + + $uploadedLog = Actionlog::query() + ->where('action_type', 'uploaded') + ->where('item_type', Maintenance::class) + ->where('item_id', $maintenance->id) + ->latest('id') + ->first(); + + $this->assertNotNull($uploadedLog); + $this->assertSame($company->id, $uploadedLog->company_id); + } + + public function test_maintenance_show_page_includes_upload_modal_for_user_with_file_permission() + { + $company = Company::factory()->create(); + + $user = User::factory() + ->editAssets() + ->create(['company_id' => $company->id]); + + $asset = Asset::factory()->create(['company_id' => $company->id]); + + $maintenance = Maintenance::factory()->create([ + 'asset_id' => $asset->id, + 'created_by' => $user->id, + ]); + + $this->actingAs($user) + ->get(route('maintenances.show', $maintenance)) + ->assertOk() + ->assertSee('id="uploadFileModal"', false) + ->assertSee(route('ui.files.store', ['object_type' => 'maintenances', 'id' => $maintenance->id]), false); + } + + public function test_user_cannot_view_or_upload_files_for_maintenance_in_another_company_when_fmcs_enabled() + { + $this->settings->enableMultipleFullCompanySupport(); + + [$companyA, $companyB] = Company::factory()->count(2)->create(); + + $userInCompanyA = $companyA->users()->save(User::factory()->editAssets()->make()); + $maintenanceForCompanyB = Maintenance::factory()->create(); + $maintenanceForCompanyB->asset->update(['company_id' => $companyB->id]); + + $this->actingAs($userInCompanyA) + ->get(route('maintenances.show', $maintenanceForCompanyB)) + ->assertRedirectToRoute('maintenances.index'); + + $this->actingAs($userInCompanyA) + ->post(route('ui.files.store', ['object_type' => 'maintenances', 'id' => $maintenanceForCompanyB->id]), [ + 'file' => [UploadedFile::fake()->create('cross-company.pdf', 64)], + ]) + ->assertForbidden(); + + $this->assertDatabaseMissing('action_logs', [ + 'action_type' => 'uploaded', + 'item_type' => Maintenance::class, + 'item_id' => $maintenanceForCompanyB->id, + ]); + } +} diff --git a/tests/Feature/Maintenances/Ui/ShowMaintenanceTest.php b/tests/Feature/Maintenances/Ui/ShowMaintenanceTest.php index 4495676f5c..2339bf9abc 100644 --- a/tests/Feature/Maintenances/Ui/ShowMaintenanceTest.php +++ b/tests/Feature/Maintenances/Ui/ShowMaintenanceTest.php @@ -2,6 +2,7 @@ namespace Tests\Feature\Maintenances\Ui; +use App\Models\Company; use App\Models\Maintenance; use App\Models\User; use Tests\TestCase; @@ -14,4 +15,30 @@ class ShowMaintenanceTest extends TestCase ->get(route('maintenances.show', Maintenance::factory()->create()->id)) ->assertOk(); } + + public function test_page_renders_history_tab_and_history_table() + { + $maintenance = Maintenance::factory()->create(); + + $this->actingAs(User::factory()->superuser()->create()) + ->get(route('maintenances.show', $maintenance)) + ->assertOk() + ->assertSee(trans('general.history')) + ->assertSee(route('api.maintenances.history', $maintenance), false); + } + + public function test_user_cannot_view_maintenance_for_another_company_when_fmcs_enabled() + { + $this->settings->enableMultipleFullCompanySupport(); + + [$companyA, $companyB] = Company::factory()->count(2)->create(); + + $userInCompanyA = $companyA->users()->save(User::factory()->editAssets()->make()); + $maintenanceForCompanyB = Maintenance::factory()->create(); + $maintenanceForCompanyB->asset->update(['company_id' => $companyB->id]); + + $this->actingAs($userInCompanyA) + ->get(route('maintenances.show', $maintenanceForCompanyB)) + ->assertRedirectToRoute('maintenances.index'); + } } diff --git a/tests/Feature/Notifications/Email/EmailNotificationsToUserUponCheckinTest.php b/tests/Feature/Notifications/Email/EmailNotificationsToUserUponCheckinTest.php index cacaa055b7..228b46a6aa 100644 --- a/tests/Feature/Notifications/Email/EmailNotificationsToUserUponCheckinTest.php +++ b/tests/Feature/Notifications/Email/EmailNotificationsToUserUponCheckinTest.php @@ -7,6 +7,7 @@ use App\Mail\CheckinAssetMail; use App\Models\Accessory; use App\Models\Asset; use App\Models\Consumable; +use App\Models\CustomField; use App\Models\LicenseSeat; use App\Models\User; use Illuminate\Support\Facades\Mail; @@ -99,6 +100,32 @@ class EmailNotificationsToUserUponCheckinTest extends TestCase Mail::assertNothingSent(); } + public function test_checkin_email_includes_custom_fields_marked_show_in_email_and_not_encrypted() + { + $customField = CustomField::factory()->create([ + 'name' => 'Cost Center', + 'show_in_email' => '1', + 'field_encrypted' => '0', + ])->fresh(); + + $user = User::factory()->create(); + $asset = Asset::factory()->hasMultipleCustomFields([$customField])->assignedToUser($user)->create(); + $asset->{$customField->db_column} = 'ENG-42'; + $asset->save(); + + $asset->model->category->update(['checkin_email' => true]); + + $this->fireCheckInEvent($asset, $user); + + Mail::assertSent(CheckinAssetMail::class, function (CheckinAssetMail $mail) use ($user) { + $rendered = $mail->render(); + + return $mail->hasTo($user->email) + && str_contains($rendered, 'Cost Center') + && str_contains($rendered, 'ENG-42'); + }); + } + private function fireCheckInEvent($asset, $user): void { event(new CheckoutableCheckedIn( diff --git a/tests/Feature/Notifications/Email/EmailNotificationsToUserUponCheckoutTest.php b/tests/Feature/Notifications/Email/EmailNotificationsToUserUponCheckoutTest.php index 817f38fdb5..670ad6b0de 100644 --- a/tests/Feature/Notifications/Email/EmailNotificationsToUserUponCheckoutTest.php +++ b/tests/Feature/Notifications/Email/EmailNotificationsToUserUponCheckoutTest.php @@ -8,6 +8,7 @@ use App\Models\Asset; use App\Models\AssetModel; use App\Models\Category; use App\Models\CheckoutAcceptance; +use App\Models\CustomField; use App\Models\User; use Illuminate\Support\Facades\Mail; use PHPUnit\Framework\Attributes\Group; @@ -106,6 +107,35 @@ class EmailNotificationsToUserUponCheckoutTest extends TestCase $this->assertUserSentEmail(); } + public function test_email_includes_custom_fields_marked_show_in_email_and_not_encrypted() + { + $customField = CustomField::factory()->create([ + 'name' => 'Cost Center', + 'show_in_email' => '1', + 'field_encrypted' => '0', + ])->fresh(); + + $asset = Asset::factory()->hasMultipleCustomFields([$customField])->create(); + $asset->{$customField->db_column} = 'ENG-42'; + $asset->save(); + + $this->category = $asset->model->category; + $this->asset = $asset; + $this->user = User::factory()->create(); + + $this->category->update(['checkin_email' => true]); + + $this->fireCheckoutEvent(); + + Mail::assertSent(CheckoutAssetMail::class, function (CheckoutAssetMail $mail) { + $rendered = $mail->render(); + + return $mail->hasTo($this->user->email) + && str_contains($rendered, 'Cost Center') + && str_contains($rendered, 'ENG-42'); + }); + } + public function test_handles_user_not_having_email_address_set() { $this->category->update(['checkin_email' => true]); diff --git a/tests/Feature/Reporting/ActivityReportTest.php b/tests/Feature/Reporting/ActivityReportTest.php index 658710f8ef..8a9f4832a4 100644 --- a/tests/Feature/Reporting/ActivityReportTest.php +++ b/tests/Feature/Reporting/ActivityReportTest.php @@ -90,8 +90,10 @@ class ActivityReportTest extends TestCase // I don't love this, since it doesn't test that we're actually storing the company ID appropriately // but it's better than what we had - $response = $this->actingAsForApi($userInCompanyA) - ->getJson(route('api.activity.index')) + $this->actingAsForApi($userInCompanyA) + ->getJson(route('api.activity.index', [ + 'action_type' => 'update', + ])) ->assertOk() ->assertJsonStructure([ 'rows', @@ -100,7 +102,9 @@ class ActivityReportTest extends TestCase $this->actingAsForApi($userInCompanyB) ->getJson( - route('api.activity.index')) + route('api.activity.index', [ + 'action_type' => 'update', + ])) ->assertOk() ->assertJsonStructure([ 'rows', diff --git a/tests/Feature/Search/SearchableTraitTest.php b/tests/Feature/Search/SearchableTraitTest.php index f9d38481fc..8c0183268d 100644 --- a/tests/Feature/Search/SearchableTraitTest.php +++ b/tests/Feature/Search/SearchableTraitTest.php @@ -13,6 +13,7 @@ use App\Models\Statuslabel; use App\Models\Supplier; use App\Models\User; use Illuminate\Testing\Fluent\AssertableJson; +use PHPUnit\Framework\Attributes\Group; use Tests\TestCase; /** @@ -272,9 +273,8 @@ class SearchableTraitTest extends TestCase /** * Test User free-text search on attributes - * - * @group skip-flaky */ + #[Group('skip-flaky')] public function test_user_free_text_search_on_attributes() { // Note: User search includes the acting user in results, making this test flaky @@ -332,6 +332,71 @@ class SearchableTraitTest extends TestCase ->assertJson(fn (AssertableJson $json) => $json->has('rows', 1)->etc()); } + /** + * "name" is a virtual column on User (CONCAT of first_name + last_name). + * A positive filter should match on concatenated full name. + */ + public function test_user_name_virtual_column_filter_positive() + { + $ts = now()->timestamp; + User::factory()->create(['first_name' => 'VirtFirst'.$ts, 'last_name' => 'VirtLast'.$ts]); + User::factory()->create(['first_name' => 'Other'.$ts, 'last_name' => 'Person'.$ts]); + + $this->actingAsForApi(User::factory()->superuser()->create()) + ->getJson(route('api.users.index', [ + 'filter' => json_encode(['name' => 'VirtFirst'.$ts]), + ])) + ->assertOk() + ->assertJson(fn (AssertableJson $json) => $json->has('rows', 1)->etc()); + } + + /** + * A negated "name" filter using the "!" prefix should exclude matching users, + * returning only those whose full name does NOT contain the term. + */ + public function test_user_name_virtual_column_filter_negation_bang_prefix() + { + $ts = now()->timestamp; + $negUser = User::factory()->create(['first_name' => 'NegFirst'.$ts, 'last_name' => 'NegLast'.$ts]); + $safeUser = User::factory()->create(['first_name' => 'SafeFirst'.$ts, 'last_name' => 'SafeLast'.$ts]); + + $response = $this->actingAsForApi(User::factory()->superuser()->create()) + ->getJson(route('api.users.index', [ + 'filter' => json_encode(['name' => '!NegFirst'.$ts]), + ])) + ->assertOk(); + + $returnedIds = collect($response->json('rows'))->pluck('id')->map(fn ($id) => (int) $id)->all(); + + // The matched (negated) user must not appear in results. + $this->assertNotContains((int) $negUser->id, $returnedIds); + + // The safe user should appear in results. + $this->assertContains((int) $safeUser->id, $returnedIds); + } + + /** + * A negated "name" filter using the "not:" prefix should behave identically to "!". + */ + public function test_user_name_virtual_column_filter_negation_not_colon_prefix() + { + $ts = now()->timestamp; + User::factory()->create(['first_name' => 'NotFirst'.$ts, 'last_name' => 'NotLast'.$ts]); + + $response = $this->actingAsForApi(User::factory()->superuser()->create()) + ->getJson(route('api.users.index', [ + 'filter' => json_encode(['name' => 'not:NotFirst'.$ts]), + ])) + ->assertOk(); + + $returnedIds = collect($response->json('rows'))->pluck('id')->map(fn ($id) => (int) $id)->all(); + + $this->assertNotContains( + (int) User::where('first_name', 'NotFirst'.$ts)->value('id'), + $returnedIds + ); + } + /** * Test Category free-text search on attributes */ @@ -690,6 +755,106 @@ class SearchableTraitTest extends TestCase ->assertJson(fn (AssertableJson $json) => $json->has('rows', 0)->etc()); } + /** + * "is:null" on a direct nullable attribute should match rows where that column is NULL. + * "is:not_null" should match rows where it is not NULL. + */ + public function test_is_null_filter_on_nullable_attribute() + { + $ts = now()->timestamp; + + $withNotes = Asset::factory()->create(['notes' => 'Some notes '.$ts]); + $withoutNotes = Asset::factory()->create(['notes' => null]); + + $superuser = User::factory()->viewAssets()->create(); + + // is:null → only the asset with no notes + $response = $this->actingAsForApi($superuser) + ->getJson(route('api.assets.index', ['filter' => json_encode(['notes' => 'is:null'])])) + ->assertOk(); + + $returnedIds = collect($response->json('rows'))->pluck('id')->map(fn ($id) => (int) $id)->all(); + + $this->assertContains((int) $withoutNotes->id, $returnedIds); + $this->assertNotContains((int) $withNotes->id, $returnedIds); + + // is:not_null → only the asset with notes + $response2 = $this->actingAsForApi($superuser) + ->getJson(route('api.assets.index', ['filter' => json_encode(['notes' => 'is:not_null'])])) + ->assertOk(); + + $returnedIds2 = collect($response2->json('rows'))->pluck('id')->map(fn ($id) => (int) $id)->all(); + + $this->assertContains((int) $withNotes->id, $returnedIds2); + $this->assertNotContains((int) $withoutNotes->id, $returnedIds2); + } + + /** + * "is:not_null" on the User virtual "name" column should match users where + * at least one constituent column (first_name, last_name) is not null. + * All factory-created users have a first_name, so they should all appear. + */ + public function test_is_null_filter_on_virtual_name_column() + { + $ts = now()->timestamp; + + $userWithName = User::factory()->create([ + 'first_name' => 'VirtNullFirst'.$ts, + 'last_name' => 'VirtNullLast'.$ts, + ]); + + $superuser = User::factory()->superuser()->create(); + + // is:not_null → users with at least first_name set should be returned. + $response = $this->actingAsForApi($superuser) + ->getJson(route('api.users.index', ['filter' => json_encode(['name' => 'is:not_null'])])) + ->assertOk(); + + $returnedIds = collect($response->json('rows'))->pluck('id')->map(fn ($id) => (int) $id)->all(); + + // The user with an actual name must appear. + $this->assertContains((int) $userWithName->id, $returnedIds); + + // The acting superuser itself also has a name, so it should appear too. + $this->assertContains((int) $superuser->id, $returnedIds); + } + + /** + * "is:null" on a searchable relation key should return records that have no + * related record (equivalent to doesntHave). + * "is:not_null" should return only records that have a related record. + */ + public function test_is_null_filter_on_relation_key() + { + $ts = now()->timestamp; + + $supplier = Supplier::factory()->create(['name' => 'RelNullSupplier'.$ts]); + $withSupplier = Asset::factory()->create(['supplier_id' => $supplier->id]); + $withoutSupplier = Asset::factory()->create(['supplier_id' => null]); + + $superuser = User::factory()->viewAssets()->create(); + + // is:null on supplier → assets with no supplier + $response = $this->actingAsForApi($superuser) + ->getJson(route('api.assets.index', ['filter' => json_encode(['supplier' => 'is:null'])])) + ->assertOk(); + + $returnedIds = collect($response->json('rows'))->pluck('id')->map(fn ($id) => (int) $id)->all(); + + $this->assertContains((int) $withoutSupplier->id, $returnedIds); + $this->assertNotContains((int) $withSupplier->id, $returnedIds); + + // is:not_null on supplier → assets with a supplier + $response2 = $this->actingAsForApi($superuser) + ->getJson(route('api.assets.index', ['filter' => json_encode(['supplier' => 'is:not_null'])])) + ->assertOk(); + + $returnedIds2 = collect($response2->json('rows'))->pluck('id')->map(fn ($id) => (int) $id)->all(); + + $this->assertContains((int) $withSupplier->id, $returnedIds2); + $this->assertNotContains((int) $withoutSupplier->id, $returnedIds2); + } + /** * Test custom field partial match via filter. */ @@ -711,4 +876,325 @@ class SearchableTraitTest extends TestCase ->assertOk() ->assertJson(fn (AssertableJson $json) => $json->has('rows', 1)->etc()); } + + /** + * "is:{value}" on a direct attribute should match records where the column equals + * the value exactly — no partial/wildcard matching. + */ + public function test_exact_match_filter_on_direct_attribute() + { + $ts = now()->timestamp; + + $exact = Asset::factory()->create(['notes' => 'ExactNote'.$ts]); + $partial = Asset::factory()->create(['notes' => 'ExactNote'.$ts.'SomeSuffix']); + Asset::factory()->create(['notes' => 'Unrelated'.$ts]); + + $superuser = User::factory()->viewAssets()->create(); + + // is:ExactNote{ts} should only return the exact match, not the partial one. + $response = $this->actingAsForApi($superuser) + ->getJson(route('api.assets.index', ['filter' => json_encode(['notes' => 'is:ExactNote'.$ts])])) + ->assertOk(); + + $returnedIds = collect($response->json('rows'))->pluck('id')->map(fn ($id) => (int) $id)->all(); + + $this->assertContains((int) $exact->id, $returnedIds); + $this->assertNotContains((int) $partial->id, $returnedIds); + } + + /** + * "is:{value}" on a relation key should match records where the related model's + * column equals the value exactly. + */ + public function test_exact_match_filter_on_relation() + { + $ts = now()->timestamp; + + $exactSupplier = Supplier::factory()->create(['name' => 'ExactSupplier'.$ts]); + $partialSupplier = Supplier::factory()->create(['name' => 'ExactSupplier'.$ts.'Extra']); + + $exactAsset = Asset::factory()->create(['supplier_id' => $exactSupplier->id]); + $partialAsset = Asset::factory()->create(['supplier_id' => $partialSupplier->id]); + + $superuser = User::factory()->viewAssets()->create(); + + $response = $this->actingAsForApi($superuser) + ->getJson(route('api.assets.index', [ + 'filter' => json_encode(['supplier' => 'is:ExactSupplier'.$ts]), + ])) + ->assertOk(); + + $returnedIds = collect($response->json('rows'))->pluck('id')->map(fn ($id) => (int) $id)->all(); + + $this->assertContains((int) $exactAsset->id, $returnedIds); + $this->assertNotContains((int) $partialAsset->id, $returnedIds); + } + + /** + * "is:{value}" on the User virtual "name" column should match only users whose + * CONCAT(first_name, ' ', last_name) equals the value exactly. + */ + public function test_exact_match_filter_on_virtual_name_column() + { + $ts = now()->timestamp; + + $exactUser = User::factory()->create(['first_name' => 'John'.$ts, 'last_name' => 'Smith'.$ts]); + $partialUser = User::factory()->create(['first_name' => 'John'.$ts, 'last_name' => 'Smithson'.$ts]); + + $response = $this->actingAsForApi(User::factory()->superuser()->create()) + ->getJson(route('api.users.index', [ + 'filter' => json_encode(['name' => 'is:John'.$ts.' Smith'.$ts]), + ])) + ->assertOk(); + + $returnedIds = collect($response->json('rows'))->pluck('id')->map(fn ($id) => (int) $id)->all(); + + $this->assertContains((int) $exactUser->id, $returnedIds); + $this->assertNotContains((int) $partialUser->id, $returnedIds); + } + + /** + * Confirm that the reserved "is:null" and "is:not_null" tokens are still honoured + * as null checks even after adding generic "is:{value}" exact-match support. + */ + public function test_is_null_tokens_still_work_after_exact_match_addition() + { + $withNotes = Asset::factory()->create(['notes' => 'SomeValue'.now()->timestamp]); + $withoutNotes = Asset::factory()->create(['notes' => null]); + + $superuser = User::factory()->viewAssets()->create(); + + // is:null should still mean IS NULL, not exact match on the string "null". + $response = $this->actingAsForApi($superuser) + ->getJson(route('api.assets.index', ['filter' => json_encode(['notes' => 'is:null'])])) + ->assertOk(); + + $returnedIds = collect($response->json('rows'))->pluck('id')->map(fn ($id) => (int) $id)->all(); + + $this->assertContains((int) $withoutNotes->id, $returnedIds); + $this->assertNotContains((int) $withNotes->id, $returnedIds); + } + + /** + * Test negation filter using "!" prefix on a direct attribute. + * filter={"name":"!Dell"} should return all assets whose name does NOT contain "Dell". + */ + public function test_negation_filter_with_bang_prefix_on_attribute() + { + Asset::factory()->create(['name' => 'MacBook Pro', 'asset_tag' => 'NEG-001']); + Asset::factory()->create(['name' => 'Dell XPS 13', 'asset_tag' => 'NEG-002']); + Asset::factory()->create(['name' => 'HP Pavilion', 'asset_tag' => 'NEG-003']); + + $this->actingAsForApi(User::factory()->viewAssets()->create()) + ->getJson(route('api.assets.index', [ + 'filter' => json_encode(['name' => '!Dell']), + ])) + ->assertOk() + ->assertJson(fn (AssertableJson $json) => $json->has('rows', 2)->etc()); + } + + /** + * Test negation filter using "not:" prefix on a direct attribute. + * filter={"name":"not:Dell"} should behave identically to "!Dell". + */ + public function test_negation_filter_with_not_prefix_on_attribute() + { + Asset::factory()->create(['name' => 'MacBook Pro', 'asset_tag' => 'NOTP-001']); + Asset::factory()->create(['name' => 'Dell XPS 13', 'asset_tag' => 'NOTP-002']); + Asset::factory()->create(['name' => 'HP Pavilion', 'asset_tag' => 'NOTP-003']); + + $this->actingAsForApi(User::factory()->viewAssets()->create()) + ->getJson(route('api.assets.index', [ + 'filter' => json_encode(['name' => 'not:Dell']), + ])) + ->assertOk() + ->assertJson(fn (AssertableJson $json) => $json->has('rows', 2)->etc()); + } + + /** + * Test that combining a positive filter and a negation filter works correctly. + * filter={"asset_tag":"COMBO","name":"!Dell"} should return assets tagged COMBO that + * are NOT named Dell. + */ + public function test_combined_positive_and_negation_filters() + { + Asset::factory()->create(['name' => 'MacBook Pro', 'asset_tag' => 'COMBO-001']); + Asset::factory()->create(['name' => 'Dell XPS 13', 'asset_tag' => 'COMBO-002']); + Asset::factory()->create(['name' => 'HP Pavilion', 'asset_tag' => 'OTHER-001']); + + $this->actingAsForApi(User::factory()->viewAssets()->create()) + ->getJson(route('api.assets.index', [ + 'filter' => json_encode(['asset_tag' => 'COMBO', 'name' => '!Dell']), + ])) + ->assertOk() + ->assertJson(fn (AssertableJson $json) => $json->has('rows', 1)->etc()); + } + + /** + * Test negation filter on a relation attribute. + * filter={"manufacturer":"!Apple"} should return assets whose manufacturer does NOT + * contain "Apple". + */ + public function test_negation_filter_on_relation() + { + $apple = Manufacturer::factory()->create(['name' => 'Apple']); + $dell = Manufacturer::factory()->create(['name' => 'Dell']); + + $appleModel = AssetModel::factory()->create(['manufacturer_id' => $apple->id]); + $dellModel = AssetModel::factory()->create(['manufacturer_id' => $dell->id]); + + Asset::factory()->create(['model_id' => $appleModel->id, 'asset_tag' => 'REL-001']); + Asset::factory()->create(['model_id' => $dellModel->id, 'asset_tag' => 'REL-002']); + + $this->actingAsForApi(User::factory()->viewAssets()->create()) + ->getJson(route('api.assets.index', [ + 'filter' => json_encode(['manufacturer' => '!Apple']), + ])) + ->assertOk() + ->assertJson(fn (AssertableJson $json) => $json->has('rows', 1)->etc()); + } + + /** + * Test negation filter on a custom field. + * filter={"_snipeit_cpu_X":"!Intel"} should return assets where the CPU field + * does NOT contain "Intel". + */ + public function test_negation_filter_on_custom_field() + { + $field = CustomField::factory()->cpu()->create(); + $dbColumn = $field->db_column_name(); + + Asset::factory()->create([$dbColumn => '3.2GHz Intel Core i9', 'asset_tag' => 'CF-001']); + Asset::factory()->create([$dbColumn => '2.4GHz AMD Ryzen 7', 'asset_tag' => 'CF-002']); + + Asset::flushCustomFieldFilterMap(); + + $this->actingAsForApi(User::factory()->viewAssets()->create()) + ->getJson(route('api.assets.index', [ + 'filter' => json_encode([$dbColumn => '!Intel']), + ])) + ->assertOk() + ->assertJson(fn (AssertableJson $json) => $json->has('rows', 1)->etc()); + } + + /** + * Negation filter "!blah" on the "location" relation key for Assets + * should exclude assets with a location name containing "blah". + */ + public function test_negation_filter_on_asset_location_relation() + { + $ts = now()->timestamp; + + $blahLocation = Location::factory()->create(['name' => 'Blah Office '.$ts]); + $safeLocation = Location::factory()->create(['name' => 'Safe Office '.$ts]); + + $blahAsset = Asset::factory()->create(['location_id' => $blahLocation->id]); + $safeAsset = Asset::factory()->create(['location_id' => $safeLocation->id]); + + $response = $this->actingAsForApi(User::factory()->viewAssets()->create()) + ->getJson(route('api.assets.index', [ + 'filter' => json_encode(['location' => '!Blah']), + ])) + ->assertOk(); + + $returnedIds = collect($response->json('rows'))->pluck('id')->map(fn ($id) => (int) $id)->all(); + + // Asset in the "blah" location must NOT appear. + $this->assertNotContains((int) $blahAsset->id, $returnedIds); + // Asset in a different location MUST appear. + $this->assertContains((int) $safeAsset->id, $returnedIds); + } + + /** + * Negation filter "!blah" on the "location" relation key for Users + * should exclude users whose location name contains "blah". + * + * The User model stores location via the "userloc" Eloquent relation + * (not "location"), so a "location" → "userloc" alias must be registered. + */ + public function test_negation_filter_on_user_location_relation() + { + $ts = now()->timestamp; + + $blahLocation = Location::factory()->create([ + 'name' => 'Blah Floor '.$ts, + 'address' => 'Safe Address '.$ts, + ]); + $safeLocation = Location::factory()->create([ + 'name' => 'Safe Floor '.$ts, + // Regression guard: structured filter on "location" should not inspect address. + 'address' => 'Blah Address '.$ts, + ]); + + $blahUser = User::factory()->create(['location_id' => $blahLocation->id]); + $safeUser = User::factory()->create(['location_id' => $safeLocation->id]); + $nullLocationUser = User::factory()->create(['location_id' => null]); + + $response = $this->actingAsForApi(User::factory()->superuser()->create()) + ->getJson(route('api.users.index', [ + 'filter' => json_encode(['location' => '!Blah']), + ])) + ->assertOk(); + + $returnedIds = collect($response->json('rows'))->pluck('id')->map(fn ($id) => (int) $id)->all(); + + // The user in the "blah" location must NOT appear. + $this->assertNotContains((int) $blahUser->id, $returnedIds); + // The user in a safe location MUST appear. + $this->assertContains((int) $safeUser->id, $returnedIds); + // Users with no location should also be included for negated filters. + $this->assertContains((int) $nullLocationUser->id, $returnedIds); + } + + /** + * Regression: structured AND filter should honor model_number and location together. + */ + public function test_asset_structured_filter_and_operator_with_model_number_and_location() + { + $locationA = Location::factory()->create(['name' => 'HQ-East']); + $locationB = Location::factory()->create(['name' => 'HQ-West']); + $manufacturer = Manufacturer::factory()->create(['name' => 'FilterCo']); + + $modelMatch = AssetModel::factory()->create([ + 'manufacturer_id' => $manufacturer->id, + 'model_number' => 'MODEL-111', + ]); + + $modelOther = AssetModel::factory()->create([ + 'manufacturer_id' => $manufacturer->id, + 'model_number' => 'MODEL-222', + ]); + + // ✅ Matches both model_number and location. + Asset::factory()->create([ + 'asset_tag' => 'AND-MATCH-1', + 'model_id' => $modelMatch->id, + 'location_id' => $locationA->id, + ]); + + // ❌ Matches location only. + Asset::factory()->create([ + 'asset_tag' => 'AND-LOC-ONLY', + 'model_id' => $modelOther->id, + 'location_id' => $locationA->id, + ]); + + // ❌ Matches model_number only. + Asset::factory()->create([ + 'asset_tag' => 'AND-MODEL-ONLY', + 'model_id' => $modelMatch->id, + 'location_id' => $locationB->id, + ]); + + $this->actingAsForApi(User::factory()->viewAssets()->create()) + ->getJson(route('api.assets.index', [ + 'filter' => json_encode([ + 'model_number' => 'MODEL-111', + 'location' => 'HQ-East', + ]), + 'filter_operator' => 'and', + ])) + ->assertOk() + ->assertJson(fn (AssertableJson $json) => $json->has('rows', 1)->etc()); + } } diff --git a/tests/Feature/Users/Ui/IndexUsersTest.php b/tests/Feature/Users/Ui/IndexUsersTest.php index fea2b3d81c..58d0c9c5bb 100644 --- a/tests/Feature/Users/Ui/IndexUsersTest.php +++ b/tests/Feature/Users/Ui/IndexUsersTest.php @@ -20,4 +20,15 @@ class IndexUsersTest extends TestCase ->get(route('users.index')) ->assertOk(); } + + public function test_page_renders_with_array_query_inputs() + { + $this->actingAs(User::factory()->viewUsers()->create()) + ->get(route('users.index', [ + 'manager_id' => [1], + 'company_id' => [1], + 'status' => ['deleted'], + ])) + ->assertOk(); + } }