diff --git a/.chipperci.yml b/.chipperci.yml index 0c18b253c9..bfd1e9ecb8 100644 --- a/.chipperci.yml +++ b/.chipperci.yml @@ -6,7 +6,6 @@ environment: services: - mysql: 5.7 - - dusk: on: push: @@ -43,19 +42,3 @@ pipeline: - name: PHPUnit Feature Tests cmd: | php artisan test --testsuite Feature - -# - name: Browser Tests -# cmd: | -# cp -v .env.dusk.example .env.dusk.ci -# sed -i "s@APP_ENV=.*@APP_ENV=ci@g" .env.dusk.ci -# sed -i "s@APP_URL=.*@APP_URL=http://$BUILD_HOST:8000@g" .env.dusk.ci -# #sed -i "s@DB_HOST=.*@DB_HOST=mysql@g" .env.dusk.ci -# sed -i "s@DB_HOST=.*@DB_HOST=$DB_HOST@g" .env.dusk.ci -# sed -i "s@DB_USERNAME=.*@DB_USERNAME=chipperci@g" .env.dusk.ci -# sed -i "s@DB_DATABASE=.*@DB_DATABASE=chipperci@g" .env.dusk.ci -# sed -i "s@DB_PASSWORD=.*@DB_PASSWORD=secret@g" .env.dusk.ci -# -# php -S [::0]:8000 -t public 2>server.log & -# sleep 2 -# php artisan dusk:chrome-driver $CHROME_DRIVER -# php artisan dusk --env=ci diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml new file mode 100644 index 0000000000..a3ab759441 --- /dev/null +++ b/.github/workflows/tests.yml @@ -0,0 +1,73 @@ +name: Tests + +on: + push: + branches: + - master + - develop + pull_request: + +jobs: + tests: + runs-on: ubuntu-latest + + services: + mysql: + image: mysql:5.7 + env: + MYSQL_ALLOW_EMPTY_PASSWORD: yes + MYSQL_DATABASE: snipeit + ports: + - 33306:3306 + options: --health-cmd="mysqladmin ping" --health-interval=10s --health-timeout=5s --health-retries=3 + + strategy: + fail-fast: false + matrix: + php-version: + - "7.4" + - "8.0" + - "8.1.1" + + name: PHP ${{ matrix.php-version }} + + steps: + - uses: shivammathur/setup-php@v2 + with: + php-version: "${{ matrix.php-version }}" + coverage: none + + - uses: actions/checkout@v3 + + - name: Get Composer Cache Directory + id: composer-cache + run: | + echo "dir=$(composer config cache-files-dir)" >> $GITHUB_OUTPUT + - uses: actions/cache@v3 + with: + path: ${{ steps.composer-cache.outputs.dir }} + key: ${{ runner.os }}-${{ matrix.php-version }}-composer-${{ hashFiles('**/composer.lock') }} + restore-keys: | + ${{ runner.os }}-composer- + + - name: Copy .env + run: | + cp -v .env.testing.example .env + cp -v .env.testing.example .env.testing + + - name: Install Dependencies + run: composer install -q --no-ansi --no-interaction --no-scripts --no-progress --prefer-dist + + - name: Generate key + run: php artisan key:generate + + - name: Directory Permissions + run: chmod -R 777 storage bootstrap/cache + + - name: Execute tests (Unit and Feature tests) via PHPUnit + env: + DB_CONNECTION: mysql + DB_DATABASE: snipeit + DB_PORT: ${{ job.services.mysql.ports[3306] }} + DB_USERNAME: root + run: php artisan test --parallel diff --git a/.gitignore b/.gitignore index f0e9bfcec2..bf8360ba24 100755 --- a/.gitignore +++ b/.gitignore @@ -1,8 +1,6 @@ .couscous .DS_Store .env -.env.dusk.* -!.env.dusk.example .env.testing phpstan.neon .idea diff --git a/TESTING.md b/TESTING.md index 3f0e588105..3a2f4e5385 100644 --- a/TESTING.md +++ b/TESTING.md @@ -9,7 +9,39 @@ Before starting, follow the [instructions](README.md#installation) for installin Before attempting to run the test suite copy the example environment file for tests and update the values to match your environment: `cp .env.testing.example .env.testing` -> Since the data in the database is flushed after each test it is recommended you create a separate mysql database for specifically for tests + +The following should work for running tests in memory with sqlite: +``` +# -------------------------------------------- +# REQUIRED: BASIC APP SETTINGS +# -------------------------------------------- +APP_ENV=testing +APP_DEBUG=true +APP_KEY=base64:glJpcM7BYwWiBggp3SQ/+NlRkqsBQMaGEOjemXqJzOU= +APP_URL=http://localhost:8000 +APP_TIMEZONE='UTC' +APP_LOCALE=en + +# -------------------------------------------- +# REQUIRED: DATABASE SETTINGS +# -------------------------------------------- +DB_CONNECTION=sqlite_testing +#DB_HOST=127.0.0.1 +#DB_PORT=3306 +#DB_DATABASE=null +#DB_USERNAME=null +#DB_PASSWORD=null +``` + +To use MySQL you should update the `DB_` variables to match your local test database: +``` +DB_CONNECTION=mysql +DB_HOST=127.0.0.1 +DB_PORT=3306 +DB_DATABASE={} +DB_USERNAME={} +DB_PASSWORD={} +``` Now you are ready to run the entire test suite from your terminal: @@ -18,34 +50,3 @@ Now you are ready to run the entire test suite from your terminal: To run individual test files, you can pass the path to the test that you want to run: `php artisan test tests/Unit/AccessoryTest.php` - -## Browser Tests - -Browser tests are run via [Laravel Dusk](https://laravel.com/docs/8.x/dusk) and require Google Chrome to be installed. - -Before attempting to run Dusk tests copy the example environment file for Dusk and update the values to match your environment: - -`cp .env.dusk.example .env.dusk.local` -> `local` refers to the value of `APP_ENV` in your `.env` so if you have it set to `dev` then the file should be named `.env.dusk.dev`. - -**Important**: Dusk tests cannot be run using an in-memory SQLite database. Additionally, the Dusk test suite uses the `DatabaseMigrations` trait which will leave the database in a fresh state after running. Therefore, it is recommended that you create a test database and point `DB_DATABASE` in `.env.dusk.local` to it. - -### Running Browser Tests - -Your application needs to be configured and up and running in order for the browser tests to actually run. When running the tests locally, you can start the application using the following command: - -`php artisan serve` - -Now you are ready to run the test suite. Use the following command from another terminal tab or window: - -`php artisan dusk` - -To run individual test files, you can pass the path to the test that you want to run: - -`php artisan dusk tests/Browser/LoginTest.php` - -If you get an error when attempting to run Dusk tests that says `Couldn't connect to server` run: - -`php artisan dusk:chrome-driver --detect` - -This command will install the specific ChromeDriver Dusk needs for your operating system and Chrome version. diff --git a/app/Events/CheckoutableCheckedIn.php b/app/Events/CheckoutableCheckedIn.php index 9609f7d415..48aed2a64d 100644 --- a/app/Events/CheckoutableCheckedIn.php +++ b/app/Events/CheckoutableCheckedIn.php @@ -15,18 +15,20 @@ class CheckoutableCheckedIn public $checkedInBy; public $note; public $action_date; // Date setted in the hardware.checkin view at the checkin_at input, for the action log + public $originalValues; /** * Create a new event instance. * * @return void */ - public function __construct($checkoutable, $checkedOutTo, User $checkedInBy, $note, $action_date = null) + public function __construct($checkoutable, $checkedOutTo, User $checkedInBy, $note, $action_date = null, $originalValues = []) { $this->checkoutable = $checkoutable; $this->checkedOutTo = $checkedOutTo; $this->checkedInBy = $checkedInBy; $this->note = $note; $this->action_date = $action_date ?? date('Y-m-d'); + $this->originalValues = $originalValues; } } diff --git a/app/Events/CheckoutableCheckedOut.php b/app/Events/CheckoutableCheckedOut.php index 30f70ca0d8..3f215bd3bc 100644 --- a/app/Events/CheckoutableCheckedOut.php +++ b/app/Events/CheckoutableCheckedOut.php @@ -14,17 +14,19 @@ class CheckoutableCheckedOut public $checkedOutTo; public $checkedOutBy; public $note; + public $originalValues; /** * Create a new event instance. * * @return void */ - public function __construct($checkoutable, $checkedOutTo, User $checkedOutBy, $note) + public function __construct($checkoutable, $checkedOutTo, User $checkedOutBy, $note, $originalValues = []) { $this->checkoutable = $checkoutable; $this->checkedOutTo = $checkedOutTo; $this->checkedOutBy = $checkedOutBy; $this->note = $note; + $this->originalValues = $originalValues; } } diff --git a/app/Http/Controllers/Accessories/AccessoriesFilesController.php b/app/Http/Controllers/Accessories/AccessoriesFilesController.php index ef701020d8..4f166b06d5 100644 --- a/app/Http/Controllers/Accessories/AccessoriesFilesController.php +++ b/app/Http/Controllers/Accessories/AccessoriesFilesController.php @@ -161,22 +161,19 @@ class AccessoriesFilesController extends Controller ->header('Content-Type', 'text/plain'); } else { + // Display the file inline + if (request('inline') == 'true') { + $headers = [ + 'Content-Disposition' => 'inline', + ]; + return Storage::download($file, $log->filename, $headers); + } + + // We have to override the URL stuff here, since local defaults in Laravel's Flysystem // won't work, as they're not accessible via the web if (config('filesystems.default') == 'local') { // TODO - is there any way to fix this at the StorageHelper layer? return StorageHelper::downloader($file); - } else { - if ($download != 'true') { - \Log::debug('display the file'); - if ($contents = file_get_contents(Storage::url($file))) { // TODO - this will fail on private S3 files or large public ones - return Response::make(Storage::url($file)->header('Content-Type', mime_content_type($file))); - } - - return JsonResponse::create(['error' => 'Failed validation: '], 500); - } - - return StorageHelper::downloader($file); - } } } diff --git a/app/Http/Controllers/Api/AssetsController.php b/app/Http/Controllers/Api/AssetsController.php index e8f37d8574..39b5ee238e 100644 --- a/app/Http/Controllers/Api/AssetsController.php +++ b/app/Http/Controllers/Api/AssetsController.php @@ -905,6 +905,7 @@ class AssetsController extends Controller $asset->expected_checkin = null; $asset->last_checkout = null; + $asset->last_checkin = now(); $asset->assigned_to = null; $asset->assignedTo()->disassociate($asset); $asset->accepted = null; @@ -924,10 +925,14 @@ class AssetsController extends Controller } $checkin_at = $request->filled('checkin_at') ? $request->input('checkin_at').' '. date('H:i:s') : date('Y-m-d H:i:s'); + $originalValues = $asset->getRawOriginal(); + if (($request->filled('checkin_at')) && ($request->get('checkin_at') != date('Y-m-d'))) { + $originalValues['action_date'] = $checkin_at; + } if ($asset->save()) { - event(new CheckoutableCheckedIn($asset, $target, Auth::user(), $request->input('note'), $checkin_at)); + event(new CheckoutableCheckedIn($asset, $target, Auth::user(), $request->input('note'), $checkin_at, $originalValues)); return response()->json(Helper::formatStandardApiResponse('success', ['asset'=> e($asset->asset_tag)], trans('admin/hardware/message.checkin.success'))); } diff --git a/app/Http/Controllers/Api/UsersController.php b/app/Http/Controllers/Api/UsersController.php index 7b22f3af4b..3b76317327 100644 --- a/app/Http/Controllers/Api/UsersController.php +++ b/app/Http/Controllers/Api/UsersController.php @@ -75,7 +75,6 @@ class UsersController extends Controller ])->with('manager', 'groups', 'userloc', 'company', 'department', 'assets', 'licenses', 'accessories', 'consumables', 'createdBy',) ->withCount('assets as assets_count', 'licenses as licenses_count', 'accessories as accessories_count', 'consumables as consumables_count'); - $users = Company::scopeCompanyables($users); if ($request->filled('activated')) { @@ -271,6 +270,8 @@ class UsersController extends Controller } elseif (($request->filled('all')) && ($request->input('all') == 'true')) { $users = $users->withTrashed(); } + + $users = Company::scopeCompanyables($users); $total = $users->count(); $users = $users->skip($offset)->take($limit)->get(); diff --git a/app/Http/Controllers/AssetModelsController.php b/app/Http/Controllers/AssetModelsController.php index 99188f27f7..3bb72413b0 100755 --- a/app/Http/Controllers/AssetModelsController.php +++ b/app/Http/Controllers/AssetModelsController.php @@ -288,6 +288,7 @@ class AssetModelsController extends Controller return view('models/edit') ->with('depreciation_list', Helper::depreciationList()) ->with('item', $model) + ->with('model_id', $model_to_clone->id) ->with('clone_model', $model_to_clone); } diff --git a/app/Http/Controllers/AssetModelsFilesController.php b/app/Http/Controllers/AssetModelsFilesController.php index a68ef482cc..9889cd29ca 100644 --- a/app/Http/Controllers/AssetModelsFilesController.php +++ b/app/Http/Controllers/AssetModelsFilesController.php @@ -78,7 +78,7 @@ class AssetModelsFilesController extends Controller * @return View * @throws \Illuminate\Auth\Access\AuthorizationException */ - public function show($modelId = null, $fileId = null, $download = true) + public function show($modelId = null, $fileId = null) { $model = AssetModel::find($modelId); // the asset is valid @@ -99,12 +99,13 @@ class AssetModelsFilesController extends Controller ->header('Content-Type', 'text/plain'); } - if ($download != 'true') { - if ($contents = file_get_contents(Storage::url($file))) { - return Response::make(Storage::url($file)->header('Content-Type', mime_content_type($file))); - } + if (request('inline') == 'true') { - return JsonResponse::create(['error' => 'Failed validation: '], 500); + $headers = [ + 'Content-Disposition' => 'inline', + ]; + + return Storage::download($file, $log->filename, $headers); } return StorageHelper::downloader($file); diff --git a/app/Http/Controllers/Assets/AssetCheckinController.php b/app/Http/Controllers/Assets/AssetCheckinController.php index fd5cee30ea..4fe10e895f 100644 --- a/app/Http/Controllers/Assets/AssetCheckinController.php +++ b/app/Http/Controllers/Assets/AssetCheckinController.php @@ -68,6 +68,7 @@ class AssetCheckinController extends Controller $asset->expected_checkin = null; $asset->last_checkout = null; + $asset->last_checkin = now(); $asset->assigned_to = null; $asset->assignedTo()->disassociate($asset); $asset->assigned_type = null; @@ -108,8 +109,11 @@ class AssetCheckinController extends Controller } } + $originalValues = $asset->getRawOriginal(); + $checkin_at = date('Y-m-d H:i:s'); if (($request->filled('checkin_at')) && ($request->get('checkin_at') != date('Y-m-d'))) { + $originalValues['action_date'] = $checkin_at; $checkin_at = $request->get('checkin_at'); } @@ -132,7 +136,7 @@ class AssetCheckinController extends Controller // Was the asset updated? if ($asset->save()) { - event(new CheckoutableCheckedIn($asset, $target, Auth::user(), $request->input('note'), $checkin_at)); + event(new CheckoutableCheckedIn($asset, $target, Auth::user(), $request->input('note'), $checkin_at, $originalValues)); if ((isset($user)) && ($backto == 'user')) { return redirect()->route('users.show', $user->id)->with('success', trans('admin/hardware/message.checkin.success')); diff --git a/app/Http/Controllers/Assets/AssetFilesController.php b/app/Http/Controllers/Assets/AssetFilesController.php index 2c7d6ff9ec..a4e0605999 100644 --- a/app/Http/Controllers/Assets/AssetFilesController.php +++ b/app/Http/Controllers/Assets/AssetFilesController.php @@ -79,7 +79,7 @@ class AssetFilesController extends Controller * @return View * @throws \Illuminate\Auth\Access\AuthorizationException */ - public function show($assetId = null, $fileId = null, $download = true) + public function show($assetId = null, $fileId = null) { $asset = Asset::find($assetId); // the asset is valid @@ -103,12 +103,13 @@ class AssetFilesController extends Controller ->header('Content-Type', 'text/plain'); } - if ($download != 'true') { - if ($contents = file_get_contents(Storage::url($file))) { - return Response::make(Storage::url($file)->header('Content-Type', mime_content_type($file))); - } + if (request('inline') == 'true') { - return JsonResponse::create(['error' => 'Failed validation: '], 500); + $headers = [ + 'Content-Disposition' => 'inline', + ]; + + return Storage::download($file, $log->filename, $headers); } return StorageHelper::downloader($file); diff --git a/app/Http/Controllers/Assets/BulkAssetsController.php b/app/Http/Controllers/Assets/BulkAssetsController.php index a744db5788..932176286f 100644 --- a/app/Http/Controllers/Assets/BulkAssetsController.php +++ b/app/Http/Controllers/Assets/BulkAssetsController.php @@ -58,8 +58,14 @@ class BulkAssetsController extends Controller switch ($request->input('bulk_actions')) { case 'labels': $this->authorize('view', Asset::class); + $assets_found = Asset::find($asset_ids); + + if ($assets_found->isEmpty()){ + return redirect()->back(); + } + return (new Label) - ->with('assets', Asset::find($asset_ids)) + ->with('assets', $assets_found) ->with('settings', Setting::getSettings()) ->with('bulkedit', true) ->with('count', 0); diff --git a/app/Http/Controllers/Components/ComponentsFilesController.php b/app/Http/Controllers/Components/ComponentsFilesController.php index 3fc93b74e5..d46dc05f9c 100644 --- a/app/Http/Controllers/Components/ComponentsFilesController.php +++ b/app/Http/Controllers/Components/ComponentsFilesController.php @@ -132,7 +132,7 @@ class ComponentsFilesController extends Controller * @return \Symfony\Component\HttpFoundation\Response * @throws \Illuminate\Auth\Access\AuthorizationException */ - public function show($componentId = null, $fileId = null, $download = true) + public function show($componentId = null, $fileId = null) { \Log::debug('Private filesystem is: '.config('filesystems.default')); $component = Component::find($componentId); @@ -157,21 +157,17 @@ class ComponentsFilesController extends Controller ->header('Content-Type', 'text/plain'); } else { + // Display the file inline + if (request('inline') == 'true') { + $headers = [ + 'Content-Disposition' => 'inline', + ]; + return Storage::download($file, $log->filename, $headers); + } + if (config('filesystems.default') == 'local') { // TODO - is there any way to fix this at the StorageHelper layer? return StorageHelper::downloader($file); - } else { - if ($download != 'true') { - \Log::debug('display the file'); - if ($contents = file_get_contents(Storage::url($file))) { // TODO - this will fail on private S3 files or large public ones - return Response::make(Storage::url($file)->header('Content-Type', mime_content_type($file))); - } - - return JsonResponse::create(['error' => 'Failed validation: '], 500); - } - - return StorageHelper::downloader($file); - - } + } } } diff --git a/app/Http/Controllers/Consumables/ConsumablesFilesController.php b/app/Http/Controllers/Consumables/ConsumablesFilesController.php index 9b4007a43b..def1e0d8ed 100644 --- a/app/Http/Controllers/Consumables/ConsumablesFilesController.php +++ b/app/Http/Controllers/Consumables/ConsumablesFilesController.php @@ -131,7 +131,7 @@ class ConsumablesFilesController extends Controller * @return \Symfony\Consumable\HttpFoundation\Response * @throws \Illuminate\Auth\Access\AuthorizationException */ - public function show($consumableId = null, $fileId = null, $download = true) + public function show($consumableId = null, $fileId = null) { $consumable = Consumable::find($consumableId); @@ -155,22 +155,19 @@ class ConsumablesFilesController extends Controller ->header('Content-Type', 'text/plain'); } else { + // Display the file inline + if (request('inline') == 'true') { + $headers = [ + 'Content-Disposition' => 'inline', + ]; + return Storage::download($file, $log->filename, $headers); + } + + // We have to override the URL stuff here, since local defaults in Laravel's Flysystem // won't work, as they're not accessible via the web if (config('filesystems.default') == 'local') { // TODO - is there any way to fix this at the StorageHelper layer? return StorageHelper::downloader($file); - } else { - if ($download != 'true') { - \Log::debug('display the file'); - if ($contents = file_get_contents(Storage::url($file))) { // TODO - this will fail on private S3 files or large public ones - return Response::make(Storage::url($file)->header('Content-Type', mime_content_type($file))); - } - - return JsonResponse::create(['error' => 'Failed validation: '], 500); - } - - return StorageHelper::downloader($file); - } } } diff --git a/app/Http/Controllers/Licenses/LicenseFilesController.php b/app/Http/Controllers/Licenses/LicenseFilesController.php index d457d4983a..442635669b 100644 --- a/app/Http/Controllers/Licenses/LicenseFilesController.php +++ b/app/Http/Controllers/Licenses/LicenseFilesController.php @@ -152,21 +152,19 @@ class LicenseFilesController extends Controller ->header('Content-Type', 'text/plain'); } else { + if (request('inline') == 'true') { + + $headers = [ + 'Content-Disposition' => 'inline', + ]; + + return Storage::download($file, $log->filename, $headers); + } + // We have to override the URL stuff here, since local defaults in Laravel's Flysystem // won't work, as they're not accessible via the web if (config('filesystems.default') == 'local') { // TODO - is there any way to fix this at the StorageHelper layer? return StorageHelper::downloader($file); - } else { - if ($download != 'true') { - \Log::debug('display the file'); - if ($contents = file_get_contents(Storage::url($file))) { // TODO - this will fail on private S3 files or large public ones - return Response::make(Storage::url($file)->header('Content-Type', mime_content_type($file))); - } - - return JsonResponse::create(['error' => 'Failed validation: '], 500); - } - - return StorageHelper::downloader($file); } } diff --git a/app/Http/Controllers/ReportsController.php b/app/Http/Controllers/ReportsController.php index c9a88ea0f1..6bb7aa35cf 100644 --- a/app/Http/Controllers/ReportsController.php +++ b/app/Http/Controllers/ReportsController.php @@ -545,6 +545,10 @@ class ReportsController extends Controller $header[] = trans('admin/hardware/table.checkout_date'); } + if ($request->filled('checkin_date')) { + $header[] = trans('admin/hardware/table.last_checkin_date'); + } + if ($request->filled('expected_checkin')) { $header[] = trans('admin/hardware/form.expected_checkin'); } @@ -651,6 +655,14 @@ class ReportsController extends Controller $assets->whereBetween('assets.last_checkout', [$checkout_start, $checkout_end]); } + if (($request->filled('checkin_date_start'))) { + $assets->whereBetween('last_checkin', [ + Carbon::parse($request->input('checkin_date_start'))->startOfDay(), + // use today's date is `checkin_date_end` is not provided + Carbon::parse($request->input('checkin_date_end', now()))->endOfDay(), + ]); + } + if (($request->filled('expected_checkin_start')) && ($request->filled('expected_checkin_end'))) { $assets->whereBetween('assets.expected_checkin', [$request->input('expected_checkin_start'), $request->input('expected_checkin_end')]); } @@ -835,6 +847,12 @@ class ReportsController extends Controller $row[] = ($asset->last_checkout) ? $asset->last_checkout : ''; } + if ($request->filled('checkin_date')) { + $row[] = ($asset->last_checkin) + ? Carbon::parse($asset->last_checkin)->format('Y-m-d') + : ''; + } + if ($request->filled('expected_checkin')) { $row[] = ($asset->expected_checkin) ? $asset->expected_checkin : ''; } @@ -1003,7 +1021,7 @@ class ReportsController extends Controller $assetsForReport = $acceptances ->filter(function ($acceptance) { - return $acceptance->checkoutable_type == 'App\Models\Asset'; + return $acceptance->checkoutable_type == 'App\Models\Asset' && $acceptance->checkoutable->checkedOutToUser(); }) ->map(function($acceptance) { return ['assetItem' => $acceptance->checkoutable, 'acceptance' => $acceptance]; diff --git a/app/Http/Controllers/Users/UserFilesController.php b/app/Http/Controllers/Users/UserFilesController.php index cb49396324..62726e9827 100644 --- a/app/Http/Controllers/Users/UserFilesController.php +++ b/app/Http/Controllers/Users/UserFilesController.php @@ -2,6 +2,7 @@ namespace App\Http\Controllers\Users; +use App\Helpers\StorageHelper; use App\Http\Controllers\Controller; use App\Http\Requests\AssetFileRequest; use App\Models\Actionlog; @@ -139,18 +140,25 @@ class UserFilesController extends Controller // the license is valid if (isset($user->id)) { + $this->authorize('view', $user); $log = Actionlog::find($fileId); - $file = $log->get_src('users'); - return Response::download($file); //FIXME this doesn't use the new StorageHelper yet, but it's complicated... + // Display the file inline + if (request('inline') == 'true') { + $headers = [ + 'Content-Disposition' => 'inline', + ]; + return Storage::download('private_uploads/users/'.$log->filename, $log->filename, $headers); + } + + return Storage::download('private_uploads/users/'.$log->filename); + } - // Prepare the error message - $error = trans('admin/users/message.user_not_found', ['id' => $userId]); - // Redirect to the licence management page - return redirect()->route('users.index')->with('error', $error); + // Redirect to the user management page if the user doesn't exist + return redirect()->route('users.index')->with('error', trans('admin/users/message.user_not_found', ['id' => $userId])); } } diff --git a/app/Http/Requests/SaveUserRequest.php b/app/Http/Requests/SaveUserRequest.php index 98e561549e..b38193c15a 100644 --- a/app/Http/Requests/SaveUserRequest.php +++ b/app/Http/Requests/SaveUserRequest.php @@ -32,6 +32,7 @@ class SaveUserRequest extends FormRequest public function rules() { $rules = [ + 'department_id' => 'nullable|exists:departments,id', 'manager_id' => 'nullable|exists:users,id', ]; diff --git a/app/Http/Transformers/ActionlogsTransformer.php b/app/Http/Transformers/ActionlogsTransformer.php index b2634135c3..ecabf086e8 100644 --- a/app/Http/Transformers/ActionlogsTransformer.php +++ b/app/Http/Transformers/ActionlogsTransformer.php @@ -3,6 +3,7 @@ namespace App\Http\Transformers; use App\Helpers\Helper; use App\Models\Actionlog; +use App\Models\CustomField; use App\Models\Setting; use App\Models\Company; use App\Models\Supplier; @@ -42,6 +43,7 @@ class ActionlogsTransformer public function transformActionlog (Actionlog $actionlog, $settings = null) { $icon = $actionlog->present()->icon(); + $custom_field = CustomField::all(); if ($actionlog->filename!='') { $icon = e(\App\Helpers\Helper::filetype_icon($actionlog->filename)); } @@ -54,9 +56,18 @@ class ActionlogsTransformer if ($meta_array) { foreach ($meta_array as $fieldname => $fieldata) { - $clean_meta[$fieldname]['old'] = $this->clean_field($fieldata->old); - $clean_meta[$fieldname]['new'] = $this->clean_field($fieldata->new); + if( str_starts_with($fieldname, '_snipeit_')){ + if( $custom_field->where('db_column', '=', $fieldname)->where('field_encrypted', true)){ + $clean_meta[$fieldname]['old'] = "encrypted"; + $clean_meta[$fieldname]['new'] = "encrypted"; + } + } + else { + $clean_meta[$fieldname]['old'] = $this->clean_field($fieldata->old); + $clean_meta[$fieldname]['new'] = $this->clean_field($fieldata->new); + } } + } $clean_meta= $this->changedInfo($clean_meta); } @@ -122,6 +133,9 @@ class ActionlogsTransformer 'action_date' => ($actionlog->action_date) ? Helper::getFormattedDateObject($actionlog->action_date, 'datetime'): Helper::getFormattedDateObject($actionlog->created_at, 'datetime'), ]; +// \Log::info("Clean Meta is: ".print_r($clean_meta,true)); + //dd($array); + return $array; } @@ -142,39 +156,70 @@ class ActionlogsTransformer * @param array $clean_meta * @return array */ + public function changedInfo(array $clean_meta) - { + { $location = Location::withTrashed()->get(); + $supplier = Supplier::withTrashed()->get(); + $model = AssetModel::withTrashed()->get(); + $company = Company::get(); + if(array_key_exists('rtd_location_id',$clean_meta)) { - $clean_meta['rtd_location_id']['old'] = $clean_meta['rtd_location_id']['old'] ? "[id: ".$clean_meta['rtd_location_id']['old']."] ". Location::find($clean_meta['rtd_location_id']['old'])->name : trans('general.unassigned'); - $clean_meta['rtd_location_id']['new'] = $clean_meta['rtd_location_id']['new'] ? "[id: ".$clean_meta['rtd_location_id']['new']."] ". Location::find($clean_meta['rtd_location_id']['new'])->name : trans('general.unassigned'); + $clean_meta['rtd_location_id']['old'] = $clean_meta['rtd_location_id']['old'] ? "[id: ".$clean_meta['rtd_location_id']['old']."] ". $location->find($clean_meta['rtd_location_id']['old'])->name : trans('general.unassigned'); + $clean_meta['rtd_location_id']['new'] = $clean_meta['rtd_location_id']['new'] ? "[id: ".$clean_meta['rtd_location_id']['new']."] ". $location->find($clean_meta['rtd_location_id']['new'])->name : trans('general.unassigned'); $clean_meta['Default Location'] = $clean_meta['rtd_location_id']; unset($clean_meta['rtd_location_id']); } if(array_key_exists('location_id', $clean_meta)) { - $clean_meta['location_id']['old'] = $clean_meta['location_id']['old'] ? "[id: ".$clean_meta['location_id']['old']."] ".Location::find($clean_meta['location_id']['old'])->name : trans('general.unassigned'); - $clean_meta['location_id']['new'] = $clean_meta['location_id']['new'] ? "[id: ".$clean_meta['location_id']['new']."] ".Location::find($clean_meta['location_id']['new'])->name : trans('general.unassigned'); + $clean_meta['location_id']['old'] = $clean_meta['location_id']['old'] ? "[id: ".$clean_meta['location_id']['old']."] ".$location->find($clean_meta['location_id']['old'])->name : trans('general.unassigned'); + $clean_meta['location_id']['new'] = $clean_meta['location_id']['new'] ? "[id: ".$clean_meta['location_id']['new']."] ".$location->find($clean_meta['location_id']['new'])->name : trans('general.unassigned'); $clean_meta['Current Location'] = $clean_meta['location_id']; unset($clean_meta['location_id']); } if(array_key_exists('model_id', $clean_meta)) { - $clean_meta['model_id']['old'] = "[id: ".$clean_meta['model_id']['old']."] ".AssetModel::withTrashed()->find($clean_meta['model_id']['old'])->name; - $clean_meta['model_id']['new'] = "[id: ".$clean_meta['model_id']['new']."] ".AssetModel::withTrashed()->find($clean_meta['model_id']['new'])->name; /* model is required at asset creation */ + + $oldModel = $model->find($clean_meta['model_id']['old']); + $oldModelName = $oldModel->name ?? trans('admin/models/message.deleted'); + + $newModel = $model->find($clean_meta['model_id']['new']); + $newModelName = $newModel->name ?? trans('admin/models/message.deleted'); + + $clean_meta['model_id']['old'] = "[id: ".$clean_meta['model_id']['old']."] ".$oldModelName; + $clean_meta['model_id']['new'] = "[id: ".$clean_meta['model_id']['new']."] ".$newModelName; /** model is required at asset creation */ + $clean_meta['Model'] = $clean_meta['model_id']; unset($clean_meta['model_id']); } if(array_key_exists('company_id', $clean_meta)) { - $clean_meta['company_id']['old'] = $clean_meta['company_id']['old'] ? "[id: ".$clean_meta['company_id']['old']."]".Company::find($clean_meta['company_id']['old'])->name : trans('general.unassigned'); - $clean_meta['company_id']['new'] = $clean_meta['company_id']['new'] ? "[id: ".$clean_meta['company_id']['new']."] ".Company::find($clean_meta['company_id']['new'])->name : trans('general.unassigned'); + + $oldCompany = $company->find($clean_meta['company_id']['old']); + $oldCompanyName = $oldCompany->name ?? trans('admin/companies/message.deleted'); + + $newCompany = $company->find($clean_meta['company_id']['new']); + $newCompanyName = $newCompany->name ?? trans('admin/companies/message.deleted'); + + $clean_meta['company_id']['old'] = $clean_meta['company_id']['old'] ? "[id: ".$clean_meta['company_id']['old']."] ". $oldCompanyName : trans('general.unassigned'); + $clean_meta['company_id']['new'] = $clean_meta['company_id']['new'] ? "[id: ".$clean_meta['company_id']['new']."] ". $newCompanyName : trans('general.unassigned'); $clean_meta['Company'] = $clean_meta['company_id']; unset($clean_meta['company_id']); } if(array_key_exists('supplier_id', $clean_meta)) { - $clean_meta['supplier_id']['old'] = $clean_meta['supplier_id']['old'] ? "[id: ".$clean_meta['supplier_id']['old']."] ".Supplier::find($clean_meta['supplier_id']['old'])->name : trans('general.unassigned'); - $clean_meta['supplier_id']['new'] = $clean_meta['supplier_id']['new'] ? "[id: ".$clean_meta['supplier_id']['new']."] ".Supplier::find($clean_meta['supplier_id']['new'])->name : trans('general.unassigned'); + + $oldSupplier = $supplier->find($clean_meta['supplier_id']['old']); + $oldSupplierName = $oldSupplier->name ?? trans('admin/suppliers/message.deleted'); + + $newSupplier = $supplier->find($clean_meta['supplier_id']['new']); + $newSupplierName = $newSupplier->name ?? trans('admin/suppliers/message.deleted'); + + $clean_meta['supplier_id']['old'] = $clean_meta['supplier_id']['old'] ? "[id: ".$clean_meta['supplier_id']['old']."] ". $oldSupplierName : trans('general.unassigned'); + $clean_meta['supplier_id']['new'] = $clean_meta['supplier_id']['new'] ? "[id: ".$clean_meta['supplier_id']['new']."] ". $newSupplierName : trans('general.unassigned'); $clean_meta['Supplier'] = $clean_meta['supplier_id']; unset($clean_meta['supplier_id']); } + if(array_key_exists('asset_eol_date', $clean_meta)) { + $clean_meta['EOL date'] = $clean_meta['asset_eol_date']; + unset($clean_meta['asset_eol_date']); + } return $clean_meta; diff --git a/app/Listeners/LogListener.php b/app/Listeners/LogListener.php index d14b674364..2ffee3e3c3 100644 --- a/app/Listeners/LogListener.php +++ b/app/Listeners/LogListener.php @@ -33,7 +33,7 @@ class LogListener */ public function onCheckoutableCheckedIn(CheckoutableCheckedIn $event) { - $event->checkoutable->logCheckin($event->checkedOutTo, $event->note, $event->action_date); + $event->checkoutable->logCheckin($event->checkedOutTo, $event->note, $event->action_date, $event->originalValues); } /** @@ -46,7 +46,7 @@ class LogListener */ public function onCheckoutableCheckedOut(CheckoutableCheckedOut $event) { - $event->checkoutable->logCheckout($event->note, $event->checkedOutTo, $event->checkoutable->last_checkout); + $event->checkoutable->logCheckout($event->note, $event->checkedOutTo, $event->checkoutable->last_checkout, $event->originalValues); } /** diff --git a/app/Models/Asset.php b/app/Models/Asset.php index 6792cf2859..4308c3b0f1 100644 --- a/app/Models/Asset.php +++ b/app/Models/Asset.php @@ -73,6 +73,7 @@ class Asset extends Depreciable protected $casts = [ 'purchase_date' => 'date', 'last_checkout' => 'datetime', + 'last_checkin' => 'datetime', 'expected_checkin' => 'date', 'last_audit_date' => 'datetime', 'next_audit_date' => 'date', @@ -82,7 +83,6 @@ class Asset extends Depreciable 'location_id' => 'integer', 'rtd_company_id' => 'integer', 'supplier_id' => 'integer', - 'byod' => 'boolean', 'created_at' => 'datetime', 'updated_at' => 'datetime', 'deleted_at' => 'datetime', @@ -105,6 +105,7 @@ class Asset extends Depreciable 'purchase_cost' => 'numeric|nullable|gte:0', 'supplier_id' => 'exists:suppliers,id|nullable', 'asset_eol_date' => 'date|max:10|min:10|nullable', + 'byod' => 'boolean', ]; /** @@ -332,6 +333,13 @@ class Asset extends Depreciable } } + $originalValues = $this->getRawOriginal(); + + // attempt to detect change in value if different from today's date + if ($checkout_at && strpos($checkout_at, date('Y-m-d')) === false) { + $originalValues['action_date'] = date('Y-m-d H:i:s'); + } + if ($this->save()) { if (is_int($admin)) { $checkedOutBy = User::findOrFail($admin); @@ -340,7 +348,7 @@ class Asset extends Depreciable } else { $checkedOutBy = Auth::user(); } - event(new CheckoutableCheckedOut($this, $target, $checkedOutBy, $note)); + event(new CheckoutableCheckedOut($this, $target, $checkedOutBy, $note, $originalValues)); $this->increment('checkout_counter', 1); diff --git a/app/Models/License.php b/app/Models/License.php index 162b3d662a..44f1f45b70 100755 --- a/app/Models/License.php +++ b/app/Models/License.php @@ -323,7 +323,10 @@ class License extends Depreciable */ public function checkin_email() { - return $this->category->checkin_email; + if ($this->category) { + return $this->category->checkin_email; + } + return false; } /** @@ -335,7 +338,11 @@ class License extends Depreciable */ public function requireAcceptance() { - return $this->category->require_acceptance; + if ($this->category) { + return $this->category->require_acceptance; + } + + return false; } /** @@ -348,14 +355,16 @@ class License extends Depreciable */ public function getEula() { - - if ($this->category->eula_text) { - return Helper::parseEscapedMarkedown($this->category->eula_text); - } elseif ($this->category->use_default_eula == '1') { - return Helper::parseEscapedMarkedown(Setting::getSettings()->default_eula_text); - } else { - return false; + if ($this->category){ + if ($this->category->eula_text) { + return Helper::parseEscapedMarkedown($this->category->eula_text); + } elseif ($this->category->use_default_eula == '1') { + return Helper::parseEscapedMarkedown(Setting::getSettings()->default_eula_text); + } } + + return false; + } /** diff --git a/app/Models/LicenseSeat.php b/app/Models/LicenseSeat.php index d2a99d3c56..8a51c0c9cf 100755 --- a/app/Models/LicenseSeat.php +++ b/app/Models/LicenseSeat.php @@ -48,7 +48,10 @@ class LicenseSeat extends SnipeModel implements ICompanyableChild */ public function requireAcceptance() { - return $this->license->category->require_acceptance; + if ($this->license && $this->license->category) { + return $this->license->category->require_acceptance; + } + return false; } public function getEula() diff --git a/app/Models/Loggable.php b/app/Models/Loggable.php index d0bbd10733..ce3a07f159 100644 --- a/app/Models/Loggable.php +++ b/app/Models/Loggable.php @@ -23,7 +23,7 @@ trait Loggable * @since [v3.4] * @return \App\Models\Actionlog */ - public function logCheckout($note, $target, $action_date = null) + public function logCheckout($note, $target, $action_date = null, $originalValues = []) { $log = new Actionlog; $log = $this->determineLogItemType($log); @@ -62,6 +62,23 @@ trait Loggable $log->action_date = date('Y-m-d H:i:s'); } + $changed = []; + $originalValues = array_intersect_key($originalValues, array_flip(['action_date','name','status_id','location_id','expected_checkin'])); + + foreach ($originalValues as $key => $value) { + if ($key == 'action_date' && $value != $action_date) { + $changed[$key]['old'] = $value; + $changed[$key]['new'] = is_string($action_date) ? $action_date : $action_date->format('Y-m-d H:i:s'); + } elseif ($value != $this->getAttributes()[$key]) { + $changed[$key]['old'] = $value; + $changed[$key]['new'] = $this->getAttributes()[$key]; + } + } + + if (!empty($changed)){ + $log->log_meta = json_encode($changed); + } + $log->logaction('checkout'); return $log; @@ -89,7 +106,7 @@ trait Loggable * @since [v3.4] * @return \App\Models\Actionlog */ - public function logCheckin($target, $note, $action_date = null) + public function logCheckin($target, $note, $action_date = null, $originalValues = []) { $settings = Setting::getSettings(); $log = new Actionlog; @@ -114,13 +131,9 @@ trait Loggable } } - $log->location_id = null; $log->note = $note; $log->action_date = $action_date; - if (! $log->action_date) { - $log->action_date = date('Y-m-d H:i:s'); - } if (! $log->action_date) { $log->action_date = date('Y-m-d H:i:s'); @@ -130,6 +143,23 @@ trait Loggable $log->user_id = Auth::user()->id; } + $changed = []; + $originalValues = array_intersect_key($originalValues, array_flip(['action_date','name','status_id','location_id','rtd_location_id','expected_checkin'])); + + foreach ($originalValues as $key => $value) { + if ($key == 'action_date' && $value != $action_date) { + $changed[$key]['old'] = $value; + $changed[$key]['new'] = is_string($action_date) ? $action_date : $action_date->format('Y-m-d H:i:s'); + } elseif ($value != $this->getAttributes()[$key]) { + $changed[$key]['old'] = $value; + $changed[$key]['new'] = $this->getAttributes()[$key]; + } + } + + if (!empty($changed)){ + $log->log_meta = json_encode($changed); + } + $log->logaction('checkin from'); // $params = [ diff --git a/app/Models/User.php b/app/Models/User.php index 0d49b977c4..70c9fc44ae 100644 --- a/app/Models/User.php +++ b/app/Models/User.php @@ -69,15 +69,12 @@ class User extends SnipeModel implements AuthenticatableContract, AuthorizableCo ]; protected $casts = [ - 'activated' => 'boolean', 'manager_id' => 'integer', 'location_id' => 'integer', 'company_id' => 'integer', - 'vip' => 'boolean', 'created_at' => 'datetime', 'updated_at' => 'datetime', 'deleted_at' => 'datetime', - 'autoassign_licenses' => 'boolean', ]; /** @@ -103,6 +100,9 @@ class User extends SnipeModel implements AuthenticatableContract, AuthorizableCo 'state' => 'min:2|max:191|nullable', 'country' => 'min:2|max:191|nullable', 'zip' => 'max:10|nullable', + 'vip' => 'boolean', + 'remote' => 'boolean', + 'activated' => 'boolean', ]; /** @@ -750,4 +750,26 @@ class User extends SnipeModel implements AuthenticatableContract, AuthorizableCo { return $this->locale; } + public function getUserTotalCost(){ + $asset_cost= 0; + $license_cost= 0; + $accessory_cost= 0; + foreach ($this->assets as $asset){ + $asset_cost += $asset->purchase_cost; + $this->asset_cost = $asset_cost; + } + foreach ($this->licenses as $license){ + $license_cost += $license->purchase_cost; + $this->license_cost = $license_cost; + } + foreach ($this->accessories as $accessory){ + $accessory_cost += $accessory->purchase_cost; + $this->accessory_cost = $accessory_cost; + } + + $this->total_user_cost = ($asset_cost + $accessory_cost + $license_cost); + + + return $this; + } } diff --git a/app/Presenters/AssetPresenter.php b/app/Presenters/AssetPresenter.php index ec50933583..4a068ba642 100644 --- a/app/Presenters/AssetPresenter.php +++ b/app/Presenters/AssetPresenter.php @@ -548,8 +548,10 @@ class AssetPresenter extends Presenter public function dynamicWarrantyUrl() { $warranty_lookup_url = $this->model->model->manufacturer->warranty_lookup_url; - $url = (str_replace('{LOCALE}',\App\Models\Setting::getSettings()->locale,$warranty_lookup_url)); - $url = (str_replace('{SERIAL}',$this->model->serial,$url)); + $url = (str_replace('{LOCALE}',\App\Models\Setting::getSettings()->locale, $warranty_lookup_url)); + $url = (str_replace('{SERIAL}', urlencode($this->model->serial), $url)); + $url = (str_replace('{MODEL_NAME}', urlencode($this->model->model->name), $url)); + $url = (str_replace('{MODEL_NUMBER}', urlencode($this->model->model->model_number), $url)); return $url; } diff --git a/app/Providers/AppServiceProvider.php b/app/Providers/AppServiceProvider.php index 607d206a67..325fb8ad12 100644 --- a/app/Providers/AppServiceProvider.php +++ b/app/Providers/AppServiceProvider.php @@ -75,12 +75,7 @@ class AppServiceProvider extends ServiceProvider // Only load rollbar if there is a rollbar key and the app is in production if (($this->app->environment('production')) && (config('logging.channels.rollbar.access_token'))) { $this->app->register(\Rollbar\Laravel\RollbarServiceProvider::class); - } - - // Only load dusk's service provider if the app is in local or develop mode - if ($this->app->environment(['local', 'develop'])) { - $this->app->register(\Laravel\Dusk\DuskServiceProvider::class); - } + } $this->app->singleton('ArieTimmerman\Laravel\SCIMServer\SCIMConfig', SnipeSCIMConfig::class); // this overrides the default SCIM configuration with our own diff --git a/composer.json b/composer.json index 20d76a1d91..020b2f9ca7 100644 --- a/composer.json +++ b/composer.json @@ -77,7 +77,6 @@ "require-dev": { "brianium/paratest": "^6.6", "fakerphp/faker": "^1.16", - "laravel/dusk": "^6.25", "mockery/mockery": "^1.4", "nunomaduro/larastan": "^1.0", "nunomaduro/phpinsights": "^2.7", @@ -107,7 +106,6 @@ }, "autoload-dev": { "classmap": [ - "tests/DuskTestCase.php", "tests/TestCase.php" ], "psr-4": { diff --git a/composer.lock b/composer.lock index 54d3e556b6..7ca88da10f 100644 --- a/composer.lock +++ b/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "f30d1bebf56af36eb55a56d093b54650", + "content-hash": "348f96db24a0f8dfb595ee38b38b34eb", "packages": [ { "name": "alek13/slack", @@ -78,25 +78,25 @@ "source": { "type": "git", "url": "https://github.com/grokability/laravel-scim-server.git", - "reference": "9e8dd2d3958d3c3c05d0a99fe6475361ad9e9419" + "reference": "dda6dfb60d70fb6cca4b8d4ce1c5f4c19deaab2d" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/grokability/laravel-scim-server/zipball/9e8dd2d3958d3c3c05d0a99fe6475361ad9e9419", - "reference": "9e8dd2d3958d3c3c05d0a99fe6475361ad9e9419", + "url": "https://api.github.com/repos/grokability/laravel-scim-server/zipball/dda6dfb60d70fb6cca4b8d4ce1c5f4c19deaab2d", + "reference": "dda6dfb60d70fb6cca4b8d4ce1c5f4c19deaab2d", "shasum": "" }, "require": { - "illuminate/console": "^6.0|^7.0|^8.0|^9.0", - "illuminate/database": "^6.0|^7.0|^8.0|^9.0", - "illuminate/support": "^6.0|^7.0|^8.0|^9.0", + "illuminate/console": "^6.0|^7.0|^8.0|^9.0|^10.0", + "illuminate/database": "^6.0|^7.0|^8.0|^9.0|^10.0", + "illuminate/support": "^6.0|^7.0|^8.0|^9.0|^10.0", "php": "^7.0|^8.0", "tmilos/scim-filter-parser": "^1.3", "tmilos/scim-schema": "^0.1.0" }, "require-dev": { "laravel/legacy-factories": "*", - "orchestra/testbench": "^5.0|^6.0" + "orchestra/testbench": "^4.0|^5.0|^6.0|^7.0|^8.0" }, "default-branch": true, "type": "library", @@ -133,7 +133,7 @@ "support": { "source": "https://github.com/grokability/laravel-scim-server/tree/master" }, - "time": "2023-01-12T00:32:07+00:00" + "time": "2023-09-07T16:45:26+00:00" }, { "name": "asm89/stack-cors", @@ -13521,79 +13521,6 @@ }, "time": "2022-04-13T08:02:27+00:00" }, - { - "name": "laravel/dusk", - "version": "v6.25.2", - "source": { - "type": "git", - "url": "https://github.com/laravel/dusk.git", - "reference": "25a595ac3dc82089a91af10dd23b0d58fd3f6d0b" - }, - "dist": { - "type": "zip", - "url": "https://api.github.com/repos/laravel/dusk/zipball/25a595ac3dc82089a91af10dd23b0d58fd3f6d0b", - "reference": "25a595ac3dc82089a91af10dd23b0d58fd3f6d0b", - "shasum": "" - }, - "require": { - "ext-json": "*", - "ext-zip": "*", - "illuminate/console": "^6.0|^7.0|^8.0|^9.0", - "illuminate/support": "^6.0|^7.0|^8.0|^9.0", - "nesbot/carbon": "^2.0", - "php": "^7.2|^8.0", - "php-webdriver/webdriver": "^1.9.0", - "symfony/console": "^4.3|^5.0|^6.0", - "symfony/finder": "^4.3|^5.0|^6.0", - "symfony/process": "^4.3|^5.0|^6.0", - "vlucas/phpdotenv": "^3.0|^4.0|^5.2" - }, - "require-dev": { - "mockery/mockery": "^1.0", - "orchestra/testbench": "^4.16|^5.17.1|^6.12.1|^7.0", - "phpunit/phpunit": "^7.5.15|^8.4|^9.0" - }, - "suggest": { - "ext-pcntl": "Used to gracefully terminate Dusk when tests are running." - }, - "type": "library", - "extra": { - "branch-alias": { - "dev-master": "6.x-dev" - }, - "laravel": { - "providers": [ - "Laravel\\Dusk\\DuskServiceProvider" - ] - } - }, - "autoload": { - "psr-4": { - "Laravel\\Dusk\\": "src/" - } - }, - "notification-url": "https://packagist.org/downloads/", - "license": [ - "MIT" - ], - "authors": [ - { - "name": "Taylor Otwell", - "email": "taylor@laravel.com" - } - ], - "description": "Laravel Dusk provides simple end-to-end testing and browser automation.", - "keywords": [ - "laravel", - "testing", - "webdriver" - ], - "support": { - "issues": "https://github.com/laravel/dusk/issues", - "source": "https://github.com/laravel/dusk/tree/v6.25.2" - }, - "time": "2022-09-29T09:37:07+00:00" - }, { "name": "league/container", "version": "4.2.0", @@ -14230,71 +14157,6 @@ }, "time": "2022-02-21T12:50:22+00:00" }, - { - "name": "php-webdriver/webdriver", - "version": "1.12.1", - "source": { - "type": "git", - "url": "https://github.com/php-webdriver/php-webdriver.git", - "reference": "b27ddf458d273c7d4602106fcaf978aa0b7fe15a" - }, - "dist": { - "type": "zip", - "url": "https://api.github.com/repos/php-webdriver/php-webdriver/zipball/b27ddf458d273c7d4602106fcaf978aa0b7fe15a", - "reference": "b27ddf458d273c7d4602106fcaf978aa0b7fe15a", - "shasum": "" - }, - "require": { - "ext-curl": "*", - "ext-json": "*", - "ext-zip": "*", - "php": "^5.6 || ~7.0 || ^8.0", - "symfony/polyfill-mbstring": "^1.12", - "symfony/process": "^2.8 || ^3.1 || ^4.0 || ^5.0 || ^6.0" - }, - "replace": { - "facebook/webdriver": "*" - }, - "require-dev": { - "ondram/ci-detector": "^2.1 || ^3.5 || ^4.0", - "php-coveralls/php-coveralls": "^2.4", - "php-mock/php-mock-phpunit": "^1.1 || ^2.0", - "php-parallel-lint/php-parallel-lint": "^1.2", - "phpunit/phpunit": "^5.7 || ^7 || ^8 || ^9", - "squizlabs/php_codesniffer": "^3.5", - "symfony/var-dumper": "^3.3 || ^4.0 || ^5.0 || ^6.0" - }, - "suggest": { - "ext-SimpleXML": "For Firefox profile creation" - }, - "type": "library", - "autoload": { - "files": [ - "lib/Exception/TimeoutException.php" - ], - "psr-4": { - "Facebook\\WebDriver\\": "lib/" - } - }, - "notification-url": "https://packagist.org/downloads/", - "license": [ - "MIT" - ], - "description": "A PHP client for Selenium WebDriver. Previously facebook/webdriver.", - "homepage": "https://github.com/php-webdriver/php-webdriver", - "keywords": [ - "Chromedriver", - "geckodriver", - "php", - "selenium", - "webdriver" - ], - "support": { - "issues": "https://github.com/php-webdriver/php-webdriver/issues", - "source": "https://github.com/php-webdriver/php-webdriver/tree/1.12.1" - }, - "time": "2022-05-03T12:16:34+00:00" - }, { "name": "phpstan/phpdoc-parser", "version": "1.22.1", diff --git a/config/database.php b/config/database.php index 36440b2127..bb62cf0d3a 100755 --- a/config/database.php +++ b/config/database.php @@ -69,7 +69,7 @@ return [ 'mysql' => [ 'driver' => 'mysql', 'host' => env('DB_HOST', 'localhost'), - 'port' => env('DB_PORT', '3306'), + 'port' => env('DB_PORT', 3306), 'database' => env('DB_DATABASE', 'forge'), 'username' => env('DB_USERNAME', 'forge'), 'password' => env('DB_PASSWORD', ''), diff --git a/config/version.php b/config/version.php index ba0dd3bace..05805e4564 100644 --- a/config/version.php +++ b/config/version.php @@ -1,10 +1,10 @@ 'v6.1.2', - 'full_app_version' => 'v6.1.2 - build 10938-g32747cafd', - 'build_version' => '10938', + 'app_version' => 'v6.2.0-pre', + 'full_app_version' => 'v6.2.0-pre - build 11391-g319cb2305', + 'build_version' => '11391', 'prerelease_version' => '', - 'hash_version' => 'g32747cafd', - 'full_hash' => 'v6.1.2-89-g32747cafd', + 'hash_version' => 'g319cb2305', + 'full_hash' => 'v6.2.0-pre-451-g319cb2305', 'branch' => 'develop', ); \ No newline at end of file diff --git a/database/migrations/2023_08_17_202638_add_last_checkin_to_assets.php b/database/migrations/2023_08_17_202638_add_last_checkin_to_assets.php new file mode 100644 index 0000000000..74048ce941 --- /dev/null +++ b/database/migrations/2023_08_17_202638_add_last_checkin_to_assets.php @@ -0,0 +1,36 @@ +dateTime('last_checkin')->after('last_checkout')->nullable(); + }); + + DB::statement( + "UPDATE " . DB::getTablePrefix() . "assets SET last_checkin=(SELECT MAX(" . DB::getTablePrefix() . "action_logs.action_date) FROM " . DB::getTablePrefix() . "action_logs WHERE item_type='App\\\Models\\\Asset' AND " . DB::getTablePrefix() . "action_logs.item_id=" . DB::getTablePrefix() . "assets.id AND " . DB::getTablePrefix() . "action_logs.action_type='checkin from')" + ); + } + + /** + * Reverse the migrations. + * + * @return void + */ + public function down() + { + Schema::table('assets', function (Blueprint $table) { + $table->dropColumn('last_checkin'); + }); + } +} diff --git a/package-lock.json b/package-lock.json index 649581f940..56ec3d57db 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1329,9 +1329,9 @@ "dev": true }, "@fortawesome/fontawesome-free": { - "version": "6.4.0", - "resolved": "https://registry.npmjs.org/@fortawesome/fontawesome-free/-/fontawesome-free-6.4.0.tgz", - "integrity": "sha512-0NyytTlPJwB/BF5LtRV8rrABDbe3TdTXqNB3PdZ+UUUZAEIrdOJdmABqKjt4AXwIoJNaRVVZEXxpNrqvE1GAYQ==" + "version": "6.4.2", + "resolved": "https://registry.npmjs.org/@fortawesome/fontawesome-free/-/fontawesome-free-6.4.2.tgz", + "integrity": "sha512-m5cPn3e2+FDCOgi1mz0RexTUvvQibBebOUlUlW0+YrMjDTPkiJ6VTKukA1GRsvRw+12KyJndNjj0O4AgTxm2Pg==" }, "@jridgewell/gen-mapping": { "version": "0.1.1", @@ -1883,6 +1883,19 @@ } } }, + "@vue/reactivity": { + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/@vue/reactivity/-/reactivity-3.1.5.tgz", + "integrity": "sha512-1tdfLmNjWG6t/CsPldh+foumYFo3cpyCHgBYQ34ylaMsJ+SNHQ1kApMIa8jN+i593zQuaw3AdWH0nJTARzCFhg==", + "requires": { + "@vue/shared": "3.1.5" + } + }, + "@vue/shared": { + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/@vue/shared/-/shared-3.1.5.tgz", + "integrity": "sha512-oJ4F3TnvpXaQwZJNF3ZK+kLPHKarDmJjJ6jyzVNDKH9md1dptjC7lWR//jrGuLdek/U6iltWxqAnYOu8gCiOvA==" + }, "@webassemblyjs/ast": { "version": "1.11.6", "resolved": "https://registry.npmjs.org/@webassemblyjs/ast/-/ast-1.11.6.tgz", @@ -2187,6 +2200,14 @@ "resolved": "https://registry.npmjs.org/ajv-keywords/-/ajv-keywords-3.5.2.tgz", "integrity": "sha512-5p6WTN0DdTGVQk6VjcEju19IgaHudalcfabD7yhDGeA6bcQnmL+CpveLJq/3hvfwd1aof6L386Ougkx6RfyMIQ==" }, + "alpinejs": { + "version": "3.13.0", + "resolved": "https://registry.npmjs.org/alpinejs/-/alpinejs-3.13.0.tgz", + "integrity": "sha512-7FYR1Yz3evIjlJD1mZ3SYWSw+jlOmQGeQ1QiSufSQ6J84XMQFkzxm6OobiZ928SfqhGdoIp2SsABNsS4rXMMJw==", + "requires": { + "@vue/reactivity": "~3.1.1" + } + }, "ansi-html-community": { "version": "0.0.8", "resolved": "https://registry.npmjs.org/ansi-html-community/-/ansi-html-community-0.0.8.tgz", @@ -3964,11 +3985,11 @@ "dev": true }, "copy-anything": { - "version": "2.0.3", - "resolved": "https://registry.npmjs.org/copy-anything/-/copy-anything-2.0.3.tgz", - "integrity": "sha512-GK6QUtisv4fNS+XcI7shX0Gx9ORg7QqIznyfho79JTnX1XhLiyZHfftvGiziqzRiEi/Bjhgpi+D2o7HxJFPnDQ==", + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/copy-anything/-/copy-anything-2.0.6.tgz", + "integrity": "sha512-1j20GZTsvKNkc4BY3NpMOM8tt///wY3FpIzozTOFO2ffuZcV61nojHXVKIy3WM+7ADCy5FVhdZYHYDdgTU0yJw==", "requires": { - "is-what": "^3.12.0" + "is-what": "^3.14.1" } }, "core-js": { @@ -15278,6 +15299,7 @@ "version": "0.4.24", "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.24.tgz", "integrity": "sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==", + "dev": true, "requires": { "safer-buffer": ">= 2.1.2 < 3" } @@ -16312,9 +16334,9 @@ } }, "less": { - "version": "4.1.2", - "resolved": "https://registry.npmjs.org/less/-/less-4.1.2.tgz", - "integrity": "sha512-EoQp/Et7OSOVu0aJknJOtlXZsnr8XE8KwuzTHOLeVSEx8pVWUICc8Q0VYRHgzyjX78nMEyC/oztWFbgyhtNfDA==", + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/less/-/less-4.2.0.tgz", + "integrity": "sha512-P3b3HJDBtSzsXUl0im2L7gTO5Ubg8mEN6G8qoTS77iXxXX4Hvu4Qj540PZDvQ8V6DmX6iXo98k7Md0Cm1PrLaA==", "requires": { "copy-anything": "^2.0.1", "errno": "^0.1.1", @@ -16322,34 +16344,12 @@ "image-size": "~0.5.0", "make-dir": "^2.1.0", "mime": "^1.4.1", - "needle": "^2.5.2", + "needle": "^3.1.0", "parse-node-version": "^1.0.1", "source-map": "~0.6.0", "tslib": "^2.3.0" }, "dependencies": { - "make-dir": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/make-dir/-/make-dir-2.1.0.tgz", - "integrity": "sha512-LS9X+dc8KLxXCb8dni79fLIIUA5VyZoyjSMCwTluaXA0o27cCK0bhXkpgw+sTXVpPy/lSO57ilRixqk0vDmtRA==", - "optional": true, - "requires": { - "pify": "^4.0.1", - "semver": "^5.6.0" - } - }, - "pify": { - "version": "4.0.1", - "resolved": "https://registry.npmjs.org/pify/-/pify-4.0.1.tgz", - "integrity": "sha512-uB80kBFb/tfd68bVleG9T5GGsGPjJrLAUpR5PZIrhBnIaRTQRjqdJSsIKkOP6OAIFbj7GOrcudc5pNjZ+geV2g==", - "optional": true - }, - "semver": { - "version": "5.7.1", - "resolved": "https://registry.npmjs.org/semver/-/semver-5.7.1.tgz", - "integrity": "sha512-sauaDf/PZdVgrLTNYHRtpXa1iRiKcaebiKQ1BJdpQlWH2lCvexQdX55snPFyK7QzpudqbCI0qXFfOasHdyNDGQ==", - "optional": true - }, "source-map": { "version": "0.6.1", "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", @@ -16500,6 +16500,24 @@ "yallist": "^4.0.0" } }, + "make-dir": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/make-dir/-/make-dir-2.1.0.tgz", + "integrity": "sha512-LS9X+dc8KLxXCb8dni79fLIIUA5VyZoyjSMCwTluaXA0o27cCK0bhXkpgw+sTXVpPy/lSO57ilRixqk0vDmtRA==", + "optional": true, + "requires": { + "pify": "^4.0.1", + "semver": "^5.6.0" + }, + "dependencies": { + "semver": { + "version": "5.7.2", + "resolved": "https://registry.npmjs.org/semver/-/semver-5.7.2.tgz", + "integrity": "sha512-cBznnQ9KjJqU67B52RMC65CMarK2600WFnbkcaiwWq3xy/5haFJlshgnpjovMVJ+Hff49d8GEn0b87C5pDQ10g==", + "optional": true + } + } + }, "md5": { "version": "2.3.0", "resolved": "https://registry.npmjs.org/md5/-/md5-2.3.0.tgz", @@ -16799,13 +16817,13 @@ "dev": true }, "needle": { - "version": "2.9.1", - "resolved": "https://registry.npmjs.org/needle/-/needle-2.9.1.tgz", - "integrity": "sha512-6R9fqJ5Zcmf+uYaFgdIHmLwNldn5HbK8L5ybn7Uz+ylX/rnOsSp1AHcvQSrCaFN+qNM1wpymHqD7mVasEOlHGQ==", + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/needle/-/needle-3.2.0.tgz", + "integrity": "sha512-oUvzXnyLiVyVGoianLijF9O/RecZUf7TkBfimjGrLM4eQhXyeJwM6GeAWccwfQ9aa4gMCZKqhAOuLaMIcQxajQ==", "optional": true, "requires": { "debug": "^3.2.6", - "iconv-lite": "^0.4.4", + "iconv-lite": "^0.6.3", "sax": "^1.2.4" }, "dependencies": { @@ -16818,6 +16836,15 @@ "ms": "^2.1.1" } }, + "iconv-lite": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz", + "integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==", + "optional": true, + "requires": { + "safer-buffer": ">= 2.1.2 < 3.0.0" + } + }, "ms": { "version": "2.1.3", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", @@ -17405,6 +17432,12 @@ "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz", "integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==" }, + "pify": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/pify/-/pify-4.0.1.tgz", + "integrity": "sha512-uB80kBFb/tfd68bVleG9T5GGsGPjJrLAUpR5PZIrhBnIaRTQRjqdJSsIKkOP6OAIFbj7GOrcudc5pNjZ+geV2g==", + "optional": true + }, "pkg-dir": { "version": "4.2.0", "resolved": "https://registry.npmjs.org/pkg-dir/-/pkg-dir-4.2.0.tgz", diff --git a/package.json b/package.json index 7a497786e3..79473ec92d 100644 --- a/package.json +++ b/package.json @@ -24,7 +24,7 @@ "vue-template-compiler": "2.4.4" }, "dependencies": { - "@fortawesome/fontawesome-free": "^6.4.0", + "@fortawesome/fontawesome-free": "^6.4.2", "acorn": "^8.9.0", "acorn-import-assertions": "^1.9.0", "admin-lte": "^2.4.18", @@ -46,7 +46,7 @@ "jquery-ui-bundle": "^1.12.1", "jquery.iframe-transport": "^1.0.0", "jspdf-autotable": "^3.5.30", - "less": "^4.1.2", + "less": "^4.2.0", "less-loader": "^5.0", "list.js": "^1.5.0", "papaparse": "^4.3.3", diff --git a/public/css/dist/all.css b/public/css/dist/all.css index 6a556a42bf..b181114443 100644 --- a/public/css/dist/all.css +++ b/public/css/dist/all.css @@ -6833,7 +6833,7 @@ button.close { } /*# sourceMappingURL=bootstrap.css.map */ /*! - * Font Awesome Free 6.4.0 by @fontawesome - https://fontawesome.com + * Font Awesome Free 6.4.2 by @fontawesome - https://fontawesome.com * License - https://fontawesome.com/license/free (Icons: CC BY 4.0, Fonts: SIL OFL 1.1, Code: MIT License) * Copyright 2023 Fonticons, Inc. */ @@ -13384,6 +13384,9 @@ readers do not read off random characters that represent icons */ .fa-edge:before { content: "\f282"; } +.fa-threads:before { + content: "\e618"; } + .fa-napster:before { content: "\f3d2"; } @@ -13552,6 +13555,9 @@ readers do not read off random characters that represent icons */ .fa-scribd:before { content: "\f28a"; } +.fa-debian:before { + content: "\e60b"; } + .fa-openid:before { content: "\f19b"; } @@ -13792,6 +13798,9 @@ readers do not read off random characters that represent icons */ .fa-neos:before { content: "\f612"; } +.fa-square-threads:before { + content: "\e619"; } + .fa-hackerrank:before { content: "\f5f7"; } @@ -14134,6 +14143,9 @@ readers do not read off random characters that represent icons */ .fa-erlang:before { content: "\f39d"; } +.fa-x-twitter:before { + content: "\e61b"; } + .fa-cotton-bureau:before { content: "\f89e"; } @@ -14629,6 +14641,9 @@ readers do not read off random characters that represent icons */ .fa-quora:before { content: "\f2c4"; } +.fa-square-x-twitter:before { + content: "\e61a"; } + .fa-reacteurope:before { content: "\f75d"; } diff --git a/public/css/webfonts/fa-brands-400.ttf b/public/css/webfonts/fa-brands-400.ttf index 774d51ac4b..30f55b7435 100644 Binary files a/public/css/webfonts/fa-brands-400.ttf and b/public/css/webfonts/fa-brands-400.ttf differ diff --git a/public/css/webfonts/fa-brands-400.woff2 b/public/css/webfonts/fa-brands-400.woff2 index 71e3185268..8a480d9b1f 100644 Binary files a/public/css/webfonts/fa-brands-400.woff2 and b/public/css/webfonts/fa-brands-400.woff2 differ diff --git a/public/css/webfonts/fa-regular-400.ttf b/public/css/webfonts/fa-regular-400.ttf index 8a9d6344d1..c79589d83d 100644 Binary files a/public/css/webfonts/fa-regular-400.ttf and b/public/css/webfonts/fa-regular-400.ttf differ diff --git a/public/css/webfonts/fa-regular-400.woff2 b/public/css/webfonts/fa-regular-400.woff2 index 7f021680b9..059a94e2fd 100644 Binary files a/public/css/webfonts/fa-regular-400.woff2 and b/public/css/webfonts/fa-regular-400.woff2 differ diff --git a/public/css/webfonts/fa-solid-900.ttf b/public/css/webfonts/fa-solid-900.ttf index 993dbe1f95..e479fb2934 100644 Binary files a/public/css/webfonts/fa-solid-900.ttf and b/public/css/webfonts/fa-solid-900.ttf differ diff --git a/public/css/webfonts/fa-solid-900.woff2 b/public/css/webfonts/fa-solid-900.woff2 index 5c16cd3e8a..88b0367aae 100644 Binary files a/public/css/webfonts/fa-solid-900.woff2 and b/public/css/webfonts/fa-solid-900.woff2 differ diff --git a/public/css/webfonts/fa-v4compatibility.ttf b/public/css/webfonts/fa-v4compatibility.ttf index ab6ae22482..ba6cb258e0 100644 Binary files a/public/css/webfonts/fa-v4compatibility.ttf and b/public/css/webfonts/fa-v4compatibility.ttf differ diff --git a/public/css/webfonts/fa-v4compatibility.woff2 b/public/css/webfonts/fa-v4compatibility.woff2 index 9027e38bcd..23b1c47ba2 100644 Binary files a/public/css/webfonts/fa-v4compatibility.woff2 and b/public/css/webfonts/fa-v4compatibility.woff2 differ diff --git a/public/js/dist/all-defer.js b/public/js/dist/all-defer.js index 3eac3595a9..e81b6e8766 100644 --- a/public/js/dist/all-defer.js +++ b/public/js/dist/all-defer.js @@ -3,6 +3,7 @@ var flushPending = false; var flushing = false; var queue = []; + var lastFlushedIndex = -1; function scheduler(callback) { queueJob(callback); } @@ -13,7 +14,7 @@ } function dequeueJob(job) { let index = queue.indexOf(job); - if (index !== -1) + if (index !== -1 && index > lastFlushedIndex) queue.splice(index, 1); } function queueFlush() { @@ -27,8 +28,10 @@ flushing = true; for (let i = 0; i < queue.length; i++) { queue[i](); + lastFlushedIndex = i; } queue.length = 0; + lastFlushedIndex = -1; flushing = false; } @@ -46,13 +49,13 @@ function setReactivityEngine(engine) { reactive = engine.reactive; release = engine.release; - effect = (callback) => engine.effect(callback, {scheduler: (task) => { + effect = (callback) => engine.effect(callback, { scheduler: (task) => { if (shouldSchedule) { scheduler(task); } else { task(); } - }}); + } }); raw = engine.raw; } function overrideEffect(override) { @@ -64,7 +67,7 @@ let wrappedEffect = (callback) => { let effectReference = effect(callback); if (!el._x_effects) { - el._x_effects = new Set(); + el._x_effects = /* @__PURE__ */ new Set(); el._x_runEffects = () => { el._x_effects.forEach((i) => i()); }; @@ -83,6 +86,120 @@ }]; } + // packages/alpinejs/src/utils/dispatch.js + function dispatch(el, name, detail = {}) { + el.dispatchEvent( + new CustomEvent(name, { + detail, + bubbles: true, + // Allows events to pass the shadow DOM barrier. + composed: true, + cancelable: true + }) + ); + } + + // packages/alpinejs/src/utils/walk.js + function walk(el, callback) { + if (typeof ShadowRoot === "function" && el instanceof ShadowRoot) { + Array.from(el.children).forEach((el2) => walk(el2, callback)); + return; + } + let skip = false; + callback(el, () => skip = true); + if (skip) + return; + let node = el.firstElementChild; + while (node) { + walk(node, callback, false); + node = node.nextElementSibling; + } + } + + // packages/alpinejs/src/utils/warn.js + function warn(message, ...args) { + console.warn(`Alpine Warning: ${message}`, ...args); + } + + // packages/alpinejs/src/lifecycle.js + var started = false; + function start() { + if (started) + warn("Alpine has already been initialized on this page. Calling Alpine.start() more than once can cause problems."); + started = true; + if (!document.body) + warn("Unable to initialize. Trying to load Alpine before `` is available. Did you forget to add `defer` in Alpine's ` diff --git a/tests/Browser/LoginTest.php b/tests/Browser/LoginTest.php deleted file mode 100644 index 18f5172f15..0000000000 --- a/tests/Browser/LoginTest.php +++ /dev/null @@ -1,46 +0,0 @@ -make(); - - // We override the existing password to use a hash of one we know - $user->password = '$2y$10$8o5W8fgAKJbN3Kz4taepeeRVgKsG8pkZ1L4eJfdEKrn2mgI/JgCJy'; - - // We want a user that is a superuser - $user->permissions = '{"superuser": 1}'; - - $user->save(); - - Setting::factory()->create(); - - $this->browse(function (Browser $browser) { - $browser->visitRoute('login') - ->assertSee(trans('auth/general.login_prompt')); - }); - - $this->browse(function ($browser) use ($user) { - $browser->visitRoute('login') - ->type('username', $user->username) - ->type('password', 'password') - ->press(trans('auth/general.login')) - ->assertPathIs('/'); - $browser->screenshot('dashboard'); - }); - } -} diff --git a/tests/Browser/Pages/HomePage.php b/tests/Browser/Pages/HomePage.php deleted file mode 100644 index 26bf174f3d..0000000000 --- a/tests/Browser/Pages/HomePage.php +++ /dev/null @@ -1,41 +0,0 @@ - '#selector', - ]; - } -} diff --git a/tests/Browser/Pages/Page.php b/tests/Browser/Pages/Page.php deleted file mode 100644 index f8d76222c0..0000000000 --- a/tests/Browser/Pages/Page.php +++ /dev/null @@ -1,20 +0,0 @@ - '#selector', - ]; - } -} diff --git a/tests/Browser/console/.gitignore b/tests/Browser/console/.gitignore deleted file mode 100644 index d6b7ef32c8..0000000000 --- a/tests/Browser/console/.gitignore +++ /dev/null @@ -1,2 +0,0 @@ -* -!.gitignore diff --git a/tests/Browser/screenshots/.gitignore b/tests/Browser/screenshots/.gitignore deleted file mode 100644 index d6b7ef32c8..0000000000 --- a/tests/Browser/screenshots/.gitignore +++ /dev/null @@ -1,2 +0,0 @@ -* -!.gitignore diff --git a/tests/Browser/source/.gitignore b/tests/Browser/source/.gitignore deleted file mode 100644 index d6b7ef32c8..0000000000 --- a/tests/Browser/source/.gitignore +++ /dev/null @@ -1,2 +0,0 @@ -* -!.gitignore diff --git a/tests/DuskTestCase.php b/tests/DuskTestCase.php deleted file mode 100644 index af46d0e3dc..0000000000 --- a/tests/DuskTestCase.php +++ /dev/null @@ -1,70 +0,0 @@ -addArguments(collect([ - '--window-size=1920,1080', - ])->unless($this->hasHeadlessDisabled(), function ($items) { - return $items->merge([ - '--disable-gpu', - '--headless', - ]); - })->all()); - - return RemoteWebDriver::create( - $_ENV['DUSK_DRIVER_URL'] ?? 'http://127.0.0.1:9515', - DesiredCapabilities::chrome()->setCapability( - ChromeOptions::CAPABILITY, $options - ) - ); - } - - /** - * Determine whether the Dusk command has disabled headless mode. - * - * @return bool - */ - protected function hasHeadlessDisabled() - { - return isset($_SERVER['DUSK_HEADLESS_DISABLED']) || - isset($_ENV['DUSK_HEADLESS_DISABLED']); - } -} diff --git a/tests/Feature/Api/Assets/AssetCheckinTest.php b/tests/Feature/Api/Assets/AssetCheckinTest.php new file mode 100644 index 0000000000..f71191d80c --- /dev/null +++ b/tests/Feature/Api/Assets/AssetCheckinTest.php @@ -0,0 +1,30 @@ +superuser()->create(); + $asset = Asset::factory()->create(['last_checkin' => null]); + + $asset->checkOut(User::factory()->create(), $admin, now()); + + $this->actingAsForApi($admin) + ->postJson(route('api.asset.checkin', $asset)) + ->assertOk(); + + $this->assertNotNull( + $asset->fresh()->last_checkin, + 'last_checkin field should be set on checkin' + ); + } +} diff --git a/tests/Feature/Api/Users/UpdateUserApiTest.php b/tests/Feature/Api/Users/UpdateUserApiTest.php new file mode 100644 index 0000000000..81c1154648 --- /dev/null +++ b/tests/Feature/Api/Users/UpdateUserApiTest.php @@ -0,0 +1,68 @@ +superuser()->create(); + $user = User::factory()->create(['activated' => 0]); + + $this->actingAsForApi($admin) + ->patch(route('api.users.update', $user), [ + 'activated' => 1, + ]); + + $this->assertEquals(1, $user->refresh()->activated); + } + + public function testApiUsersCanBeActivatedWithBooleanTrue() + { + $admin = User::factory()->superuser()->create(); + $user = User::factory()->create(['activated' => false]); + + $this->actingAsForApi($admin) + ->patch(route('api.users.update', $user), [ + 'activated' => true, + ]); + + $this->assertEquals(1, $user->refresh()->activated); + } + + public function testApiUsersCanBeDeactivatedWithNumber() + { + $admin = User::factory()->superuser()->create(); + $user = User::factory()->create(['activated' => true]); + + $this->actingAsForApi($admin) + ->patch(route('api.users.update', $user), [ + 'activated' => 0, + ]); + + $this->assertEquals(0, $user->refresh()->activated); + } + + public function testApiUsersCanBeDeactivatedWithBooleanFalse() + { + $admin = User::factory()->superuser()->create(); + $user = User::factory()->create(['activated' => true]); + + $this->actingAsForApi($admin) + ->patch(route('api.users.update', $user), [ + 'activated' => false, + ]); + + $this->assertEquals(0, $user->refresh()->activated); + } + +} diff --git a/tests/Feature/Api/Users/UsersSearchTest.php b/tests/Feature/Api/Users/UsersSearchTest.php index f14d704b0f..723a115db1 100644 --- a/tests/Feature/Api/Users/UsersSearchTest.php +++ b/tests/Feature/Api/Users/UsersSearchTest.php @@ -2,6 +2,7 @@ namespace Tests\Feature\Api\Users; +use App\Models\Company; use App\Models\User; use Laravel\Passport\Passport; use Tests\Support\InteractsWithSettings; @@ -83,4 +84,67 @@ class UsersSearchTest extends TestCase 'Expected deleted user does not appear in results' ); } + + public function testUsersScopedToCompanyWhenMultipleFullCompanySupportEnabled() + { + $this->settings->enableMultipleFullCompanySupport(); + + $companyA = Company::factory() + ->has(User::factory(['first_name' => 'Company A', 'last_name' => 'User'])) + ->create(); + + Company::factory() + ->has(User::factory(['first_name' => 'Company B', 'last_name' => 'User'])) + ->create(); + + $response = $this->actingAsForApi(User::factory()->for($companyA)->viewUsers()->create()) + ->getJson(route('api.users.index')) + ->assertOk(); + + $results = collect($response->json('rows')); + + $this->assertTrue( + $results->pluck('name')->contains(fn($text) => str_contains($text, 'Company A')), + 'User index does not contain expected user' + ); + $this->assertFalse( + $results->pluck('name')->contains(fn($text) => str_contains($text, 'Company B')), + 'User index contains unexpected user from another company' + ); + } + + public function testUsersScopedToCompanyDuringSearchWhenMultipleFullCompanySupportEnabled() + { + $this->settings->enableMultipleFullCompanySupport(); + + $companyA = Company::factory() + ->has(User::factory(['first_name' => 'Company A', 'last_name' => 'User'])) + ->create(); + + Company::factory() + ->has(User::factory(['first_name' => 'Company B', 'last_name' => 'User'])) + ->create(); + + $response = $this->actingAsForApi(User::factory()->for($companyA)->viewUsers()->create()) + ->getJson(route('api.users.index', [ + 'deleted' => 'false', + 'company_id' => null, + 'search' => 'user', + 'order' => 'asc', + 'offset' => '0', + 'limit' => '20', + ])) + ->assertOk(); + + $results = collect($response->json('rows')); + + $this->assertTrue( + $results->pluck('name')->contains(fn($text) => str_contains($text, 'Company A')), + 'User index does not contain expected user' + ); + $this->assertFalse( + $results->pluck('name')->contains(fn($text) => str_contains($text, 'Company B')), + 'User index contains unexpected user from another company' + ); + } } diff --git a/tests/Feature/Api/Users/UsersUpdateTest.php b/tests/Feature/Api/Users/UsersUpdateTest.php new file mode 100644 index 0000000000..953a671cf1 --- /dev/null +++ b/tests/Feature/Api/Users/UsersUpdateTest.php @@ -0,0 +1,87 @@ +superuser()->create(); + $manager = User::factory()->create(); + $company = Company::factory()->create(); + $department = Department::factory()->create(); + $location = Location::factory()->create(); + [$groupA, $groupB] = Group::factory()->count(2)->create(); + + $user = User::factory()->create([ + 'activated' => false, + 'remote' => false, + 'vip' => false, + ]); + + $this->actingAsForApi($admin) + ->patchJson(route('api.users.update', $user), [ + 'first_name' => 'Mabel', + 'last_name' => 'Mora', + 'username' => 'mabel', + 'password' => 'super-secret', + 'email' => 'mabel@onlymurderspod.com', + 'permissions' => '{"a.new.permission":"1"}', + 'activated' => true, + 'phone' => '619-555-5555', + 'jobtitle' => 'Host', + 'manager_id' => $manager->id, + 'employee_num' => '1111', + 'notes' => 'Pretty good artist', + 'company_id' => $company->id, + 'department_id' => $department->id, + 'location_id' => $location->id, + 'remote' => true, + 'groups' => $groupA->id, + 'vip' => true, + 'start_date' => '2021-08-01', + 'end_date' => '2025-12-31', + ]) + ->assertOk(); + + $user->refresh(); + $this->assertEquals('Mabel', $user->first_name, 'First name was not updated'); + $this->assertEquals('Mora', $user->last_name, 'Last name was not updated'); + $this->assertEquals('mabel', $user->username, 'Username was not updated'); + $this->assertTrue(Hash::check('super-secret', $user->password), 'Password was not updated'); + $this->assertEquals('mabel@onlymurderspod.com', $user->email, 'Email was not updated'); + $this->assertArrayHasKey('a.new.permission', $user->decodePermissions(), 'Permissions were not updated'); + $this->assertTrue((bool)$user->activated, 'User not marked as activated'); + $this->assertEquals('619-555-5555', $user->phone, 'Phone was not updated'); + $this->assertEquals('Host', $user->jobtitle, 'Job title was not updated'); + $this->assertTrue($user->manager->is($manager), 'Manager was not updated'); + $this->assertEquals('1111', $user->employee_num, 'Employee number was not updated'); + $this->assertEquals('Pretty good artist', $user->notes, 'Notes was not updated'); + $this->assertTrue($user->company->is($company), 'Company was not updated'); + $this->assertTrue($user->department->is($department), 'Department was not updated'); + $this->assertTrue($user->location->is($location), 'Location was not updated'); + $this->assertEquals(1, $user->remote, 'Remote was not updated'); + $this->assertTrue($user->groups->contains($groupA), 'Groups were not updated'); + $this->assertEquals(1, $user->vip, 'VIP was not updated'); + $this->assertEquals('2021-08-01', $user->start_date, 'Start date was not updated'); + $this->assertEquals('2025-12-31', $user->end_date, 'End date was not updated'); + + // `groups` can be an id or array or ids + $this->patch(route('api.users.update', $user), ['groups' => [$groupA->id, $groupB->id]]); + + $user->refresh(); + $this->assertTrue($user->groups->contains($groupA), 'Not part of expected group'); + $this->assertTrue($user->groups->contains($groupB), 'Not part of expected group'); + } +} diff --git a/tests/Feature/Assets/AssetCheckinTest.php b/tests/Feature/Assets/AssetCheckinTest.php new file mode 100644 index 0000000000..059fb1294f --- /dev/null +++ b/tests/Feature/Assets/AssetCheckinTest.php @@ -0,0 +1,32 @@ +superuser()->create(); + $asset = Asset::factory()->create(['last_checkin' => null]); + + $asset->checkOut(User::factory()->create(), $admin, now()); + + $this->actingAs($admin) + ->post(route('hardware.checkin.store', [ + 'assetId' => $asset->id, + ])) + ->assertRedirect(); + + $this->assertNotNull( + $asset->fresh()->last_checkin, + 'last_checkin field should be set on checkin' + ); + } +} diff --git a/tests/Feature/Reports/CustomReportTest.php b/tests/Feature/Reports/CustomReportTest.php index b27ebc27ef..a1a269a4ab 100644 --- a/tests/Feature/Reports/CustomReportTest.php +++ b/tests/Feature/Reports/CustomReportTest.php @@ -107,4 +107,29 @@ class CustomReportTest extends TestCase ->assertDontSeeTextInStreamedResponse('Asset A') ->assertSeeTextInStreamedResponse('Asset B'); } + + public function testCanLimitAssetsByLastCheckIn() + { + Asset::factory()->create(['name' => 'Asset A', 'last_checkin' => '2023-08-01']); + Asset::factory()->create(['name' => 'Asset B', 'last_checkin' => '2023-08-02']); + Asset::factory()->create(['name' => 'Asset C', 'last_checkin' => '2023-08-03']); + Asset::factory()->create(['name' => 'Asset D', 'last_checkin' => '2023-08-04']); + Asset::factory()->create(['name' => 'Asset E', 'last_checkin' => '2023-08-05']); + + $this->actingAs(User::factory()->canViewReports()->create()) + ->post('reports/custom', [ + 'asset_name' => '1', + 'asset_tag' => '1', + 'serial' => '1', + 'checkin_date' => '1', + 'checkin_date_start' => '2023-08-02', + 'checkin_date_end' => '2023-08-04', + ])->assertOk() + ->assertHeader('content-type', 'text/csv; charset=UTF-8') + ->assertDontSeeTextInStreamedResponse('Asset A') + ->assertSeeTextInStreamedResponse('Asset B') + ->assertSeeTextInStreamedResponse('Asset C') + ->assertSeeTextInStreamedResponse('Asset D') + ->assertDontSeeTextInStreamedResponse('Asset E'); + } } diff --git a/tests/Feature/Users/UpdateUserTest.php b/tests/Feature/Users/UpdateUserTest.php index 9ddb323625..92245059ef 100644 --- a/tests/Feature/Users/UpdateUserTest.php +++ b/tests/Feature/Users/UpdateUserTest.php @@ -10,10 +10,10 @@ class UpdateUserTest extends TestCase { use InteractsWithSettings; - public function testUsersCanBeActivated() + public function testUsersCanBeActivatedWithNumber() { $admin = User::factory()->superuser()->create(); - $user = User::factory()->create(['activated' => false]); + $user = User::factory()->create(['activated' => 0]); $this->actingAs($admin) ->put(route('users.update', $user), [ @@ -22,10 +22,25 @@ class UpdateUserTest extends TestCase 'activated' => 1, ]); - $this->assertTrue($user->refresh()->activated); + $this->assertEquals(1, $user->refresh()->activated); } - public function testUsersCanBeDeactivated() + public function testUsersCanBeActivatedWithBooleanTrue() + { + $admin = User::factory()->superuser()->create(); + $user = User::factory()->create(['activated' => false]); + + $this->actingAs($admin) + ->put(route('users.update', $user), [ + 'first_name' => $user->first_name, + 'username' => $user->username, + 'activated' => true, + ]); + + $this->assertEquals(1, $user->refresh()->activated); + } + + public function testUsersCanBeDeactivatedWithNumber() { $admin = User::factory()->superuser()->create(); $user = User::factory()->create(['activated' => true]); @@ -34,12 +49,25 @@ class UpdateUserTest extends TestCase ->put(route('users.update', $user), [ 'first_name' => $user->first_name, 'username' => $user->username, - // checkboxes that are not checked are - // not included in the request payload - // 'activated' => 0, + 'activated' => 0, ]); - $this->assertFalse($user->refresh()->activated); + $this->assertEquals(0, $user->refresh()->activated); + } + + public function testUsersCanBeDeactivatedWithBooleanFalse() + { + $admin = User::factory()->superuser()->create(); + $user = User::factory()->create(['activated' => true]); + + $this->actingAs($admin) + ->put(route('users.update', $user), [ + 'first_name' => $user->first_name, + 'username' => $user->username, + 'activated' => false, + ]); + + $this->assertEquals(0, $user->refresh()->activated); } public function testUsersUpdatingThemselvesDoNotDeactivateTheirAccount() @@ -50,12 +78,8 @@ class UpdateUserTest extends TestCase ->put(route('users.update', $admin), [ 'first_name' => $admin->first_name, 'username' => $admin->username, - // checkboxes that are disabled are not - // included in the request payload - // even if they are checked - // 'activated' => 0, ]); - $this->assertTrue($admin->refresh()->activated); + $this->assertEquals(1, $admin->refresh()->activated); } }