Skip to content

Commit a45a4f3

Browse files
committed
Merge remote-tracking branch 'origin/develop'
2 parents 283be2a + eda206a commit a45a4f3

9 files changed

Lines changed: 238 additions & 20 deletions

File tree

app/Http/Controllers/Api/ImportController.php

Lines changed: 4 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -217,10 +217,10 @@ public function process(ItemImportRequest $request, $import_id): JsonResponse
217217
{
218218
$this->authorize('import');
219219

220-
// Demo mode: same "feature disabled" gate as store(). Uploading
221-
// was blocked there but processing an existing (seeded or leftover)
222-
// Import row would still mutate the demo DB - close the loophole.
223-
if (config('app.lock_passwords')) {
220+
// Demo mode: uploads stay blocked at store(), but superadmins can
221+
// still process the seeded sample imports so the demo shows off
222+
// the flow end to end.
223+
if (config('app.lock_passwords') && ! auth()->user()->isSuperUser()) {
224224
return response()->json(Helper::formatStandardApiResponse('error', null, trans('general.feature_disabled')), 422);
225225
}
226226

@@ -248,9 +248,6 @@ public function process(ItemImportRequest $request, $import_id): JsonResponse
248248
$redirectTo = 'hardware.index';
249249
switch ($request->input('import-type')) {
250250
case 'asset':
251-
$model_perms = 'App\Models\Asset';
252-
$redirectTo = 'hardware.index';
253-
break;
254251
case 'assetHistory':
255252
$model_perms = 'App\Models\Asset';
256253
$redirectTo = 'hardware.index';

app/Livewire/Importer.php

Lines changed: 7 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -853,10 +853,13 @@ public function selectFile($id)
853853
*/
854854
public function startProcessing(bool $withBackup = false): void
855855
{
856-
// Demo mode: the actual per-slice POSTs would 422 out of
857-
// Api\ImportController::process() anyway, but bail here so we
858-
// don't even flip the UI into processing mode.
859-
if (config('app.lock_passwords')) {
856+
// Demo mode: uploads are blocked at Api\ImportController::store,
857+
// but a demo superadmin should still be able to run the seeded
858+
// sample imports so the end-to-end flow is exercisable in the
859+
// demo. Non-superadmins get the same "feature disabled" bail as
860+
// before. Api\ImportController::process() applies the matching
861+
// gate on the per-slice POSTs.
862+
if (config('app.lock_passwords') && ! auth()->user()->isSuperUser()) {
860863
$this->message = trans('general.feature_disabled');
861864
$this->message_type = 'danger';
862865

database/seeders/DatabaseSeeder.php

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -84,7 +84,8 @@ public function run()
8484
Model::reguard();
8585
DB::statement('SET FOREIGN_KEY_CHECKS=1');
8686

87-
DB::table('imports')->truncate();
87+
$this->call(ImportSeeder::class);
88+
$this->reportMemory('after ImportSeeder');
8889
DB::table('requested_assets')->truncate();
8990

9091
$this->reportMemory('DatabaseSeeder end');

database/seeders/ImportSeeder.php

Lines changed: 90 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,90 @@
1+
<?php
2+
3+
namespace Database\Seeders;
4+
5+
use App\Models\Import;
6+
use App\Models\User;
7+
use Illuminate\Database\Seeder;
8+
use League\Csv\Reader;
9+
10+
class ImportSeeder extends Seeder
11+
{
12+
/**
13+
* Copy a handful of canonical sample CSVs from sample_csvs/ into the
14+
* importer's storage directory and register Import rows for them so
15+
* demo/dev users can exercise the import flow end to end without
16+
* uploading anything themselves. Runs on `db:seed` and is also safe
17+
* to invoke standalone via `db:seed --class=ImportSeeder`. On the
18+
* hosted demo, the reset pipeline calls `db:seed` after paving the
19+
* DB, so this re-seeds automatically without any extra wiring.
20+
*/
21+
public function run(): void
22+
{
23+
Import::truncate();
24+
25+
$samplesDir = base_path('sample_csvs');
26+
27+
if (! is_dir($samplesDir)) {
28+
$this->command?->warn("Sample CSV directory not found at $samplesDir, skipping.");
29+
30+
return;
31+
}
32+
33+
$admin = User::where('permissions->superuser', '1')->first()
34+
?? User::factory()->firstAdmin()->create();
35+
36+
$importsDir = config('app.private_uploads').'/imports';
37+
38+
if (! is_dir($importsDir)) {
39+
mkdir($importsDir, 0755, true);
40+
}
41+
42+
// The subset a demo user is most likely to try. Keeping the list
43+
// short so the imports index doesn't become noisy after repeated
44+
// resets. Ordering is a suggestion, not a dependency chain.
45+
$samples = [
46+
'users-sample.csv',
47+
'assets-sample.csv',
48+
'licenses-sample.csv',
49+
'accessories-sample.csv',
50+
'consumables-sample.csv',
51+
];
52+
53+
foreach ($samples as $sample) {
54+
$source = $samplesDir.'/'.$sample;
55+
56+
if (! is_file($source)) {
57+
continue;
58+
}
59+
60+
// Fixed filename so re-runs overwrite in place instead of
61+
// accumulating dated dupes on disk. The DB row is idempotent
62+
// via the truncate at the top.
63+
$storedName = 'demo-'.$sample;
64+
$destination = $importsDir.'/'.$storedName;
65+
66+
copy($source, $destination);
67+
68+
try {
69+
$reader = Reader::createFromPath($destination);
70+
$headerRow = $reader->nth(0);
71+
$firstRow = $reader->nth(1);
72+
} catch (\Throwable) {
73+
$this->command?->warn("Could not parse $sample, skipping.");
74+
75+
continue;
76+
}
77+
78+
// Explicit set-and-save because Import has no $fillable, so
79+
// mass-assignment helpers (updateOrCreate / firstOrNew) throw.
80+
$import = new Import;
81+
$import->file_path = $storedName;
82+
$import->name = $storedName;
83+
$import->filesize = filesize($destination);
84+
$import->header_row = $headerRow;
85+
$import->first_row = $firstRow;
86+
$import->created_by = $admin->id;
87+
$import->save();
88+
}
89+
}
90+
}

resources/lang/en-US/general.php

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -385,6 +385,7 @@
385385
'open_new_window' => 'Open this file in a new window',
386386
'file_upload_success' => 'File upload success!',
387387
'no_files_uploaded' => 'No files were uploaded.',
388+
'no_import_files_yet' => 'No import files yet. Upload a CSV using the panel on the right to get started.',
388389
'token_expired' => 'Your form session has expired. Please try again.',
389390
'login_enabled' => 'Login Enabled',
390391
'login_disabled' => 'Login Disabled',

resources/views/livewire/importer.blade.php

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -73,6 +73,18 @@
7373
<div class="row">
7474
<div class="col-md-12 table-responsive">
7575

76+
@if ($this->files->isEmpty())
77+
{{-- Nothing to list yet. Hide the whole
78+
table so an empty <thead> doesn't
79+
render as a stub, and point the user
80+
at the upload widget in the sidebar. --}}
81+
<div class="text-center text-muted" style="padding: 40px 20px;">
82+
<p style="font-size: 16px; margin-bottom: 0;">
83+
{{ trans('general.no_import_files_yet') }}
84+
</p>
85+
</div>
86+
@else
87+
7688
@if (count($selectedIds) > 0)
7789
<div class="row" style="padding-bottom: 10px;">
7890
<div class="col-md-12">
@@ -199,6 +211,8 @@ class="col-md-12 table table-striped snipe-table">
199211
</div>
200212
</div>
201213
@endif
214+
215+
@endif {{-- $this->files->isEmpty() --}}
202216
</div>
203217
</div>
204218
</div>

tests/Feature/Assets/Ui/ImportAssetHistoryTest.php

Lines changed: 33 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -28,15 +28,16 @@ public function test_legacy_post_history_endpoint_is_gone(): void
2828
->assertStatus(405);
2929
}
3030

31-
public function test_process_endpoint_blocked_in_demo_mode(): void
31+
public function test_process_endpoint_blocked_in_demo_mode_for_non_superadmin(): void
3232
{
33-
// Uploads were already blocked at Api\ImportController::store,
34-
// but the process endpoint would still let a demo user mutate the
35-
// DB via any seeded / leftover Import row. The lock_passwords
36-
// gate here closes that loophole.
33+
// Uploads are blocked at Api\ImportController::store, and non-
34+
// superadmins are also blocked from processing so they can't
35+
// mutate the demo DB via any leftover Import row. Superadmins
36+
// are allowed through so they can exercise the seeded demo
37+
// samples end to end (see companion test below).
3738
config(['app.lock_passwords' => true]);
3839

39-
$actor = User::factory()->canImport()->superuser()->create();
40+
$actor = User::factory()->canImport()->create();
4041
$import = Import::factory()->assetHistory()->create(['created_by' => $actor->id]);
4142

4243
$this->actingAsForApi($actor);
@@ -46,6 +47,32 @@ public function test_process_endpoint_blocked_in_demo_mode(): void
4647
)->assertStatus(422);
4748
}
4849

50+
public function test_process_endpoint_allowed_in_demo_mode_for_superadmin(): void
51+
{
52+
// Superadmins bypass the demo-mode gate on process() so the
53+
// seeded sample CSVs (populated by snipeit:demo-settings) can
54+
// actually be run against the demo DB. Without a real CSV on
55+
// disk the import will error out below the gate, so this test
56+
// just proves the gate itself lets the superadmin through
57+
// (any status other than 422 "feature disabled" is fine).
58+
config(['app.lock_passwords' => true]);
59+
60+
$actor = User::factory()->canImport()->superuser()->create();
61+
$import = Import::factory()->assetHistory()->create(['created_by' => $actor->id]);
62+
63+
$this->actingAsForApi($actor);
64+
$response = $this->postJson(
65+
route('api.imports.importFile', ['import' => $import->id]),
66+
['import-type' => 'assetHistory', 'import' => $import->id],
67+
);
68+
69+
$this->assertNotEquals(
70+
trans('general.feature_disabled'),
71+
$response->json('messages'),
72+
'Superadmin should not hit the demo-mode gate on process().',
73+
);
74+
}
75+
4976
public function test_asset_history_import_requires_import_permission(): void
5077
{
5178
$actor = User::factory()->create();

tests/Feature/Livewire/ImporterTest.php

Lines changed: 15 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -508,12 +508,13 @@ public function test_auto_map_handles_underscore_style_headers(): void
508508
});
509509
}
510510

511-
public function test_demo_mode_blocks_start_processing(): void
511+
public function test_demo_mode_blocks_start_processing_for_non_superadmin(): void
512512
{
513513
// With lock_passwords set the Process button on the wizard is
514514
// disabled in the blade, but a hand-crafted Livewire call would
515515
// still fire the action - guard it server-side so the modal can't
516-
// flip into processing mode either.
516+
// flip into processing mode either. Superadmins bypass this gate
517+
// in demo mode so the seeded demo imports can actually be run.
517518
config(['app.lock_passwords' => true]);
518519

519520
$user = User::factory()->canImport()->create();
@@ -525,6 +526,18 @@ public function test_demo_mode_blocks_start_processing(): void
525526
->assertSet('message_type', 'danger');
526527
}
527528

529+
public function test_demo_mode_allows_start_processing_for_superadmin(): void
530+
{
531+
config(['app.lock_passwords' => true]);
532+
533+
$user = User::factory()->canImport()->superuser()->create();
534+
535+
Livewire::actingAs($user)
536+
->test(Importer::class)
537+
->call('startProcessing')
538+
->assertSet('processing', true);
539+
}
540+
528541
public function test_demo_mode_blocks_destroy(): void
529542
{
530543
config(['app.lock_passwords' => true]);
Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,72 @@
1+
<?php
2+
3+
namespace Tests\Feature\Seeders;
4+
5+
use App\Models\Import;
6+
use Database\Seeders\ImportSeeder;
7+
use Tests\TestCase;
8+
9+
class ImportSeederTest extends TestCase
10+
{
11+
private array $seededPaths = [];
12+
13+
protected function setUp(): void
14+
{
15+
parent::setUp();
16+
17+
if (! is_dir(base_path('sample_csvs'))) {
18+
$this->markTestSkipped('sample_csvs directory is not present on this checkout.');
19+
}
20+
}
21+
22+
protected function tearDown(): void
23+
{
24+
// The seeder writes real files via copy(); clean up so nothing
25+
// leaks between tests. DB rows go with the transaction.
26+
foreach ($this->seededPaths as $path) {
27+
@unlink($path);
28+
}
29+
30+
parent::tearDown();
31+
}
32+
33+
private function trackSeededFiles(): void
34+
{
35+
$importsDir = config('app.private_uploads').'/imports';
36+
foreach (Import::where('file_path', 'like', 'demo-%.csv')->get() as $import) {
37+
$this->seededPaths[] = $importsDir.'/'.$import->file_path;
38+
}
39+
}
40+
41+
public function test_seeds_a_handful_of_sample_imports(): void
42+
{
43+
$this->seed(ImportSeeder::class);
44+
$this->trackSeededFiles();
45+
46+
$seeded = Import::where('file_path', 'like', 'demo-%.csv')->get();
47+
48+
$this->assertGreaterThan(0, $seeded->count(), 'Expected at least one demo import to be seeded.');
49+
$this->assertContains('demo-users-sample.csv', $seeded->pluck('file_path')->all());
50+
51+
$importsDir = config('app.private_uploads').'/imports';
52+
foreach ($seeded as $import) {
53+
$this->assertFileExists($importsDir.'/'.$import->file_path);
54+
$this->assertIsArray($import->header_row);
55+
$this->assertIsArray($import->first_row);
56+
$this->assertNotEmpty($import->header_row);
57+
}
58+
}
59+
60+
public function test_seeding_is_idempotent(): void
61+
{
62+
$this->seed(ImportSeeder::class);
63+
$firstRunCount = Import::where('file_path', 'like', 'demo-%.csv')->count();
64+
65+
$this->seed(ImportSeeder::class);
66+
$secondRunCount = Import::where('file_path', 'like', 'demo-%.csv')->count();
67+
68+
$this->trackSeededFiles();
69+
70+
$this->assertSame($firstRunCount, $secondRunCount, 'Repeated seeder runs must not accumulate duplicate imports.');
71+
}
72+
}

0 commit comments

Comments
 (0)