Compare commits

...

51 Commits

Author SHA1 Message Date
snipe b540a5afc0 Fixed #19006 - update null location 2026-05-14 15:58:10 +01:00
snipe 663bab1f9d Merge pull request #19030 from grokability/purchase-cost-fix
Fixed #19029 - switch back to text for purchase cost
2026-05-14 15:56:25 +01:00
snipe 8b1e312292 Normalize purchase_cost 2026-05-14 15:33:42 +01:00
snipe 9004211a59 Added text string 2026-05-14 15:04:48 +01:00
snipe 053eb91457 Update bulk asset controller 2026-05-14 15:03:55 +01:00
snipe 3810513224 Updated HTML fields back to text from number 2026-05-14 14:30:09 +01:00
snipe e50e0f0e34 Updated tests 2026-05-14 14:29:51 +01:00
snipe cdf73f9c89 Merge pull request #19028 from grokability/move-and-rename-trait
Move and rename CheckInOutRequest to CheckInOutTrait
2026-05-14 13:13:50 +01:00
snipe bc808cbe46 Pint 2026-05-14 12:50:31 +01:00
snipe fdc65fb1b2 Moved and renamed CheckInOutRequest 2026-05-14 12:50:23 +01:00
snipe 3db9a15dd3 Merge pull request #19027 from grokability/add-fields-to-user-export
Fixed #18659 - added more data to user export
2026-05-14 12:32:24 +01:00
snipe 42bf43d68d Fixed #18659 - added more data to user export 2026-05-14 12:24:08 +01:00
snipe 4548ed8a45 Fixed flappy test 2026-05-14 11:45:20 +01:00
snipe 407e2d0246 Merge pull request #19026 from grokability/scim-typo
Fixed SCIMConfig typo "string_starts_with"
2026-05-14 11:12:52 +01:00
snipe 2af7367480 Pint 2026-05-14 11:00:58 +01:00
snipe 29b9a78f54 Fixed typo “string_starts_with” 2026-05-14 11:00:25 +01:00
snipe 382a164b9d Updated composer.lock 2026-05-14 10:40:41 +01:00
snipe 9216a7550f Merge pull request #19019 from grokability/adding-normal-pagination-to-api-responses
Added page number support in API, added `per_page`, `total_pages`, and `current_page` to API response
2026-05-13 22:16:40 +01:00
snipe 4f9ba7c6cc Updated to use fullUrlWithQuery 2026-05-13 22:08:27 +01:00
snipe afb7c69ac3 Merge pull request #19021 from marcusmoore/fixes/21081-missing-asset-in-maintenance
Fixed asset maintenances page not rendering with missing asset
2026-05-13 22:00:24 +01:00
snipe 5f232c0584 Merge pull request #19024 from grokability/api-disallow-own-priv-changes
Tighten permission changes and UI, fixed #18831
2026-05-13 21:59:15 +01:00
snipe ed931d497a Hide admin/superadmin sections if the user is not an admin/superadmin 2026-05-13 21:45:56 +01:00
snipe 59278c3f70 Missed a spot 2026-05-13 21:39:46 +01:00
snipe 179d031bb2 Fixed #18831 - better spacing for buttons 2026-05-13 21:32:36 +01:00
snipe dc1410aa70 Tweaked logic around messaging 2026-05-13 21:27:25 +01:00
snipe 0f595a8854 Updated preserve permissions action 2026-05-13 21:06:04 +01:00
snipe 70e1dcf1b4 Disallow self-editing of privs on user model 2026-05-13 20:06:51 +01:00
snipe 780e3e1cd9 Merge pull request #19023 from marcusmoore/fixes/users-export
Fixed duplicate headers in users csv export
2026-05-13 19:41:55 +01:00
Marcus Moore 339c93ebbf Add documentation for assertion 2026-05-13 11:27:26 -07:00
Marcus Moore b4bb1556be Add assertions 2026-05-13 11:26:04 -07:00
Marcus Moore ba96aa5a61 Write assertion implementation 2026-05-13 11:25:52 -07:00
snipe 2171556ec4 Merge pull request #19022 from marcusmoore/fixes/disable-debugbar-for-unaccepted-assets-report
Disabled debugbar on acceptance report
2026-05-13 19:18:53 +01:00
Marcus Moore 2d33368063 WIP: better assertions 2026-05-13 11:10:45 -07:00
Marcus Moore 93a2f74f9e Disable debugbar on acceptance report 2026-05-13 10:46:58 -07:00
Marcus Moore ee61084ac8 Ensure maintenance has asset before attempting to render 2026-05-13 10:11:50 -07:00
snipe 8d8a1889cd Drop the limit from the next/prev links unless they were specifically sent in the URL parameters 2026-05-13 17:17:14 +01:00
snipe f275cb6928 Pint 2026-05-13 17:10:41 +01:00
snipe db8de1f794 Codacy fixes 2026-05-13 17:10:34 +01:00
snipe d901e821cc Include next and previous urls in the payload 2026-05-13 16:55:55 +01:00
snipe 34a533b2d6 Added page number support in API, added per_page, total_pages, and current_page 2026-05-13 16:48:39 +01:00
snipe d3d37c70ab Merge pull request #19017 from grokability/FD-52058-importer-updated-at-timestamp
Fixed importer `created_at` timestamp getting weird on large imports
2026-05-13 12:18:01 +01:00
snipe 475e674fc6 Bumped laravel version in README 2026-05-13 12:17:44 +01:00
snipe 01436d0532 Import the model in test 2026-05-13 12:06:39 +01:00
snipe 96bf7d0c2b Fixed FD-52058 - Importer using wrong created_at date 2026-05-13 12:06:13 +01:00
snipe 529973aa77 Updated EULA PDF filename to include username 2026-05-13 10:32:17 +01:00
snipe f4cd090ac6 Merge pull request #19012 from marcusmoore/fixes/activity-report-link
Fixed anchor tag on report index page
2026-05-13 02:41:35 +01:00
Marcus Moore 6d5e68274d Only write headers once 2026-05-12 16:36:26 -07:00
Marcus Moore 3e002cb940 Implement test and remove trailing comma in group column 2026-05-12 16:33:53 -07:00
Marcus Moore b7ea9a959c Scaffold some tests 2026-05-12 16:25:53 -07:00
Marcus Moore dc3a16c437 Update test macros 2026-05-12 16:25:00 -07:00
Marcus Moore 608af84253 Fix anchor tag 2026-05-12 15:13:29 -07:00
59 changed files with 1590 additions and 261 deletions
+1 -1
View File
@@ -7,7 +7,7 @@
This is a FOSS project for asset management in IT Operations. Knowing who has which laptop, when it was purchased in order to depreciate it correctly, handling software licenses, etc.
It is built on [Laravel 11](http://laravel.com).
It is built on [Laravel 12](http://laravel.com).
Snipe-IT is actively developed and we [release quite frequently](https://github.com/grokability/snipe-it/releases). ([Check out the live demo here](https://snipeitapp.com/demo/).)
@@ -13,8 +13,13 @@ final class PreserveUnauthorizedPrivilegedPermissionsAction
* @param array<string, mixed> $originalPermissions
* @return array<string, mixed>
*/
public static function run(array $requestedPermissions, User $authenticatedUser, array $originalPermissions = []): array
public static function run(array $requestedPermissions, User $authenticatedUser, array $originalPermissions = [], ?User $targetUser = null): array
{
// Disallow non-admin/superuser users from modifying their own permissions, but allow them to modify other users' permissions (except for admin/superuser keys).
if ($targetUser && ! $authenticatedUser->isSuperUser() && $authenticatedUser->id === $targetUser->id) {
return $originalPermissions;
}
if (! $authenticatedUser->isSuperUser()) {
if (array_key_exists('superuser', $originalPermissions)) {
$requestedPermissions['superuser'] = $originalPermissions['superuser'];
+3
View File
@@ -78,6 +78,7 @@ class IconHelper
case 'angle-right':
return 'fas fa-angle-right';
case 'warning':
case 'alert':
return 'fas fa-exclamation-triangle';
case 'kits':
return 'fas fa-object-group';
@@ -126,6 +127,7 @@ class IconHelper
case 'dashboard':
return 'fas fa-tachometer-alt';
case 'info-circle':
case 'info':
return 'fas fa-info-circle';
case 'caret-right':
return 'fa fa-caret-right';
@@ -156,6 +158,7 @@ class IconHelper
case 'remote':
return 'fa-solid fa-house-laptop';
case 'more-info':
case 'help':
case 'support':
return 'far fa-life-ring';
case 'plus':
@@ -4,9 +4,9 @@ namespace App\Http\Controllers\Accessories;
use App\Events\CheckoutableCheckedOut;
use App\Helpers\Helper;
use App\Http\Controllers\CheckInOutRequest;
use App\Http\Controllers\Controller;
use App\Http\Requests\AccessoryCheckoutRequest;
use App\Http\Traits\CheckInOutTrait;
use App\Models\Accessory;
use App\Models\AccessoryCheckout;
use App\Models\CheckoutAcceptance;
@@ -18,7 +18,7 @@ use Illuminate\Http\Request;
class AccessoryCheckoutController extends Controller
{
use CheckInOutRequest;
use CheckInOutTrait;
/**
* Return the form to checkout an Accessory to a user.
@@ -149,6 +149,9 @@ class AcceptanceController extends Controller
$item = $acceptance->checkoutable_type::find($acceptance->checkoutable_id);
$username_slug = Str::slug($assignedUser->username);
$asset_tag_slug = ($item instanceof Asset && $item->asset_tag) ? '-'.Str::slug($item->asset_tag) : '';
// If signatures are required, make sure we have one
if ($requiresSignature) {
@@ -234,7 +237,7 @@ class AcceptanceController extends Controller
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';
$pdf_filename = 'accepted-'.$username_slug.$asset_tag_slug.'-'.date('Y-m-d-h-i-s').'.pdf';
// Generate the PDF content
$pdf_content = $acceptance->generateAcceptancePdf($data, $acceptance);
@@ -4,11 +4,11 @@ namespace App\Http\Controllers\Api;
use App\Events\CheckoutableCheckedOut;
use App\Helpers\Helper;
use App\Http\Controllers\CheckInOutRequest;
use App\Http\Controllers\Controller;
use App\Http\Requests\AccessoryCheckoutRequest;
use App\Http\Requests\ImageUploadRequest;
use App\Http\Requests\StoreAccessoryRequest;
use App\Http\Traits\CheckInOutTrait;
use App\Http\Transformers\AccessoriesTransformer;
use App\Http\Transformers\ActionlogsTransformer;
use App\Http\Transformers\SelectlistTransformer;
@@ -25,7 +25,7 @@ use Illuminate\Support\Facades\DB;
class AccessoriesController extends Controller
{
use CheckInOutRequest;
use CheckInOutTrait;
/**
* Display a listing of the resource.
@@ -38,6 +38,7 @@ class MaintenancesController extends Controller
$this->authorize('view', Asset::class);
$maintenances = Maintenance::select('maintenances.*')
->whereHas('asset')
->with('asset', 'asset.model', 'asset.location', 'asset.defaultLoc', 'supplier', 'asset.company', 'asset.status', 'adminuser', 'asset.assignedTo');
// This invokes the Searchable model trait scopeTextSearch and will handle input by search or by advanced search filter
@@ -569,6 +569,7 @@ class UsersController extends Controller
requestedPermissions: NormalizePermissionsPayloadAction::run($request->input('permissions')),
authenticatedUser: $authenticatedUser,
originalPermissions: NormalizePermissionsPayloadAction::run($user->decodePermissions()),
targetUser: $user,
));
}
@@ -133,14 +133,17 @@ class AssetCheckinController extends Controller
$this->migrateLegacyLocations($asset);
$asset->location_id = $asset->rtd_location_id;
if ($request->filled('location_id')) {
Log::debug('NEW Location ID: '.$request->input('location_id'));
$asset->location_id = $request->input('location_id');
if ($request->has('location_id')) {
if ($request->filled('location_id')) {
Log::debug('NEW Location ID: ' . $request->input('location_id'));
$asset->location_id = $request->input('location_id');
if ($request->input('update_default_location') == 0) {
$asset->rtd_location_id = $request->input('location_id');
if ($request->input('update_default_location') == 0) {
$asset->rtd_location_id = $request->input('location_id');
}
} else {
$asset->location_id = null;
}
}
@@ -4,9 +4,9 @@ namespace App\Http\Controllers\Assets;
use App\Exceptions\CheckoutNotAllowed;
use App\Helpers\Helper;
use App\Http\Controllers\CheckInOutRequest;
use App\Http\Controllers\Controller;
use App\Http\Requests\AssetCheckoutRequest;
use App\Http\Traits\CheckInOutTrait;
use App\Models\Asset;
use App\Models\CheckoutAcceptance;
use App\Models\Setting;
@@ -17,7 +17,7 @@ use Illuminate\Http\RedirectResponse;
class AssetCheckoutController extends Controller
{
use CheckInOutRequest;
use CheckInOutTrait;
/**
* Returns a view that presents a form to check an asset out to a
@@ -4,9 +4,9 @@ namespace App\Http\Controllers\Assets;
use App\Events\CheckoutablesCheckedOutInBulk;
use App\Helpers\Helper;
use App\Http\Controllers\CheckInOutRequest;
use App\Http\Controllers\Controller;
use App\Http\Requests\AssetCheckoutRequest;
use App\Http\Traits\CheckInOutTrait;
use App\Models\Asset;
use App\Models\AssetModel;
use App\Models\Company;
@@ -27,7 +27,7 @@ use Illuminate\Support\Facades\Log;
class BulkAssetsController extends Controller
{
use CheckInOutRequest;
use CheckInOutTrait;
/**
* Display the bulk edit page.
@@ -4,7 +4,8 @@ namespace App\Http\Controllers\Components;
use App\Helpers\Helper;
use App\Http\Controllers\Controller;
use App\Http\Requests\ImageUploadRequest;
use App\Http\Requests\StoreComponentRequest;
use App\Http\Requests\UpdateComponentRequest;
use App\Models\Company;
use App\Models\Component;
use Illuminate\Auth\Access\AuthorizationException;
@@ -12,7 +13,6 @@ use Illuminate\Contracts\View\View;
use Illuminate\Http\RedirectResponse;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Facades\Storage;
use Illuminate\Support\Facades\Validator;
/**
* This class controls all actions related to Components for
@@ -74,7 +74,7 @@ class ComponentsController extends Controller
*
* @throws AuthorizationException
*/
public function store(ImageUploadRequest $request)
public function store(StoreComponentRequest $request)
{
$this->authorize('create', Component::class);
$component = new Component;
@@ -148,21 +148,10 @@ class ComponentsController extends Controller
*
* @since [v3.0]
*/
public function update(ImageUploadRequest $request, Component $component)
public function update(UpdateComponentRequest $request, Component $component)
{
$min = $component->numCheckedOut();
$validator = Validator::make($request->all(), [
'qty' => "required|numeric|min:$min",
]);
if ($validator->fails()) {
return redirect()->back()
->withErrors($validator)
->withInput();
}
$this->authorize('update', $component);
// Update the component data
$component->name = $request->input('name');
$component->category_id = $request->input('category_id');
@@ -2,8 +2,8 @@
namespace App\Http\Controllers\Kits;
use App\Http\Controllers\CheckInOutRequest;
use App\Http\Controllers\Controller;
use App\Http\Traits\CheckInOutTrait;
use App\Models\Asset;
use App\Models\PredefinedKit;
use App\Models\User;
@@ -23,7 +23,7 @@ class CheckoutKitController extends Controller
{
public $kitService;
use CheckInOutRequest;
use CheckInOutTrait;
public function __construct(PredefinedKitCheckoutService $kitService)
{
@@ -1236,6 +1236,9 @@ class ReportsController extends Controller
public function getAssetAcceptanceReport($deleted = false): View
{
$this->authorize('reports.view');
$this->disableDebugbar();
$showDeleted = $deleted == 'deleted';
$query = CheckoutAcceptance::Pending()
+85 -37
View File
@@ -315,6 +315,7 @@ class UsersController extends Controller
requestedPermissions: NormalizePermissionsPayloadAction::run($request->input('permission')),
authenticatedUser: $authenticatedUser,
originalPermissions: $orig_permissions_array,
targetUser: $user,
));
// Only save groups if the user is a superuser
@@ -534,54 +535,76 @@ class UsersController extends Controller
// Open output stream
$handle = fopen('php://output', 'w');
$headers = [
// strtolower to prevent Excel from trying to open it as a SYLK file
strtolower(trans('general.id')),
trans('admin/companies/table.title'),
trans('admin/users/table.title'),
trans('general.employee_number'),
trans('admin/users/table.first_name'),
trans('admin/users/table.last_name'),
trans('admin/users/table.name'),
trans('admin/users/table.display_name'),
trans('admin/users/table.username'),
trans('admin/users/table.email'),
trans('admin/users/table.phone'),
trans('admin/users/table.mobile'),
trans('general.website'),
trans('general.address'),
trans('general.city'),
trans('general.state'),
trans('general.country'),
trans('general.zip'),
trans('admin/users/table.manager'),
trans('admin/users/table.location'),
trans('general.department'),
trans('general.assets'),
trans('general.licenses'),
trans('general.accessories'),
trans('general.consumables'),
trans('general.groups'),
trans('general.permissions'),
trans('general.notes'),
trans('admin/users/table.activated'),
trans('general.created_at'),
trans('general.importer.vip'),
trans('admin/users/general.remote'),
trans('general.language'),
trans('general.autoassign_licenses'),
trans('general.ldap_sync'),
trans('admin/users/general.two_factor_enrolled'),
trans('admin/users/general.two_factor_active'),
trans('admin/users/table.managed_users'),
trans('admin/users/table.managed_locations'),
trans('admin/users/general.department_manager'),
trans('general.created_by'),
trans('general.updated_at'),
trans('general.start_date'),
trans('general.end_date'),
trans('admin/users/table.last_login'),
trans('admin/licenses/table.deleted_at'),
];
fputcsv($handle, $headers);
$users = User::with(
'assets',
'accessories',
'consumables',
'department',
'department.manager',
'licenses',
'manager',
'groups',
'userloc',
'company'
)->orderBy('created_at', 'DESC')
'company',
'createdBy'
)->withCount(['managesUsers as manages_users_count', 'managedLocations as manages_locations_count'])
->orderBy('created_at', 'DESC')
->chunk(500, function ($users) use ($handle) {
$headers = [
// strtolower to prevent Excel from trying to open it as a SYLK file
strtolower(trans('general.id')),
trans('admin/companies/table.title'),
trans('admin/users/table.title'),
trans('general.employee_number'),
trans('admin/users/table.first_name'),
trans('admin/users/table.last_name'),
trans('admin/users/table.name'),
trans('admin/users/table.username'),
trans('admin/users/table.email'),
trans('admin/users/table.manager'),
trans('admin/users/table.location'),
trans('general.department'),
trans('general.assets'),
trans('general.licenses'),
trans('general.accessories'),
trans('general.consumables'),
trans('general.groups'),
trans('general.permissions'),
trans('general.notes'),
trans('admin/users/table.activated'),
trans('general.created_at'),
];
fputcsv($handle, $headers);
$formatter = new EscapeFormula('`');
foreach ($users as $user) {
$user_groups = '';
foreach ($user->groups as $user_group) {
$user_groups .= $user_group->name.', ';
}
$permissionstring = '';
if ($user->isSuperUser()) {
@@ -600,9 +623,18 @@ class UsersController extends Controller
$user->employee_num,
$user->first_name,
$user->last_name,
$user->display_name,
$user->getFullNameAttribute(),
$user->getRawOriginal('display_name'),
$user->username,
$user->email,
$user->phone,
$user->mobile,
$user->website,
$user->address,
$user->city,
$user->state,
$user->country,
$user->zip,
($user->manager) ? $user->manager->display_name : '',
($user->userloc) ? $user->userloc->name : '',
($user->department) ? $user->department->name : '',
@@ -610,11 +642,27 @@ class UsersController extends Controller
$user->licenses->count(),
$user->accessories->count(),
$user->consumables->count(),
$user_groups,
$user->groups->pluck('name')->implode(', '),
$permissionstring,
$user->notes,
($user->activated == '1') ? trans('general.yes') : trans('general.no'),
$user->created_at,
($user->vip == '1') ? trans('general.yes') : trans('general.no'),
($user->remote == '1') ? trans('general.yes') : trans('general.no'),
$user->locale,
($user->autoassign_licenses == '1') ? trans('general.yes') : trans('general.no'),
($user->ldap_import == '1') ? trans('general.yes') : trans('general.no'),
($user->two_factor_active_and_enrolled()) ? trans('general.yes') : trans('general.no'),
($user->two_factor_active()) ? trans('general.yes') : trans('general.no'),
$user->manages_users_count,
$user->manages_locations_count,
($user->department && $user->department->manager) ? $user->department->manager->display_name : '',
($user->createdBy) ? $user->createdBy->display_name : '',
$user->updated_at,
$user->start_date,
$user->end_date,
$user->last_login,
$user->deleted_at,
];
// CSV_ESCAPE_FORMULAS is set to false in the .env
@@ -2,6 +2,7 @@
namespace App\Http\Requests;
use App\Helpers\Helper;
use App\Http\Requests\Traits\MayContainCustomFields;
use App\Models\Asset;
use App\Models\AssetModel;
@@ -26,6 +27,10 @@ class CreateMultipleAssetRequest extends ImageUploadRequest // should I extend f
{
parent::prepareForValidation();
if ($this->filled('purchase_cost') && ! is_float($this->input('purchase_cost')) && preg_match('/^[\d.,]+$/', (string) $this->input('purchase_cost'))) {
$this->merge(['purchase_cost' => Helper::ParseCurrency($this->input('purchase_cost'))]);
}
if (Setting::getSettings()->full_multiple_companies_support == '1' && ! $this->user()->isSuperUser()) {
$this->mergeIfMissing(['company_id' => $this->user()->company_id]);
}
+5 -1
View File
@@ -2,6 +2,7 @@
namespace App\Http\Requests;
use App\Helpers\Helper;
use App\Models\Accessory;
use App\Models\Category;
use Illuminate\Contracts\Validation\ValidationRule;
@@ -21,6 +22,10 @@ class StoreAccessoryRequest extends ImageUploadRequest
{
parent::prepareForValidation();
if ($this->filled('purchase_cost') && ! is_float($this->input('purchase_cost')) && preg_match('/^[\d.,]+$/', (string) $this->input('purchase_cost'))) {
$this->merge(['purchase_cost' => Helper::ParseCurrency($this->input('purchase_cost'))]);
}
if ($this->category_id) {
if ($category = Category::find($this->category_id)) {
$this->merge([
@@ -28,7 +33,6 @@ class StoreAccessoryRequest extends ImageUploadRequest
]);
}
}
}
/**
+4 -26
View File
@@ -2,10 +2,10 @@
namespace App\Http\Requests;
use App\Helpers\Helper;
use App\Http\Requests\Traits\MayContainCustomFields;
use App\Models\Asset;
use App\Models\Company;
use App\Models\Setting;
use App\Rules\AssetCannotBeCheckedOutToNondeployableStatus;
use Carbon\Carbon;
use Carbon\Exceptions\InvalidFormatException;
@@ -39,6 +39,9 @@ class StoreAssetRequest extends ImageUploadRequest
$this->merge([
'asset_tag' => $this->asset_tag ?? Asset::autoincrement_asset(),
'company_id' => $idForCurrentUser,
'purchase_cost' => $this->filled('purchase_cost') && ! is_float($this->input('purchase_cost')) && preg_match('/^[\d.,]+$/', (string) $this->input('purchase_cost'))
? Helper::ParseCurrency($this->input('purchase_cost'))
: $this->input('purchase_cost'),
]);
}
@@ -49,15 +52,6 @@ class StoreAssetRequest extends ImageUploadRequest
{
$modelRules = (new Asset)->getRules();
if (Setting::getSettings()->digit_separator === '1.234,56' && is_string($this->input('purchase_cost'))) {
// If purchase_cost was submitted as a string with a comma separator
// then we need to ignore the normal numeric rules.
// Since the original rules still live on the model they will be run
// right before saving (and after purchase_cost has been
// converted to a float via setPurchaseCostAttribute).
$modelRules = $this->removeNumericRulesFromPurchaseCost($modelRules);
}
return array_merge(
$modelRules,
['status_id' => [new AssetCannotBeCheckedOutToNondeployableStatus]],
@@ -81,20 +75,4 @@ class StoreAssetRequest extends ImageUploadRequest
}
}
}
private function removeNumericRulesFromPurchaseCost(array $rules): array
{
$purchaseCost = $rules['purchase_cost'];
// If rule is in "|" format then turn it into an array
if (is_string($purchaseCost)) {
$purchaseCost = explode('|', $purchaseCost);
}
$rules['purchase_cost'] = array_filter($purchaseCost, function ($rule) {
return $rule !== 'numeric' && $rule !== 'gte:0';
});
return $rules;
}
}
@@ -0,0 +1,27 @@
<?php
namespace App\Http\Requests;
use App\Helpers\Helper;
use App\Models\Component;
use Illuminate\Support\Facades\Gate;
class StoreComponentRequest extends ImageUploadRequest
{
public function authorize(): bool
{
return Gate::allows('create', Component::class);
}
public function prepareForValidation(): void
{
if ($this->filled('purchase_cost') && ! is_float($this->input('purchase_cost')) && preg_match('/^[\d.,]+$/', (string) $this->input('purchase_cost'))) {
$this->merge(['purchase_cost' => Helper::ParseCurrency($this->input('purchase_cost'))]);
}
}
public function response(array $errors)
{
return $this->redirector->back()->withInput()->withErrors($errors, $this->errorBag);
}
}
+5 -1
View File
@@ -2,6 +2,7 @@
namespace App\Http\Requests;
use App\Helpers\Helper;
use App\Models\Category;
use App\Models\Consumable;
use Illuminate\Contracts\Validation\ValidationRule;
@@ -21,6 +22,10 @@ class StoreConsumableRequest extends ImageUploadRequest
{
parent::prepareForValidation();
if ($this->filled('purchase_cost') && ! is_float($this->input('purchase_cost')) && preg_match('/^[\d.,]+$/', (string) $this->input('purchase_cost'))) {
$this->merge(['purchase_cost' => Helper::ParseCurrency($this->input('purchase_cost'))]);
}
if ($this->category_id) {
if ($category = Category::find($this->category_id)) {
$this->merge([
@@ -28,7 +33,6 @@ class StoreConsumableRequest extends ImageUploadRequest
]);
}
}
}
/**
+8 -6
View File
@@ -2,6 +2,7 @@
namespace App\Http\Requests;
use App\Helpers\Helper;
use App\Http\Requests\Traits\MayContainCustomFields;
use App\Models\Asset;
use App\Models\Setting;
@@ -22,6 +23,13 @@ class UpdateAssetRequest extends ImageUploadRequest
return Gate::allows('update', $this->asset);
}
public function prepareForValidation(): void
{
if ($this->filled('purchase_cost') && ! is_float($this->input('purchase_cost')) && preg_match('/^[\d.,]+$/', (string) $this->input('purchase_cost'))) {
$this->merge(['purchase_cost' => Helper::ParseCurrency($this->input('purchase_cost'))]);
}
}
/**
* Get the validation rules that apply to the request.
*
@@ -51,12 +59,6 @@ class UpdateAssetRequest extends ImageUploadRequest
],
);
// if the purchase cost is passed in as a string **and** the digit_separator is ',' (as is common in the EU)
// then we tweak the purchase_cost rule to make it a string
if ($setting->digit_separator === '1.234,56' && is_string($this->input('purchase_cost'))) {
$rules['purchase_cost'] = ['nullable', 'string'];
}
return $rules;
}
}
@@ -0,0 +1,35 @@
<?php
namespace App\Http\Requests;
use App\Helpers\Helper;
use Illuminate\Support\Facades\Gate;
class UpdateComponentRequest extends ImageUploadRequest
{
public function authorize(): bool
{
return Gate::allows('update', $this->component);
}
public function prepareForValidation(): void
{
if ($this->filled('purchase_cost') && ! is_float($this->input('purchase_cost')) && preg_match('/^[\d.,]+$/', (string) $this->input('purchase_cost'))) {
$this->merge(['purchase_cost' => Helper::ParseCurrency($this->input('purchase_cost'))]);
}
}
public function rules(): array
{
$min = $this->component->numCheckedOut();
return array_merge(parent::rules(), [
'qty' => "required|numeric|min:{$min}",
]);
}
public function response(array $errors)
{
return $this->redirector->back()->withInput()->withErrors($errors, $this->errorBag);
}
}
@@ -1,13 +1,13 @@
<?php
namespace App\Http\Controllers;
namespace App\Http\Traits;
use App\Models\Asset;
use App\Models\Location;
use App\Models\SnipeModel;
use App\Models\User;
trait CheckInOutRequest
trait CheckInOutTrait
{
/**
* Find target for checkout
@@ -9,8 +9,24 @@ class DatatablesTransformer
**/
public function transformDatatables($objects, $total = null)
{
(isset($total)) ? $objects_array['total'] = $total : $objects_array['total'] = count($objects);
$objects_array['rows'] = $objects;
$objects_array = [
'total' => $total ?? count($objects),
'rows' => $objects,
];
$current_page = app('api_current_page');
$limit = (int) app('api_limit_value');
$total_pages = $limit > 0 ? (int) ceil($objects_array['total'] / $limit) : 1;
$objects_array['current_page'] = $current_page;
$objects_array['per_page'] = $limit;
$objects_array['total_pages'] = $total_pages;
$objects_array['prev_page_url'] = $current_page > 1
? request()->fullUrlWithQuery(['page' => $current_page - 1])
: null;
$objects_array['next_page_url'] = $current_page < $total_pages
? request()->fullUrlWithQuery(['page' => $current_page + 1])
: null;
return $objects_array;
}
@@ -20,8 +36,10 @@ class DatatablesTransformer
**/
public function transformBulkResponseWithStatusAndObjects($objects, $total)
{
(isset($total)) ? $objects_array['total'] = $total : $objects_array['total'] = count($objects);
$objects_array['rows'] = $objects;
$objects_array = [
'total' => $total ?? count($objects),
'rows' => $objects,
];
return $objects_array;
}
+2 -1
View File
@@ -37,7 +37,8 @@ class AccessoryImporter extends ItemImporter
$this->log('Updating Accessory');
$this->item['model_number'] = trim($this->findCsvMatch($row, 'model_number'));
$accessory->update($this->sanitizeItemForUpdating($accessory));
$accessory->save();
// update() already saves the model, no need to call save() again while Model::unguard() is active
$accessory->setImported(true);
return;
}
+28 -8
View File
@@ -176,35 +176,55 @@ class AssetImporter extends ItemImporter
if ($editingAsset) {
$asset->update($item);
$asset->setImported(true);
} else {
$asset->fill($item);
$asset->setImported(true);
}
// If we're updating, we don't want to overwrite old fields.
// Apply custom fields to asset attributes if they exist
$customFieldsToSave = [];
if (array_key_exists('custom_fields', $this->item)) {
foreach ($this->item['custom_fields'] as $custom_field => $val) {
$asset->{$custom_field} = $val;
$customFieldsToSave[$custom_field] = $val;
}
}
// This sets an attribute on the Loggable trait for the action log
$asset->setImported(true);
// For existing assets that have custom fields, update them.
// This avoids the issue of calling save() twice with Model::unguard() active.
if ($editingAsset && ! empty($customFieldsToSave)) {
$asset->update($customFieldsToSave);
$success = true;
} elseif (! $editingAsset) {
// For new assets, save with all changes (custom fields included via direct attribute assignment above)
$success = $asset->save();
} else {
// For existing assets without custom fields, update() already saved everything
$success = true;
}
if ($asset->save()) {
if ($success) {
$this->log('Asset '.$this->item['name'].' with serial number '.$this->item['serial'].' was created');
$this->log('Asset '.$this->item['name'].' with serial number '.$this->item['serial'].' created or updated');
// If we have a target to checkout to, lets do so.
// -- created_by is a property of the abstract class Importer, which this class inherits from and it's set by
// -- the class that needs to use it (command importer or GUI importer inside the project).
if (isset($target) && ($target !== false)) {
if (! is_null($asset->assigned_to)) {
if ($asset->assigned_to != $target->id) {
$asset = $asset->fresh();
$targetType = get_class($target);
$alreadyCheckedOutToTarget = ($asset->assigned_to == $target->id) && ($asset->assigned_type === $targetType);
// Skip duplicate checkout noise when update mode keeps the same assignment target.
if (! $alreadyCheckedOutToTarget) {
if (! is_null($asset->assigned_to)) {
event(new CheckoutableCheckedIn($asset, $asset->assigned, auth()->user(), 'Checkin from CSV Importer', $checkin_date));
}
}
$asset->fresh()->checkOut($target, $this->created_by, $checkout_date, null, 'Checkout from CSV Importer', $asset->name);
$asset->checkOut($target, $this->created_by, $checkout_date, null, 'Checkout from CSV Importer', $asset->name);
}
}
return;
+2 -1
View File
@@ -42,7 +42,8 @@ class ComponentImporter extends ItemImporter
}
$this->log('Updating Component');
$component->update($this->sanitizeItemForUpdating($component));
$component->save();
// update() already saves the model, no need to call save() again while Model::unguard() is active
$component->setImported(true);
return;
}
+2 -1
View File
@@ -38,7 +38,8 @@ class ConsumableImporter extends ItemImporter
}
$this->log('Updating Consumable');
$consumable->update($this->sanitizeItemForUpdating($consumable));
$consumable->save();
// update() already saves the model, no need to call save() again while Model::unguard() is active
$consumable->setImported(true);
return;
}
+6 -2
View File
@@ -88,8 +88,12 @@ class LicenseImporter extends ItemImporter
// This sets an attribute on the Loggable trait for the action log
$license->setImported(true);
if ($license->save()) {
$this->log('License '.$this->item['name'].' with serial number '.$this->item['serial'].' was created');
// For new licenses we need to save, for existing ones update() already saved
$licenseWasSaved = $editingLicense || $license->save();
if ($licenseWasSaved) {
$this->log('License '.$this->item['name'].' with serial number '.$this->item['serial'].' was created or updated');
// Lets try to checkout seats if the fields exist and we have seats.
if ($license->seats > 0) {
+82 -80
View File
@@ -2,10 +2,6 @@
namespace App\Models;
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\Attribute\Attribute;
use ArieTimmerman\Laravel\SCIMServer\Attribute\Collection;
use ArieTimmerman\Laravel\SCIMServer\Attribute\Complex;
@@ -15,9 +11,10 @@ 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 ArieTimmerman\Laravel\SCIMServer\Exceptions\SCIMException;
use ArieTimmerman\Laravel\SCIMServer\Parser\Path;
use ArieTimmerman\Laravel\SCIMServer\SCIM\Schema;
use Illuminate\Database\Eloquent\Model;
use ArieTimmerman\Laravel\SCIMServer\Attribute\AttributeMapping;
use ArieTimmerman\Laravel\SCIMServer\SCIMConfig;
function a($name = null): Attribute
{
@@ -36,11 +33,10 @@ function eloquent($name, $attribute = null): Attribute
class EloquentWithRemove extends Eloquent
{
public function remove($value, Model &$object, Path $path = null)
public function remove($value, Model &$object, ?Path $path = null)
{
$object->{$this->attribute} = null;
}
}
class MappedTable extends Attribute
@@ -70,52 +66,48 @@ class MappedTable extends Attribute
$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)
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)
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());
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);
$this->doWrite('add', null, $value, $object);
}
public function replace($value, Model &$object, Path $path = null, $removeIfNotSet = false)
public function replace($value, Model &$object, ?Path $path = null, $removeIfNotSet = false)
{
$this->doWrite("replace", null, $value, $object, $path, $removeIfNotSet);
$this->doWrite('replace', null, $value, $object, $path, $removeIfNotSet);
}
public function patch($operation, $value, Model &$object, Path $path = null, $removeIfNotSet = false)
public function patch($operation, $value, Model &$object, ?Path $path = null, $removeIfNotSet = false)
{
$this->doWrite("patch", $operation, $value, $object, $path, $removeIfNotSet);
$this->doWrite('patch', $operation, $value, $object, $path, $removeIfNotSet);
}
public function remove($value, Model &$object, Path $path = null)
public function remove($value, Model &$object, ?Path $path = null)
{
$this->doWrite("remove", null, null, $object, $path);
$this->doWrite('remove', null, null, $object, $path);
}
}
class SnipeSCIMConfig
{
public function __construct()
{
}
public function __construct() {}
public function getConfigForResource($name)
{
$result = $this->getConfig();
return @$result[$name];
}
@@ -125,6 +117,7 @@ class SnipeSCIMConfig
}
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()
@@ -140,22 +133,19 @@ class SnipeSCIMConfig
'description' => 'User Account',
'map' => complex()->withSubAttributes(
new class ('schemas', [
"urn:ietf:params:scim:schemas:core:2.0:User",
self::ENTERPRISE,
self::GROKABILITY
]) extends Constant {
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;
}
(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;
}
public function remove($value, &$object, $path = null)
{
@@ -166,90 +156,97 @@ class SnipeSCIMConfig
new Meta('Users'),
(new AttributeSchema(Schema::SCHEMA_USER, true))->withSubAttributes(
eloquent('userName', 'username')->ensure('required'),
(new class ('active', 'activated') extends Eloquent {
(new class('active', 'activated') extends Eloquent
{
protected function doRead(&$object, $attributes = [])
{
return (bool)$object->activated; // need this extension to force boolean-ness
return (bool) $object->activated; // need this extension to force boolean-ness
}
}),
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('displayName', 'display_name'), // yes, this is *not* under 'name' - that's the spec
// eloquent('password')->ensure('nullable')->setReturned('never'),
eloquent('externalId', 'scim_externalid'),
// Email chonk
(new class ('emails') extends UpdatableComplex {
(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
'type' => 'work', // TODO - is this how we always have done it?
'primary' => true,
];
})->toArray();
}
public function doWrite($operation, $subop, $value, Model &$object, Path $path = null, $removeIfNotSet = false)
public function doWrite($operation, $subop, $value, Model &$object, ?Path $path = null, $removeIfNotSet = false)
{
if ($value) {
try {
$object->email = $value[0]['value'];
} catch (\Throwable $e) {
\Log::debug($e);
throw new SCIMException("Unknown email object: '" . print_r($value, true) . "'", 422);
throw new SCIMException("Unknown email object: '".print_r($value, true)."'", 422);
}
} else {
$object->email = null;
}
}
})->withSubAttributes(
eloquent('value', 'email')->ensure('email', 'nullable'), //Weird, this 'needs' nullable to work?
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 {
(new class('phoneNumbers') extends UpdatableComplex
{
protected function doRead(&$object, $attributes = [])
{
$phones = [];
if ($object->phone) {
$phones[] = [
'value' => $object->phone,
'type' => 'work'
'type' => 'work',
];
}
if ($object->mobile) {
$phones[] = [
'value' => $object->mobile,
'type' => 'mobile'
'type' => 'mobile',
];
}
return $phones;
}
public function doWrite($operation, $subop, $value, Model &$object, Path $path = null, $removeIfNotSet = false)
public function doWrite($operation, $subop, $value, Model &$object, ?Path $path = null, $removeIfNotSet = false)
{
\Log::debug("Phones 'value' is: " . print_r($value, true));
\Log::debug("Phones 'value' is: ".print_r($value, true));
try {
if ($operation == "patch") {
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?
$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
$object->phone = $value; // similar, don't know why, but it is
return;
}
}
parent::patch($subop, $value, $object, $path, $removeIfNotSet);
return;
}
foreach ($value as $phone) {
@@ -263,15 +260,14 @@ class SnipeSCIMConfig
break;
default:
throw new SCIMException("Unknown phone type '" . @$phone['type'] . "'", 400);
throw new SCIMException("Unknown phone type '".@$phone['type']."'", 400);
}
}
} catch (\Throwable $e) {
\Log::debug($e);
throw new SCIMException("Unknown phone object(s) '" . print_r($value, true) . "'", 422);
throw new SCIMException("Unknown phone object(s) '".print_r($value, true)."'", 422);
}
}
})->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
@@ -279,13 +275,14 @@ class SnipeSCIMConfig
->setMultiValued(true),
// addresses chonk
(new class ('addresses') extends UpdatableComplex {
static $addressmap = [
(new class('addresses') extends UpdatableComplex
{
public static $addressmap = [
'streetAddress' => 'address',
'locality' => 'city',
'region' => 'state',
'postalCode' => 'zip',
'country' => 'country'
'country' => 'country',
];
protected function doRead(&$object, $attributes = [])
@@ -300,10 +297,11 @@ class SnipeSCIMConfig
$address['type'] = 'work';
$address['primary'] = true;
}
return $address;
}
public function doWrite($operation, $subop, $value, Model &$object, Path $path = null, $removeIfNotSet = false)
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) {
@@ -311,7 +309,7 @@ class SnipeSCIMConfig
// 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)) {
if (! preg_match('/^.+\[type eq "([a-zA-Z]+)"](?:\.([a-zA-Z]+))?$/', (string) $path, $matches)) {
throw new SCIMException("Unknown path type '$path'", 422);
}
$type = $matches[1];
@@ -321,14 +319,13 @@ class SnipeSCIMConfig
$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", 422);
}
}
})->withSubAttributes(
eloquent('streetAddress', 'address'),
eloquent('locality', 'city'),
@@ -344,14 +341,15 @@ class SnipeSCIMConfig
eloquent('preferredLanguage', 'locale'),
(new Collection('groups'))->withSubAttributes(
eloquent('value', 'id'),
(new class ('$ref') extends Eloquent {
(new class('$ref') extends Eloquent
{
protected function doRead(&$object, $attributes = [])
{
return route(
'scim.resource',
[
'resourceType' => 'Group',
'resourceObject' => $object->id ?? "not-saved"
'resourceObject' => $object->id ?? 'not-saved',
]
);
}
@@ -368,14 +366,16 @@ class SnipeSCIMConfig
(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 {
(new class('manager') extends UpdatableComplex
{
protected function doRead(&$object, $attributes = [])
{
if (!$object->manager) {
if (! $object->manager) {
return null;
}
return [
'value' => $object->manager->id, //TODO - ID's aren't unique like they're supposed to be :/
'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,
];
@@ -383,10 +383,10 @@ class SnipeSCIMConfig
public function doWrite($operation, $subop, $value, Model &$object, $path = null, $removeIfNotSet = false)
{
\Log::debug("What type of value is value? " . gettype($value));
\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?");
\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
@@ -396,8 +396,8 @@ class SnipeSCIMConfig
// extract ID from URL, jam it in?
$url = $value['$ref'];
$users_prefix = route('scim.resources', ['resourceType' => 'User']) . '/';
if (string_starts_with($url, $users_prefix)) {
$users_prefix = route('scim.resources', ['resourceType' => 'User']).'/';
if (str_starts_with($url, $users_prefix)) {
$manager_id = substr($url, strlen($users_prefix));
}
} elseif (array_key_exists('value', $value)) {
@@ -407,9 +407,10 @@ class SnipeSCIMConfig
// 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));
\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);
@@ -431,24 +432,24 @@ class SnipeSCIMConfig
'class' => $this->getGroupClass(),
'singular' => 'Group',
//eager loading
// eager loading
'withRelations' => [],
'description' => 'Group',
'map' => complex()->withSubAttributes(
new class ('schemas', [
"urn:ietf:params:scim:schemas:core:2.0:Group",
]) extends Constant {
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 {
(new class('id', null) extends Constant
{
protected function doRead(&$object, $attributes = [])
{
return (string)$object->id;
return (string) $object->id;
}
public function remove($value, &$object, $path = null)
@@ -469,14 +470,15 @@ class SnipeSCIMConfig
}),
(new MutableCollection('members'))->withSubAttributes(
eloquent('value', 'id')->ensure('required'),
(new class ('$ref') extends Eloquent {
(new class('$ref') extends Eloquent
{
protected function doRead(&$object, $attributes = [])
{
return route(
'scim.resource',
[
'resourceType' => 'Users',
'resourceObject' => $object->id ?? "not-saved"
'resourceObject' => $object->id ?? 'not-saved',
]
);
}
+21 -3
View File
@@ -44,11 +44,29 @@ class SettingsServiceProvider extends ServiceProvider
return $limit;
});
// Make sure the offset is actually set and is an integer
// Make sure the offset is actually set and is an integer.
// If 'page' is passed without 'offset', derive the offset from the page number.
app()->singleton('api_offset_value', function () {
$offset = intval(request('offset'));
if (request()->filled('page') && ! request()->filled('offset')) {
$page = max(1, intval(request('page')));
return $offset;
return ($page - 1) * (int) app('api_limit_value');
}
return intval(request('offset'));
});
// Resolve the current page number for inclusion in API list responses.
// Supports both page= and legacy offset= parameters.
app()->singleton('api_current_page', function () {
if (request()->filled('page') && ! request()->filled('offset')) {
return max(1, intval(request('page')));
}
$limit = (int) app('api_limit_value');
$offset = (int) app('api_offset_value');
return $limit > 0 ? (int) floor($offset / $limit) + 1 : 1;
});
/**
Generated
+4 -4
View File
@@ -78,12 +78,12 @@
"source": {
"type": "git",
"url": "https://github.com/grokability/laravel-scim-server.git",
"reference": "dfef93c7344be3c3c255d54694e182c5ec784bab"
"reference": "5ddb8188dd50e2bdb6f8133b9b7f0a0b54b83148"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/grokability/laravel-scim-server/zipball/dfef93c7344be3c3c255d54694e182c5ec784bab",
"reference": "dfef93c7344be3c3c255d54694e182c5ec784bab",
"url": "https://api.github.com/repos/grokability/laravel-scim-server/zipball/5ddb8188dd50e2bdb6f8133b9b7f0a0b54b83148",
"reference": "5ddb8188dd50e2bdb6f8133b9b7f0a0b54b83148",
"shasum": ""
},
"require": {
@@ -133,7 +133,7 @@
"support": {
"source": "https://github.com/grokability/laravel-scim-server/tree/scimv2_with_logging"
},
"time": "2026-05-12T11:17:15+00:00"
"time": "2026-05-14T09:39:34+00:00"
},
{
"name": "aws/aws-crt-php",
+1 -1
View File
@@ -59,7 +59,7 @@ class AssetFactory extends Factory
// the explicit boolean gets set in the saving() method on the observer
$asset->asset_eol_date = $this->faker->boolean(5)
? CarbonImmutable::parse($asset->purchase_date)->addMonths(rand(0, 20))->format('Y-m-d')
: CarbonImmutable::parse($asset->purchase_date)->addMonths($asset->model->eol)->format('Y-m-d');
: CarbonImmutable::parse($asset->purchase_date)->addMonths($asset->model?->eol ?? rand(12, 60))->format('Y-m-d');
});
}
+3 -2
View File
@@ -37,8 +37,9 @@ return [
'user_activated' => 'User can login',
'activation_status_warning' => 'Do not change activation status',
'group_memberships_helpblock' => 'Only superadmins may edit group memberships.',
'superadmin_permission_warning' => 'Only superadmins may grant a user superadmin access.',
'admin_permission_warning' => 'Only users with admins rights or greater may grant a user admin access.',
'superadmin_permission_warning' => 'Only superadmins may grant or revoke superadmin access.',
'self_permission_warning' => 'Only superadmins may edit their own permissions.',
'admin_permission_warning' => 'Only users with admins rights or greater may grant or revoke admin access.',
'remove_group_memberships' => 'Remove Group Memberships',
'warning_deletion_information' => 'You are about to checkin ALL items from the :count user(s) listed below.',
'update_user_assets_status' => 'Update all assets for these users to this status',
+2
View File
@@ -255,6 +255,8 @@ return [
'processing' => 'Processing',
'profile' => 'View Profile',
'purchase_cost' => 'Purchase Cost',
'purchase_cost_format_help' => 'This should be entered in your system-configured number format, e.g. :format',
'purchase_cost_invalid' => 'Please enter a valid number (digits, dots, and commas only)',
'purchase_date' => 'Purchase Date',
'qty' => 'QTY',
'quantity' => 'Quantity',
+1 -1
View File
@@ -177,7 +177,7 @@
</label>
<div class="input-group col-md-3">
<span class="input-group-addon">{{ $snipeSettings->default_currency }}</span>
<input type="text" class="form-control" maxlength="10" placeholder="{{ trans('admin/hardware/form.cost') }}" name="purchase_cost" id="purchase_cost" value="{{ old('purchase_cost') }}">
<input type="text" class="form-control" pattern="^\d+([.,]\d+)?$" maxlength="10" placeholder="{{ trans('admin/hardware/form.cost') }}" name="purchase_cost" id="purchase_cost" value="{{ old('purchase_cost') }}">
{!! $errors->first('purchase_cost', '<span class="alert-msg" aria-hidden="true"><i class="fas fa-times" aria-hidden="true"></i> :message</span>') !!}
</div>
</div>
+10
View File
@@ -2344,6 +2344,16 @@
email: "{{ trans('validation.generic.email') }}"
});
$.validator.addMethod('pattern', function(value, element, param) {
if (this.optional(element)) {
return true;
}
if (typeof param === 'string') {
param = new RegExp('^(?:' + param + ')$');
}
return param.test(value);
}, '{{ trans('validation.generic.invalid_value_in_field') }}');
function showHideEncValue(e) {
// Use element id to find the text element to hide / show
+2 -1
View File
@@ -166,8 +166,9 @@
{{ $snipeSettings->default_currency }}
@endif
</span>
<input class="form-control" type="number" name="cost" min="0.00" max="99999999999999999.000" step="0.001" aria-label="cost" id="cost" value="{{ old('cost', $item->cost) }}" maxlength="25" />
<input class="form-control" type="text" inputmode="decimal" pattern="[\d.,]+" name="cost" aria-label="cost" id="cost" value="{{ old('cost', \App\Helpers\Helper::formatCurrencyOutput($item->cost)) }}" maxlength="25" data-msg-pattern="{{ trans('general.purchase_cost_invalid') }}"/>
{!! $errors->first('cost', '<span class="alert-msg" aria-hidden="true"><i class="fas fa-times" aria-hidden="true"></i> :message</span>') !!}
<p class="help-block">{{ trans('general.purchase_cost_format_help', ['format' => $snipeSettings->digit_separator]) }}</p>
</div>
</div>
</div>
@@ -11,13 +11,21 @@
$section_name = str_slug($main_section);
}
@endphp
@if (str_slug($main_section) == 'superuser' && !auth()->user()->isSuperUser())
@continue
@endif
@if (str_slug($main_section) == 'admin' && !auth()->user()->hasAccess('admin'))
@continue
@endif
<div class="form-group {{ ($sectionPermission['permission']!='superuser') ? ' nonsuperuser' : '' }}{{ ( ($sectionPermission['permission']!='superuser') && ($sectionPermission['permission']!='admin')) ? ' nonadmin' : '' }}">
<!-- start callout legend for major sections -->
<div class="callout callout-legend col-md-12">
<!-- start left column with area name and note -->
<div class="col-md-10">
<div class="col-md-9">
<h4 id="{{ str_slug($sectionPermission['permission'])}}" class="{{ (count($main_section_permission) > 1) ? 'remember-toggle': '' }}">
@if (count($main_section_permission) > 1)
@@ -33,7 +41,7 @@
<!-- end left column with area name and note -->
<!-- Handle the checkall ALLOW and DENY radios in the right column -->
<div class="col-md-2 text-right header-row">
<div class="col-md-3 text-right header-row">
<div class="radio-toggle-wrapper">
<!-- start .radio-slider-inputs allow -->
@@ -46,10 +54,6 @@
@checked(array_key_exists($section_name, $groupPermissions) && $groupPermissions[$section_name] == '1')
type="radio"
value="1"
{{-- Disable the superuser and admin allow if the user is not a superuser --}}
@if (((str_slug($main_section) == 'admin') && (!auth()->user()->hasAccess('admin'))) || ((str_slug($main_section) == 'superuser') && (!auth()->user()->isSuperUser())))
disabled
@endif
id="{{ str_slug($main_section) }}_allow"
>
@@ -70,10 +74,6 @@
@checked((array_key_exists(str_slug($main_section), $groupPermissions) && $groupPermissions[str_slug($main_section)] == '0') || (!array_key_exists(str_slug($main_section), $groupPermissions)))
type="radio"
value="0"
{{-- Disable the superuser and admin allow if the user is not a superuser --}}
@if (((str_slug($main_section) == 'admin') && (!auth()->user()->hasAccess('admin'))) || ((str_slug($main_section) == 'superuser') && (!auth()->user()->isSuperUser())))
disabled
@endif
id="{{ str_slug($main_section) }}_inherit"
>
@@ -94,10 +94,6 @@
@checked(array_key_exists(str_slug($main_section), $groupPermissions) && $groupPermissions[str_slug($main_section)] == '-1')
type="radio"
value="-1"
{{-- Disable the superuser and admin allow if the user is not a superuser --}}
@if (((str_slug($main_section) == 'admin') && (!auth()->user()->hasAccess('admin'))) || ((str_slug($main_section) == 'superuser') && (!auth()->user()->isSuperUser())))
disabled
@endif
id="{{ str_slug($main_section) }}_deny"
>
@@ -128,14 +124,14 @@
@endphp
<div class="form-group" style="border-bottom: 1px solid #eee; padding-right: 13px;">
<div class="col-md-10">
<div class="col-md-9">
<strong>{{ $section_translation }}</strong>
@if (\Lang::has('permissions.'.str_slug($this_permission['permission']).'.note'))
<p>{{ trans('permissions.'.str_slug($this_permission['permission']).'.note') }}</p>
@endif
</div>
<div class="form-group col-md-2 text-right">
<div class="form-group col-md-3 text-right">
<div class="radio-toggle-wrapper">
<div class="radio-slider-inputs" data-tooltip="true" title="{{ trans('permissions.grant', ['area' => $section_translation]) }}">
@@ -3,7 +3,7 @@
<label for="purchase_cost" class="col-md-3 control-label">{{ $unit_cost ?? trans('general.purchase_cost') }}</label>
<div class="col-md-9">
<div class="input-group col-md-5" style="padding-left: 0px;">
<input class="form-control" type="number" name="purchase_cost" min="0.00" max="99999999999999999.000" step="0.001" aria-label="purchase_cost" id="purchase_cost" value="{{ old('purchase_cost', $item->purchase_cost) }}" maxlength="25" />
<input class="form-control" type="text" name="purchase_cost" pattern="[\d.,]+" aria-label="purchase_cost" id="purchase_cost" value="{{ old('purchase_cost', \App\Helpers\Helper::formatCurrencyOutput($item->purchase_cost)) }}" maxlength="25" inputmode="decimal" data-msg-pattern="{{ trans('general.purchase_cost_invalid') }}"/>
<span class="input-group-addon">
@if (isset($currency_type))
{{ $currency_type }}
@@ -14,6 +14,7 @@
</div>
<div class="col-md-9" style="padding-left: 0px;">
{!! $errors->first('purchase_cost', '<span class="alert-msg" aria-hidden="true"><i class="fas fa-times" aria-hidden="true"></i> :message</span>') !!}
<p class="help-block">{{ trans('general.purchase_cost_format_help', ['format' => $snipeSettings->digit_separator]) }}</p>
</div>
</div>
+2 -2
View File
@@ -13,9 +13,9 @@
<div class="row" style="padding-bottom: 10px;">
<div class="col-md-3 col-sm-6">
<span href="{{ route('reports.activity') }}" class="btn btn-theme btn-block" style="margin-bottom: 10px; white-space: normal;">
<a href="{{ route('reports.activity') }}" class="btn btn-theme btn-block" style="margin-bottom: 10px; white-space: normal;">
<x-icon type="reports"/> {{ trans('general.activity_report') }}
</span>
</a>
</div>
<div class="col-md-3 col-sm-6">
+27 -16
View File
@@ -52,10 +52,14 @@
<!-- Custom Tabs -->
<div class="nav-tabs-custom">
<ul class="nav nav-tabs">
<li class="active"><a href="#info" data-toggle="tab">{{ trans('general.information') }} </a></li>
@can('admin')
<li><a href="#permissions" data-toggle="tab">{{ trans('general.permissions') }} </a></li>
@endcan
<li class="active">
<a href="#info" data-toggle="tab">{{ trans('general.information') }} </a>
</li>
<li>
<a href="#permissions" data-toggle="tab">{{ trans('general.permissions') }} </a>
</li>
</ul>
<div class="tab-content">
@@ -641,28 +645,35 @@
</div>
</div><!-- /.tab-pane -->
@can('admin')
<div class="tab-pane" id="permissions">
@if (!Auth::user()->isSuperUser())
<p class="alert alert-warning">{{ trans('admin/users/general.superadmin_permission_warning') }}</p>
@endif
@if (!Auth::user()->hasAccess('admin'))
<p class="alert alert-warning">{{ trans('admin/users/general.admin_permission_warning') }}</p>
@endif
<x-form.legend help_text="{{ trans('permissions.use_groups') }}"/>
@if (auth()->user()->isAdmin() && !auth()->user()->isSuperUser())
<p class="alert alert-info">
{{ trans('permissions.use_groups') }}
<x-icon type="info"/>
{{ trans('admin/users/general.superadmin_permission_warning') }}
</p>
@elseif (!auth()->user()->isAdmin() && !auth()->user()->isSuperUser() && auth()->id() === $user->id)
<p class="alert alert-danger">
<x-icon type="alert"/>
{{ trans('admin/users/general.self_permission_warning') }}
</p>
@elseif (!auth()->user()->isAdmin() && !auth()->user()->isSuperUser() && auth()->id() !== $user->id)
<p class="alert alert-danger">
<x-icon type="warning"/>
{{ trans('admin/users/general.admin_permission_warning') }}
</p>
@endif
@if (auth()->user()->isSuperUser() || auth()->user()->isAdmin() || (auth()->id() !== $user->id && !$user->isSuperUser()))
<div class="col-md-12">
@include('partials.forms.edit.permissions-base', ['use_inherit' => true, 'groupPermissions' => $userPermissions])
@include('partials.forms.edit.permissions-base', ['use_inherit' => true, 'groupPermissions' => $userPermissions])
</div>
@endif
</div><!-- /.tab-pane -->
@endcan
</div><!-- /.tab-content -->
<x-redirect_submit_options
index_route="users.index"
+1 -1
View File
@@ -1,5 +1,5 @@
Company,Name,Asset Tag,Category,Supplier,Manufacturer,Location,Order Number,Model,Model Notes,Model Number,Asset Notes,Purchase Date,Purchase Cost,Checkout Type,Checked Out To: Username,Checked Out To: First Name,Checked Out To: Last Name,Checked Out To: Email,Checked Out To: Location,Asset EOL Date
Abshire and Sons,Backhoe,ICC-2065556,Ornamental Railings,"Kunde, Doyle and Kozey",Berge Inc,"Wilkinson, Waters and Kerluke",3271901481,"Macbook Pro 13""",,1786VM80X07,at nulla suspendisse potenti cras in purus eu magna vulputate luctus cum sociis natoque penatibus et magnis dis,1/23/23,2266.13,,,,,,,10/27/28
Abshire and Sons and Sons and Sons and Sons!,Backhoe,ICC-2065556,Ornamental Railings,"Kunde, Doyle and Kozey",Berge Inc,"Wilkinson, Waters and Kerluke",3271901481,"Macbook Pro 13""",,1786VM80X07,at nulla suspendisse potenti cras in purus eu magna vulputate luctus cum sociis natoque penatibus et magnis dis,1/23/23,2266.13,,,,,,,10/27/28
"Quitzon, Oberbrunner and Dibbert",Dragline,WBH-2841795,Structural and Misc Steel (Fabrication),Krajcik LLC,"Botsford, Boyle and Herzog",Lindgren-Marquardt,5504512275,"Macbook Pro 13""",ante ipsum primis in faucibus orci luctus et ultrices posuere cubilia curae mauris viverra diam vitae quam suspendisse potenti nullam,9351IS25A51,aliquam convallis nunc proin at turpis a pede posuere nonummy integer,11/14/22,1292.94,User,gmccrackem2a,Gage,McCrackem,gmccrackem2a@bing.com,,10/27/28
Boyer and Sons,Excavator,NNH-3656031,Soft Flooring and Base,"Heaney, Altenwerth and Emmerich",Pollich LLC,Pacocha-Kiehn,4861125177,"Macbook Pro 13""",,9929FR08W85,,3/1/23,2300.71,Location,,,,,Pacocha-Kiehn,10/27/28
Hayes-Rippin,Trencher,BOL-0305383,Prefabricated Aluminum Metal Canopies,"Botsford, Boyle and Herzog",Walker-Towne,Fritsch-Abernathy,2416994639,"Macbook Pro 13""",neque vestibulum eget vulputate ut ultrices vel augue vestibulum ante ipsum primis in faucibus orci luctus,9139KQ78G81,,10/26/22,1777.56,User,ksennett6,Katerina,Sennett,ksennett6@ibm.com,,10/27/28
1 Company Name Asset Tag Category Supplier Manufacturer Location Order Number Model Model Notes Model Number Asset Notes Purchase Date Purchase Cost Checkout Type Checked Out To: Username Checked Out To: First Name Checked Out To: Last Name Checked Out To: Email Checked Out To: Location Asset EOL Date
2 Abshire and Sons Abshire and Sons and Sons and Sons and Sons! Backhoe ICC-2065556 Ornamental Railings Kunde, Doyle and Kozey Berge Inc Wilkinson, Waters and Kerluke 3271901481 Macbook Pro 13" 1786VM80X07 at nulla suspendisse potenti cras in purus eu magna vulputate luctus cum sociis natoque penatibus et magnis dis 1/23/23 2266.13 10/27/28
3 Quitzon, Oberbrunner and Dibbert Dragline WBH-2841795 Structural and Misc Steel (Fabrication) Krajcik LLC Botsford, Boyle and Herzog Lindgren-Marquardt 5504512275 Macbook Pro 13" ante ipsum primis in faucibus orci luctus et ultrices posuere cubilia curae mauris viverra diam vitae quam suspendisse potenti nullam 9351IS25A51 aliquam convallis nunc proin at turpis a pede posuere nonummy integer 11/14/22 1292.94 User gmccrackem2a Gage McCrackem gmccrackem2a@bing.com 10/27/28
4 Boyer and Sons Excavator NNH-3656031 Soft Flooring and Base Heaney, Altenwerth and Emmerich Pollich LLC Pacocha-Kiehn 4861125177 Macbook Pro 13" 9929FR08W85 3/1/23 2300.71 Location Pacocha-Kiehn 10/27/28
5 Hayes-Rippin Trencher BOL-0305383 Prefabricated Aluminum Metal Canopies Botsford, Boyle and Herzog Walker-Towne Fritsch-Abernathy 2416994639 Macbook Pro 13" neque vestibulum eget vulputate ut ultrices vel augue vestibulum ante ipsum primis in faucibus orci luctus 9139KQ78G81 10/26/22 1777.56 User ksennett6 Katerina Sennett ksennett6@ibm.com 10/27/28
@@ -0,0 +1,273 @@
<?php
namespace Tests\Feature\Assets\Api;
use App\Models\Asset;
use App\Models\User;
use Tests\TestCase;
class AssetPaginationTest extends TestCase
{
private User $user;
protected function setUp(): void
{
parent::setUp();
$this->user = User::factory()->superuser()->create();
}
public function test_response_includes_pagination_fields()
{
Asset::factory()->count(3)->create();
$this->actingAsForApi($this->user)
->getJson(route('api.assets.index'))
->assertOk()
->assertJsonStructure(['total', 'rows', 'current_page', 'per_page', 'total_pages']);
}
public function test_default_request_returns_page_one()
{
Asset::factory()->count(3)->create();
$this->actingAsForApi($this->user)
->getJson(route('api.assets.index'))
->assertOk()
->assertJsonPath('current_page', 1);
}
public function test_offset_zero_returns_page_one()
{
Asset::factory()->count(10)->create();
$this->actingAsForApi($this->user)
->getJson(route('api.assets.index', ['offset' => 0, 'limit' => 5]))
->assertOk()
->assertJsonPath('current_page', 1);
}
public function test_offset_derives_correct_page_number()
{
Asset::factory()->count(10)->create();
$this->actingAsForApi($this->user)
->getJson(route('api.assets.index', ['offset' => 5, 'limit' => 5]))
->assertOk()
->assertJsonPath('current_page', 2);
}
public function test_page_one_returns_current_page_one()
{
Asset::factory()->count(10)->create();
$this->actingAsForApi($this->user)
->getJson(route('api.assets.index', ['page' => 1, 'limit' => 5]))
->assertOk()
->assertJsonPath('current_page', 1);
}
public function test_page_two_returns_current_page_two()
{
Asset::factory()->count(10)->create();
$this->actingAsForApi($this->user)
->getJson(route('api.assets.index', ['page' => 2, 'limit' => 5]))
->assertOk()
->assertJsonPath('current_page', 2);
}
public function test_page_one_returns_first_items()
{
foreach (range(1, 10) as $i) {
Asset::factory()->create(['asset_tag' => sprintf('PAG-TEST-%03d', $i)]);
}
$tags = $this->actingAsForApi($this->user)
->getJson(route('api.assets.index', ['page' => 1, 'limit' => 5, 'sort' => 'asset_tag', 'order' => 'asc']))
->assertOk()
->json('rows.*.asset_tag');
$this->assertEquals(
['PAG-TEST-001', 'PAG-TEST-002', 'PAG-TEST-003', 'PAG-TEST-004', 'PAG-TEST-005'],
$tags
);
}
public function test_page_two_returns_second_set_of_items()
{
foreach (range(1, 10) as $i) {
Asset::factory()->create(['asset_tag' => sprintf('PAG-TEST-%03d', $i)]);
}
$tags = $this->actingAsForApi($this->user)
->getJson(route('api.assets.index', ['page' => 2, 'limit' => 5, 'sort' => 'asset_tag', 'order' => 'asc']))
->assertOk()
->json('rows.*.asset_tag');
$this->assertEquals(
['PAG-TEST-006', 'PAG-TEST-007', 'PAG-TEST-008', 'PAG-TEST-009', 'PAG-TEST-010'],
$tags
);
}
public function test_offset_returns_correct_items()
{
foreach (range(1, 10) as $i) {
Asset::factory()->create(['asset_tag' => sprintf('PAG-TEST-%03d', $i)]);
}
$tags = $this->actingAsForApi($this->user)
->getJson(route('api.assets.index', ['offset' => 5, 'limit' => 5, 'sort' => 'asset_tag', 'order' => 'asc']))
->assertOk()
->json('rows.*.asset_tag');
$this->assertEquals(
['PAG-TEST-006', 'PAG-TEST-007', 'PAG-TEST-008', 'PAG-TEST-009', 'PAG-TEST-010'],
$tags
);
}
public function test_page_param_respects_limit()
{
Asset::factory()->count(10)->create();
$response = $this->actingAsForApi($this->user)
->getJson(route('api.assets.index', ['page' => 1, 'limit' => 4]))
->assertOk();
$this->assertCount(4, $response->json('rows'));
}
public function test_page_beyond_results_returns_empty_rows()
{
Asset::factory()->count(5)->create();
$response = $this->actingAsForApi($this->user)
->getJson(route('api.assets.index', ['page' => 99, 'limit' => 5]))
->assertOk();
$this->assertCount(0, $response->json('rows'));
$this->assertEquals(5, $response->json('total'));
}
public function test_offset_takes_precedence_over_page_when_both_provided()
{
Asset::factory()->count(10)->create();
// offset=0 should win over page=3, giving current_page=1
$this->actingAsForApi($this->user)
->getJson(route('api.assets.index', ['offset' => 0, 'page' => 3, 'limit' => 5]))
->assertOk()
->assertJsonPath('current_page', 1);
}
public function test_per_page_reflects_the_limit_parameter_as_an_integer()
{
Asset::factory()->count(3)->create();
$response = $this->actingAsForApi($this->user)
->getJson(route('api.assets.index', ['limit' => 25]))
->assertOk()
->assertJsonPath('per_page', 25);
$this->assertIsInt($response->json('per_page'));
}
public function test_prev_page_url_is_null_on_first_page()
{
Asset::factory()->count(10)->create();
$this->actingAsForApi($this->user)
->getJson(route('api.assets.index', ['page' => 1, 'limit' => 5]))
->assertOk()
->assertJsonPath('prev_page_url', null);
}
public function test_next_page_url_is_null_on_last_page()
{
Asset::factory()->count(10)->create();
$this->actingAsForApi($this->user)
->getJson(route('api.assets.index', ['page' => 2, 'limit' => 5]))
->assertOk()
->assertJsonPath('next_page_url', null);
}
public function test_next_page_url_contains_correct_page_number()
{
Asset::factory()->count(10)->create();
$url = $this->actingAsForApi($this->user)
->getJson(route('api.assets.index', ['page' => 1, 'limit' => 5]))
->assertOk()
->json('next_page_url');
$this->assertStringContainsString('page=2', $url);
$this->assertStringContainsString('limit=5', $url);
}
public function test_prev_page_url_contains_correct_page_number()
{
Asset::factory()->count(10)->create();
$url = $this->actingAsForApi($this->user)
->getJson(route('api.assets.index', ['page' => 2, 'limit' => 5]))
->assertOk()
->json('prev_page_url');
$this->assertStringContainsString('page=1', $url);
$this->assertStringContainsString('limit=5', $url);
}
public function test_page_urls_do_not_include_limit_when_not_in_original_request()
{
Asset::factory()->count(600)->create();
$response = $this->actingAsForApi($this->user)
->getJson(route('api.assets.index', ['page' => 1]))
->assertOk();
$this->assertStringNotContainsString('limit=', $response->json('next_page_url'));
}
public function test_both_page_urls_null_when_single_page()
{
Asset::factory()->count(3)->create();
$this->actingAsForApi($this->user)
->getJson(route('api.assets.index', ['page' => 1, 'limit' => 50]))
->assertOk()
->assertJsonPath('prev_page_url', null)
->assertJsonPath('next_page_url', null);
}
public function test_total_pages_is_correct_for_even_division()
{
Asset::factory()->count(10)->create();
$this->actingAsForApi($this->user)
->getJson(route('api.assets.index', ['limit' => 5]))
->assertOk()
->assertJsonPath('total_pages', 2);
}
public function test_total_pages_rounds_up_for_uneven_division()
{
Asset::factory()->count(11)->create();
$this->actingAsForApi($this->user)
->getJson(route('api.assets.index', ['limit' => 5]))
->assertOk()
->assertJsonPath('total_pages', 3);
}
public function test_total_pages_is_one_when_results_fit_in_single_page()
{
Asset::factory()->count(3)->create();
$this->actingAsForApi($this->user)
->getJson(route('api.assets.index', ['limit' => 5]))
->assertOk()
->assertJsonPath('total_pages', 1);
}
}
@@ -179,6 +179,22 @@ class AssetCheckinTest extends TestCase
$this->assertHasTheseActionLogs($asset, ['create', 'checkin from']);
}
public function test_location_is_nulled_when_empty_location_id_submitted_on_checkin()
{
$rtdLocation = Location::factory()->create();
$asset = Asset::factory()->assignedToUser()->create([
'location_id' => Location::factory()->create()->id,
'rtd_location_id' => $rtdLocation->id,
]);
$this->actingAs(User::factory()->checkinAssets()->create())
->post(route('hardware.checkin.store', [$asset]), [
'location_id' => '',
]);
$this->assertNull($asset->refresh()->location_id);
}
public function test_default_location_can_be_updated_upon_checkin()
{
$location = Location::factory()->create();
@@ -305,6 +305,49 @@ class ImportAccessoriesTest extends ImportDataTestCase implements TestsPermissio
);
}
#[Test]
public function update_mode_logs_accessory_update_in_actionlog(): void
{
$this->actingAsForApi(User::factory()->superuser()->create());
$initialFile = ImportFileBuilder::new();
$initialRow = $initialFile->firstRow();
$initialImport = Import::factory()->accessory()->create([
'file_path' => $initialFile->saveToImportsDirectory(),
]);
$this->importFileResponse(['import' => $initialImport->id])->assertOk();
$accessory = Accessory::query()->where('name', $initialRow['itemName'])->sole();
$updatedRow = array_merge($initialRow, [
'orderNumber' => (string) $initialRow['orderNumber'].'-UPD',
]);
$updateFile = new ImportFileBuilder([$updatedRow]);
$updateImport = Import::factory()->accessory()->create([
'file_path' => $updateFile->saveToImportsDirectory(),
]);
$this->importFileResponse([
'import' => $updateImport->id,
'import-update' => true,
])->assertOk();
$accessory->refresh();
$this->assertEquals($updatedRow['orderNumber'], $accessory->order_number);
$updateLog = Actionlog::query()
->where('item_type', Accessory::class)
->where('item_id', $accessory->id)
->where('action_type', 'update')
->latest('id')
->first();
$this->assertNotNull($updateLog, 'Expected an update action log entry after accessory importer update mode.');
}
#[Test]
public function when_import_file_contains_empty_values(): void
{
@@ -2,6 +2,7 @@
namespace Tests\Feature\Importing\Api;
use App\Models\Actionlog as ActionLog;
use App\Models\AssetModel;
use App\Models\Category;
use App\Models\Import;
@@ -131,4 +132,46 @@ class ImportAssetModelsTest extends ImportDataTestCase implements TestsPermissio
$this->assertEquals($row['name'], $updatedAssetmodel->name);
}
#[Test]
public function update_mode_logs_asset_model_update_in_actionlog(): void
{
$this->actingAsForApi(User::factory()->superuser()->create());
$initialFile = ImportFileBuilder::new();
$initialRow = $initialFile->firstRow();
$initialImport = Import::factory()->assetmodel()->create([
'file_path' => $initialFile->saveToImportsDirectory(),
]);
$this->importFileResponse(['import' => $initialImport->id])->assertOk();
$assetModel = AssetModel::query()->where('name', $initialRow['name'])->sole();
$updatedRow = array_merge($initialRow, [
'model_number' => Str::random(),
]);
$updateFile = new ImportFileBuilder([$updatedRow]);
$updateImport = Import::factory()->assetmodel()->create([
'file_path' => $updateFile->saveToImportsDirectory(),
]);
$this->importFileResponse([
'import' => $updateImport->id,
'import-update' => true,
])->assertOk();
$assetModel->refresh();
$this->assertEquals($updatedRow['model_number'], $assetModel->model_number);
$updateLog = ActionLog::query()
->where('item_type', AssetModel::class)
->where('item_id', $assetModel->id)
->where('action_type', 'update')
->latest('id')
->first();
$this->assertNotNull($updateLog, 'Expected an update action log entry after asset model importer update mode.');
}
}
@@ -427,6 +427,58 @@ class ImportAssetsTest extends ImportDataTestCase implements TestsPermissionsReq
);
}
#[Test]
public function update_mode_logs_asset_update_in_actionlog(): void
{
$this->actingAsForApi(User::factory()->superuser()->create());
$initialFile = ImportFileBuilder::new();
$initialRow = $initialFile->firstRow();
$initialImport = Import::factory()->asset()->create([
'file_path' => $initialFile->saveToImportsDirectory(),
]);
$this->importFileResponse(['import' => $initialImport->id])->assertOk();
$asset = Asset::query()->where('asset_tag', $initialRow['tag'])->sole();
$updatedRow = array_merge($initialRow, [
'itemName' => $initialRow['itemName'].' Updated',
]);
$updateFile = new ImportFileBuilder([$updatedRow]);
$updateImport = Import::factory()->asset()->create([
'file_path' => $updateFile->saveToImportsDirectory(),
]);
$this->importFileResponse([
'import' => $updateImport->id,
'import-update' => true,
])->assertOk();
$asset->refresh();
$this->assertEquals($updatedRow['itemName'], $asset->name);
$updateLog = ActionLog::query()
->where('item_type', Asset::class)
->where('item_id', $asset->id)
->where('action_type', 'update')
->latest('id')
->first();
$this->assertNotNull($updateLog, 'Expected an update action log entry after importer update mode.');
$this->assertStringContainsString('name', (string) $updateLog->log_meta);
$checkoutLogsCount = ActionLog::query()
->where('item_type', Asset::class)
->where('item_id', $asset->id)
->where('action_type', 'checkout')
->count();
$this->assertSame(1, $checkoutLogsCount, 'Re-import update should not create a duplicate checkout log for the same assignment.');
}
#[Test]
public function custom_column_mapping(): void
{
@@ -249,6 +249,52 @@ class ImportComponentsTest extends ImportDataTestCase implements TestsPermission
$this->assertEquals($component->notes, $updatedComponent->notes);
}
#[Test]
public function update_mode_logs_component_update_in_actionlog(): void
{
$this->actingAsForApi(User::factory()->superuser()->create());
$initialFile = ImportFileBuilder::new();
$initialRow = $initialFile->firstRow();
$initialImport = Import::factory()->component()->create([
'file_path' => $initialFile->saveToImportsDirectory(),
]);
$this->importFileResponse(['import' => $initialImport->id])->assertOk();
$component = Component::query()
->where('name', $initialRow['itemName'])
->where('serial', $initialRow['serialNumber'])
->sole();
$updatedRow = array_merge($initialRow, [
'orderNumber' => (string) $initialRow['orderNumber'].'-UPD',
]);
$updateFile = new ImportFileBuilder([$updatedRow]);
$updateImport = Import::factory()->component()->create([
'file_path' => $updateFile->saveToImportsDirectory(),
]);
$this->importFileResponse([
'import' => $updateImport->id,
'import-update' => true,
])->assertOk();
$component->refresh();
$this->assertEquals($updatedRow['orderNumber'], $component->order_number);
$updateLog = ActionLog::query()
->where('item_type', Component::class)
->where('item_id', $component->id)
->where('action_type', 'update')
->latest('id')
->first();
$this->assertNotNull($updateLog, 'Expected an update action log entry after component importer update mode.');
}
#[Test]
public function custom_column_mapping(): void
{
@@ -243,6 +243,49 @@ class ImportConsumablesTest extends ImportDataTestCase implements TestsPermissio
$this->assertEquals($consumable->item_number, $updatedConsumable->item_number);
}
#[Test]
public function update_mode_logs_consumable_update_in_actionlog(): void
{
$this->actingAsForApi(User::factory()->superuser()->create());
$initialFile = ImportFileBuilder::new();
$initialRow = $initialFile->firstRow();
$initialImport = Import::factory()->consumable()->create([
'file_path' => $initialFile->saveToImportsDirectory(),
]);
$this->importFileResponse(['import' => $initialImport->id])->assertOk();
$consumable = Consumable::query()->where('name', $initialRow['itemName'])->sole();
$updatedRow = array_merge($initialRow, [
'orderNumber' => (string) $initialRow['orderNumber'].'-UPD',
]);
$updateFile = new ImportFileBuilder([$updatedRow]);
$updateImport = Import::factory()->consumable()->create([
'file_path' => $updateFile->saveToImportsDirectory(),
]);
$this->importFileResponse([
'import' => $updateImport->id,
'import-update' => true,
])->assertOk();
$consumable->refresh();
$this->assertEquals($updatedRow['orderNumber'], $consumable->order_number);
$updateLog = ActivityLog::query()
->where('item_type', Consumable::class)
->where('item_id', $consumable->id)
->where('action_type', 'update')
->latest('id')
->first();
$this->assertNotNull($updateLog, 'Expected an update action log entry after consumable importer update mode.');
}
#[Test]
public function custom_column_mapping(): void
{
@@ -277,6 +277,52 @@ class ImportLicenseTest extends ImportDataTestCase implements TestsPermissionsRe
$this->assertEquals($license->min_amt, $updatedLicense->min_amt);
}
#[Test]
public function update_mode_logs_license_update_in_actionlog(): void
{
$this->actingAsForApi(User::factory()->superuser()->create());
$initialFile = ImportFileBuilder::new();
$initialRow = $initialFile->firstRow();
$initialImport = Import::factory()->license()->create([
'file_path' => $initialFile->saveToImportsDirectory(),
]);
$this->importFileResponse(['import' => $initialImport->id])->assertOk();
$license = License::query()
->where('name', $initialRow['licenseName'])
->where('serial', $initialRow['serialNumber'])
->sole();
$updatedRow = array_merge($initialRow, [
'orderNumber' => (string) $initialRow['orderNumber'].'-UPD',
]);
$updateFile = new ImportFileBuilder([$updatedRow]);
$updateImport = Import::factory()->license()->create([
'file_path' => $updateFile->saveToImportsDirectory(),
]);
$this->importFileResponse([
'import' => $updateImport->id,
'import-update' => true,
])->assertOk();
$license->refresh();
$this->assertEquals($updatedRow['orderNumber'], $license->order_number);
$updateLog = ActivityLog::query()
->where('item_type', License::class)
->where('item_id', $license->id)
->where('action_type', 'update')
->latest('id')
->first();
$this->assertNotNull($updateLog, 'Expected an update action log entry after license importer update mode.');
}
#[Test]
public function custom_column_mapping(): void
{
@@ -2,6 +2,7 @@
namespace Tests\Feature\Importing\Api;
use App\Models\Actionlog as ActionLog;
use App\Models\Import;
use App\Models\Location;
use App\Models\User;
@@ -129,4 +130,46 @@ class ImportLocationsTest extends ImportDataTestCase implements TestsPermissions
Arr::except($updatedLocation->attributesToArray(), array_merge($updatedAttributes, $location->getDates())),
);
}
#[Test]
public function update_mode_logs_location_update_in_actionlog(): void
{
$this->actingAsForApi(User::factory()->superuser()->create());
$initialFile = ImportFileBuilder::new();
$initialRow = $initialFile->firstRow();
$initialImport = Import::factory()->locations()->create([
'file_path' => $initialFile->saveToImportsDirectory(),
]);
$this->importFileResponse(['import' => $initialImport->id])->assertOk();
$location = Location::query()->where('name', $initialRow['name'])->sole();
$updatedRow = array_merge($initialRow, [
'notes' => 'Importer update notes',
]);
$updateFile = new ImportFileBuilder([$updatedRow]);
$updateImport = Import::factory()->locations()->create([
'file_path' => $updateFile->saveToImportsDirectory(),
]);
$this->importFileResponse([
'import' => $updateImport->id,
'import-update' => true,
])->assertOk();
$location->refresh();
$this->assertEquals($updatedRow['notes'], $location->notes);
$updateLog = ActionLog::query()
->where('item_type', Location::class)
->where('item_id', $location->id)
->where('action_type', 'update')
->latest('id')
->first();
$this->assertNotNull($updateLog, 'Expected an update action log entry after location importer update mode.');
}
}
@@ -2,6 +2,7 @@
namespace Tests\Feature\Importing\Api;
use App\Models\Actionlog as ActionLog;
use App\Models\Asset;
use App\Models\Import;
use App\Models\Location;
@@ -260,6 +261,48 @@ class ImportUsersTest extends ImportDataTestCase implements TestsPermissionsRequ
);
}
#[Test]
public function update_mode_logs_user_update_in_actionlog(): void
{
$this->actingAsForApi(User::factory()->superuser()->create());
$initialFile = ImportFileBuilder::new();
$initialRow = $initialFile->firstRow();
$initialImport = Import::factory()->users()->create([
'file_path' => $initialFile->saveToImportsDirectory(),
]);
$this->importFileResponse(['import' => $initialImport->id])->assertOk();
$user = User::query()->where('username', $initialRow['username'])->sole();
$updatedRow = array_merge($initialRow, [
'position' => $initialRow['position'].' Updated',
]);
$updateFile = new ImportFileBuilder([$updatedRow]);
$updateImport = Import::factory()->users()->create([
'file_path' => $updateFile->saveToImportsDirectory(),
]);
$this->importFileResponse([
'import' => $updateImport->id,
'import-update' => true,
])->assertOk();
$user->refresh();
$this->assertEquals($updatedRow['position'], $user->jobtitle);
$updateLog = ActionLog::query()
->where('item_type', User::class)
->where('item_id', $user->id)
->where('action_type', 'update')
->latest('id')
->first();
$this->assertNotNull($updateLog, 'Expected an update action log entry after user importer update mode.');
}
/**
* Some of these should mismatch on purpose to ensure the mapping is working
*/
@@ -0,0 +1,193 @@
<?php
namespace Tests\Feature\Importing;
use App\Models\Asset;
use App\Models\AssetModel;
use App\Models\Category;
use App\Models\Company;
use App\Models\Import;
use App\Models\Location;
use App\Models\Statuslabel;
use App\Models\User;
use Illuminate\Http\UploadedFile;
use PHPUnit\Framework\Attributes\Test;
use Tests\TestCase;
class AssetImportCreatedAtTest extends TestCase
{
/**
* Test that importing assets doesn't modify created_at timestamps on existing assets
* This test addresses the reported bug where large imports caused random created_at changes
*/
#[Test]
public function existing_asset_created_at_not_modified_on_import_update()
{
// Create test data
$category = Category::factory()->create(['category_type' => 'asset']);
$location = Location::factory()->create();
$statusLabel = Statuslabel::factory()->create();
$company = Company::factory()->create();
$assetModel = AssetModel::factory()->for($category, 'category')->create();
// Create an existing asset with a known created_at date
$originalCreatedAt = now()->subDays(30)->toDateTimeString();
$asset = Asset::factory()
->for($assetModel, 'model')
->for($statusLabel, 'status')
->for($location, 'defaultLoc')
->for($company, 'company')
->create([
'asset_tag' => 'TEST-001',
'name' => 'Test Asset',
'created_at' => $originalCreatedAt,
]);
// Create CSV content that updates the existing asset
$csv = "asset tag,item name,category,status\n";
$csv .= "TEST-001,Test Asset Updated,{$category->name},{$statusLabel->name}\n";
// Perform import with update flag
$this->actingAsForApi(User::factory()->canImport()->create())
->postJson(route('api.imports.store'), [
'files' => [
$this->createFakeUploadedFile('test.csv', $csv),
],
])
->assertSuccessful();
// Import the file
$import = Import::latest()->first();
$this->actingAsForApi(User::factory()->canImport()->create())
->postJson(route('api.imports.importFile', $import->id), [
'import-type' => 'asset',
'import-update' => true,
'column-mappings' => [
'asset tag' => 'asset_tag',
'item name' => 'item_name',
'category' => 'category',
'status' => 'status',
],
])
->assertSuccessful();
// Verify the asset's created_at timestamp wasn't modified
$asset->refresh();
$this->assertEquals(
$originalCreatedAt,
$asset->created_at->toDateTimeString(),
'Asset created_at timestamp was modified during import update, which should not happen'
);
// Verify the asset was updated correctly
$this->assertEquals('Test Asset Updated', $asset->name);
}
/**
* Test that multiple successive imports don't cause timestamp drift
*/
#[Test]
public function successive_imports_maintain_created_at_consistency()
{
// Create test data
$category = Category::factory()->create(['category_type' => 'asset']);
$location = Location::factory()->create();
$statusLabel = Statuslabel::factory()->create();
$company = Company::factory()->create();
$assetModel = AssetModel::factory()->for($category, 'category')->create();
// Create multiple existing assets
$assets = collect();
for ($i = 1; $i <= 5; $i++) {
$assets->push(Asset::factory()
->for($assetModel, 'model')
->for($statusLabel, 'status')
->for($location, 'defaultLoc')
->for($company, 'company')
->create([
'asset_tag' => "TEST-{$i}",
'created_at' => now()->subDays(30 - $i),
]));
}
$originalCreatedAts = $assets->mapWithKeys(fn ($asset) => [$asset->id => $asset->created_at->toDateTimeString()])->toArray();
// Perform first import update
$csv = "asset tag,item name,category,status\n";
foreach ($assets as $asset) {
$csv .= "{$asset->asset_tag},{$asset->name},{$category->name},{$statusLabel->name}\n";
}
$this->actingAsForApi(User::factory()->canImport()->create())
->postJson(route('api.imports.store'), [
'files' => [
$this->createFakeUploadedFile('test1.csv', $csv),
],
])
->assertSuccessful();
$import1 = Import::latest()->first();
$this->actingAsForApi(User::factory()->canImport()->create())
->postJson(route('api.imports.importFile', $import1->id), [
'import-type' => 'asset',
'import-update' => true,
'column-mappings' => [
'asset tag' => 'asset_tag',
'item name' => 'item_name',
'category' => 'category',
'status' => 'status',
],
])
->assertSuccessful();
// Perform second import update
$this->actingAsForApi(User::factory()->canImport()->create())
->postJson(route('api.imports.store'), [
'files' => [
$this->createFakeUploadedFile('test2.csv', $csv),
],
])
->assertSuccessful();
$import2 = Import::latest()->first();
$this->actingAsForApi(User::factory()->canImport()->create())
->postJson(route('api.imports.importFile', $import2->id), [
'import-type' => 'asset',
'import-update' => true,
'column-mappings' => [
'asset tag' => 'asset_tag',
'item name' => 'item_name',
'category' => 'category',
'status' => 'status',
],
])
->assertSuccessful();
// Verify all assets' created_at timestamps remain unchanged
foreach ($assets as $asset) {
$asset->refresh();
$this->assertEquals(
$originalCreatedAts[$asset->id],
$asset->created_at->toDateTimeString(),
"Asset {$asset->asset_tag} created_at changed between imports"
);
}
}
/**
* Helper method to create a fake uploaded file
*/
protected function createFakeUploadedFile(string $filename, string $content)
{
$path = tempnam(sys_get_temp_dir(), 'csv');
file_put_contents($path, $content);
return new UploadedFile(
$path,
$filename,
'text/csv',
null,
true
);
}
}
+133
View File
@@ -0,0 +1,133 @@
<?php
namespace Tests\Feature\Users\Ui;
use App\Models\Accessory;
use App\Models\Asset;
use App\Models\Consumable;
use App\Models\Group;
use App\Models\LicenseSeat;
use App\Models\Location;
use App\Models\User;
use Tests\TestCase;
class ExportUsersTest extends TestCase
{
public function test_requires_permission()
{
$this->actingAs(User::factory()->create())
->get(route('users.export'))
->assertForbidden();
}
public function test_can_export_users_to_csv()
{
$creator = User::factory()->create(['first_name' => 'Han', 'last_name' => 'Solo']);
$deptManager = User::factory()->create(['first_name' => 'Mace', 'last_name' => 'Windu']);
$luke = User::factory()
->forCompany(['name' => 'Jedi'])
->forManager(['first_name' => 'Ben', 'last_name' => 'Kenobi'])
->forLocation(['name' => 'Space'])
->forDepartment(['name' => 'Lightsaber Fighting Dept', 'manager_id' => $deptManager->id])
->create([
'jobtitle' => 'Jedi Master',
'employee_num' => '789',
'first_name' => 'Luke',
'last_name' => 'Skywalker',
'display_name' => 'Master Luke',
'username' => 'lskywalker',
'email' => 'skywalker@jedi.com',
'phone' => '555-1234',
'mobile' => '555-5678',
'website' => 'https://jedi.com',
'address' => '123 Moisture Farm',
'city' => 'Anchorhead',
'state' => 'TA',
'country' => 'Outer Rim',
'zip' => '12345',
'notes' => 'Nice guy...',
'vip' => 1,
'remote' => 1,
'autoassign_licenses' => 1,
'ldap_import' => 1,
'start_date' => '2020-01-01',
'end_date' => '2030-12-31',
'created_by' => $creator->id,
]);
$luke->groups()->sync(
Group::factory()->count(2)->sequence(
['name' => 'Jedi'],
['name' => 'Jedi Dance Crew'],
)->create()
);
Asset::factory()->assignedToUser($luke)->count(2)->create();
LicenseSeat::factory()->assignedToUser($luke)->count(2)->create();
Accessory::factory()->checkedOutToUser($luke)->count(2)->create();
Consumable::factory()->checkedOutToUser($luke)->count(2)->create();
User::factory()->count(3)->create(['manager_id' => $luke->id]);
Location::factory()->count(2)->create(['manager_id' => $luke->id]);
$this->actingAs(User::factory()->viewUsers()->create())
->get(route('users.export'))
->assertOk()
->assertCsvHeader()
->assertSeeTextInStreamedResponse([
'Jedi',
'Jedi Master',
'789',
'Luke',
'Skywalker',
'Luke Skywalker',
'lskywalker',
'skywalker@jedi.com',
'Ben Kenobi',
'Space',
'Lightsaber Fighting Dept',
'2',
'Jedi, Jedi Dance Crew',
trans('general.user'),
'Nice guy...',
trans('general.yes'),
])
->assertSeePairsInStreamedResponse([
trans('admin/users/table.first_name') => 'Luke',
trans('admin/users/table.last_name') => 'Skywalker',
trans('admin/users/table.display_name') => 'Master Luke',
trans('admin/users/table.username') => 'lskywalker',
trans('admin/users/table.email') => 'skywalker@jedi.com',
trans('admin/companies/table.title') => 'Jedi',
trans('general.groups') => 'Jedi, Jedi Dance Crew',
trans('admin/users/table.title') => 'Jedi Master',
trans('general.employee_number') => '789',
trans('admin/users/table.manager') => 'Ben Kenobi',
trans('admin/users/table.location') => 'Space',
trans('general.department') => 'Lightsaber Fighting Dept',
trans('general.assets') => '2',
trans('general.accessories') => '2',
trans('general.consumables') => '2',
trans('general.licenses') => '2',
trans('general.notes') => 'Nice guy...',
trans('admin/users/table.phone') => '555-1234',
trans('admin/users/table.mobile') => '555-5678',
trans('general.website') => 'https://jedi.com',
trans('general.address') => '123 Moisture Farm',
trans('general.city') => 'Anchorhead',
trans('general.state') => 'TA',
trans('general.country') => 'Outer Rim',
trans('general.zip') => '12345',
trans('general.importer.vip') => trans('general.yes'),
trans('admin/users/general.remote') => trans('general.yes'),
trans('general.autoassign_licenses') => trans('general.yes'),
trans('general.ldap_sync') => trans('general.yes'),
trans('admin/users/table.managed_users') => '3',
trans('admin/users/table.managed_locations') => '2',
trans('admin/users/general.department_manager') => 'Mace Windu',
trans('general.created_by') => 'Han Solo',
trans('general.start_date') => '2020-01-01',
trans('general.end_date') => '2030-12-31',
]);
}
}
+55 -2
View File
@@ -161,7 +161,8 @@ trait CustomTestMacros
foreach ($needles as $needle) {
Assert::assertTrue(
$records->contains($needle)
$records->contains($needle),
"Response did not contain the expected value: {$needle}"
);
}
@@ -180,12 +181,64 @@ trait CustomTestMacros
foreach ($needles as $needle) {
Assert::assertFalse(
$records->contains($needle)
$records->contains($needle),
"Response contained unexpected value: {$needle}"
);
}
return $this;
}
);
/**
* Assert that the streamed CSV response contains a row where all given header→value pairs match simultaneously.
*
* The first row of the CSV is treated as headers. Each subsequent row is combined with those headers
* to produce an associative map, and the assertion passes if at least one row satisfies every pair.
*
* Unlike assertSeeTextInStreamedResponse, this verifies that the values appear together in the same
* row under the correct column not just anywhere in the file.
*
* Usage:
* ->assertSeePairsInStreamedResponse([
* 'First Name' => 'Luke',
* 'Last Name' => 'Skywalker',
* 'Username' => 'lskywalker',
* ])
*/
TestResponse::macro(
'assertSeePairsInStreamedResponse',
function (array $pair): self {
$records = collect(Reader::createFromString($this->streamedContent())->getRecords());
$headers = collect($records->shift());
$combined = $records->map(fn ($record) => $headers->combine($record));
Assert::assertTrue(
$combined->contains(function ($row) use ($pair) {
foreach ($pair as $key => $value) {
if (($row[$key] ?? null) !== $value) {
return false;
}
}
return true;
}),
'Response did not contain a row matching the expected pairs: '.json_encode($pair)
);
return $this;
}
);
TestResponse::macro(
'assertCsvHeader',
function () {
$this->assertHeader('content-type', 'text/csv; charset=utf-8');
return $this;
}
);
}
}
@@ -82,4 +82,75 @@ class PreserveUnauthorizedPrivilegedPermissionsActionTest extends TestCase
$this->assertArrayNotHasKey('superuser', $normalized);
$this->assertSame('1', (string) $normalized['users.view']);
}
public function test_non_admin_cannot_grant_themselves_additional_permissions(): void
{
$actor = User::factory()->editUsers()->create();
$normalized = PreserveUnauthorizedPrivilegedPermissionsAction::run(
requestedPermissions: ['users.edit' => '1', 'assets.view' => '1', 'reports.view' => '1'],
authenticatedUser: $actor,
originalPermissions: ['users.edit' => '1'],
targetUser: $actor,
);
$this->assertSame(['users.edit' => '1'], $normalized);
}
public function test_non_admin_cannot_remove_their_own_permissions(): void
{
$actor = User::factory()->editUsers()->create();
$normalized = PreserveUnauthorizedPrivilegedPermissionsAction::run(
requestedPermissions: [],
authenticatedUser: $actor,
originalPermissions: ['users.edit' => '1'],
targetUser: $actor,
);
$this->assertSame(['users.edit' => '1'], $normalized);
}
public function test_admin_cannot_modify_their_own_permissions(): void
{
$actor = User::factory()->admin()->create();
$normalized = PreserveUnauthorizedPrivilegedPermissionsAction::run(
requestedPermissions: ['admin' => '1', 'assets.view' => '1'],
authenticatedUser: $actor,
originalPermissions: ['admin' => '1'],
targetUser: $actor,
);
$this->assertSame(['admin' => '1'], $normalized);
}
public function test_superuser_can_modify_their_own_permissions(): void
{
$actor = User::factory()->superuser()->create();
$normalized = PreserveUnauthorizedPrivilegedPermissionsAction::run(
requestedPermissions: ['superuser' => '1', 'assets.view' => '1'],
authenticatedUser: $actor,
originalPermissions: ['superuser' => '1'],
targetUser: $actor,
);
$this->assertSame('1', (string) $normalized['assets.view']);
}
public function test_non_admin_can_modify_permissions_of_a_different_user(): void
{
$actor = User::factory()->editUsers()->create();
$target = User::factory()->create();
$normalized = PreserveUnauthorizedPrivilegedPermissionsAction::run(
requestedPermissions: ['assets.view' => '1'],
authenticatedUser: $actor,
originalPermissions: [],
targetUser: $target,
);
$this->assertSame('1', (string) $normalized['assets.view']);
}
}
+5 -1
View File
@@ -21,11 +21,15 @@ class HelperTest extends TestCase
public function test_parse_currency_method()
{
$this->settings->set(['default_currency' => 'USD']);
$this->settings->set(['default_currency' => 'USD', 'digit_separator' => '1,234.56']);
$this->assertSame(12.34, Helper::ParseCurrency('USD 12.34'));
$this->assertSame(8888.0, Helper::ParseCurrency('8,888.00')); // US thousands comma
$this->assertSame(8888.0, Helper::ParseCurrency('8888.00')); // US plain
$this->settings->set(['digit_separator' => '1.234,56']);
$this->assertSame(12.34, Helper::ParseCurrency('12,34'));
$this->assertSame(8888.0, Helper::ParseCurrency('8.888,00')); // EU thousands dot
$this->assertSame(8888.0, Helper::ParseCurrency('8888,00')); // EU plain
}
public function test_get_redirect_option_method()
+34 -5
View File
@@ -23,13 +23,42 @@ class SnipeModelTest extends TestCase
{
$c = new SnipeModel;
$c->purchase_cost = '';
$this->assertTrue($c->purchase_cost == null);
$this->assertNull($c->purchase_cost);
$c->purchase_cost = null;
$this->assertNull($c->purchase_cost);
$c->purchase_cost = '0.00';
$this->assertTrue($c->purchase_cost == 0.00);
$this->assertSame(0.0, (float) $c->purchase_cost);
$c->purchase_cost = '9.54';
$this->assertTrue($c->purchase_cost == 9.54);
$c->purchase_cost = '9.50';
$this->assertTrue($c->purchase_cost == 9.5);
$this->assertSame(9.54, (float) $c->purchase_cost);
$c->purchase_cost = 12.34; // already a float — no ParseCurrency needed
$this->assertSame(12.34, (float) $c->purchase_cost);
}
public function test_sets_purchase_cost_from_us_format_with_thousands()
{
$this->settings->set(['digit_separator' => '1,234.56']);
$c = new SnipeModel;
$c->purchase_cost = '8,888.00';
$this->assertSame(8888.0, (float) $c->purchase_cost);
$c->purchase_cost = '8888.00';
$this->assertSame(8888.0, (float) $c->purchase_cost);
}
public function test_sets_purchase_cost_from_eu_format_with_thousands()
{
$this->settings->set(['digit_separator' => '1.234,56']);
$c = new SnipeModel;
$c->purchase_cost = '8.888,00';
$this->assertSame(8888.0, (float) $c->purchase_cost);
$c->purchase_cost = '8888,00';
$this->assertSame(8888.0, (float) $c->purchase_cost);
$c->purchase_cost = '12,34';
$this->assertSame(12.34, (float) $c->purchase_cost);
}
public function test_nulls_blank_location_ids_but_not_others()