3
0
mirror of https://github.com/snipe/snipe-it.git synced 2026-08-18 11:15:42 +00:00

Merge branch 'develop' into calendar-ui

This commit is contained in:
snipe
2026-08-16 12:18:04 +01:00
committed by GitHub
8 changed files with 202 additions and 55 deletions

View File

@ -159,11 +159,15 @@ class AccessoriesController extends Controller
// create-form input names so enrichInitialOrderFromRequest
// in store() picks them up on save and writes them onto the
// observer-created initial Order + OrderItem for the new row.
foreach ($accessory->lastOrderPrefill() as $field => $value) {
if ($value !== null) {
$cloned->{$field} = $value;
}
}
// Explicit assignments (not a foreach) so each typed value from
// lastOrderPrefill() lands on the matching typed model property
// without going through a mixed intermediate that would fail
// larastan's assign.propertyType check.
$prefill = $accessory->lastOrderPrefill();
$cloned->supplier_id = $prefill['supplier_id'];
$cloned->purchase_date = $prefill['purchase_date'];
$cloned->purchase_cost = $prefill['purchase_cost'];
$cloned->order_number = $prefill['order_number'];
return view('accessories/edit')
->with('cloned_model', $accessory_to_clone)

View File

@ -252,14 +252,13 @@ class ComponentsController extends Controller
$cloned_component->id = null;
$cloned_component->deleted_at = null;
// See AccessoriesController::getClone — same rationale for
// carrying the source item's most recent acquisition context
// onto the cloned create form.
foreach ($component->lastOrderPrefill() as $field => $value) {
if ($value !== null) {
$cloned_component->{$field} = $value;
}
}
// See AccessoriesController::getClone for the rationale, including
// the note on why these are explicit assignments not a foreach.
$prefill = $component->lastOrderPrefill();
$cloned_component->supplier_id = $prefill['supplier_id'];
$cloned_component->purchase_date = $prefill['purchase_date'];
$cloned_component->purchase_cost = $prefill['purchase_cost'];
$cloned_component->order_number = $prefill['order_number'];
// Show the page
return view('components/edit')

View File

@ -255,14 +255,13 @@ class ConsumablesController extends Controller
$consumable->id = null;
$consumable->created_by = null;
// See AccessoriesController::getClone — same rationale for
// carrying the source item's most recent acquisition context
// onto the cloned create form.
foreach ($consumable_to_close->lastOrderPrefill() as $field => $value) {
if ($value !== null) {
$consumable->{$field} = $value;
}
}
// See AccessoriesController::getClone for the rationale, including
// the note on why these are explicit assignments not a foreach.
$prefill = $consumable_to_close->lastOrderPrefill();
$consumable->supplier_id = $prefill['supplier_id'];
$consumable->purchase_date = $prefill['purchase_date'];
$consumable->purchase_cost = $prefill['purchase_cost'];
$consumable->order_number = $prefill['order_number'];
return view('consumables/edit')
->with('cloned_model', $consumable_to_close)

View File

@ -255,8 +255,35 @@ class SnipeMutableCollection extends MutableCollection
// stash the object into the request so the displayName uniqueness closure
// (which re-runs after mapping) can recognize its own row instead of
// treating it as an existing name collision.
//
// Missing-value guard: the per-member `required` rule that used to live on
// the SCIM config was dropped because it caused ValidationRuleParser to
// allocate O(N) rule stacks on the flattened payload, which OOMed on
// large group syncs (see the docblock above the members mapping in
// SnipeSCIMConfig::getGroupConfig). The check now happens here in a
// single walk so clients get a clean 400 pointing at the bad indices
// instead of the parent library's misleading 500 with an empty
// "One or more members are unknown: " message from findMany() eating
// the nulls.
public function add($value, Model &$object)
{
$missing = [];
foreach ((array) $value as $index => $entry) {
if (!is_array($entry)
|| !array_key_exists('value', $entry)
|| $entry['value'] === null
|| $entry['value'] === ''
) {
$missing[] = $index;
}
}
if ($missing !== []) {
throw new SCIMException(
'Every members entry must include a "value" field. Missing at indices: ' . implode(',', $missing),
400
);
}
if (! $object->exists) {
$object->save();
request()->attributes->set('scim_in_flight_resource', $object);
@ -413,19 +440,19 @@ class SCIMMultiCompanyArray extends Attribute
public function add($value, Model &$object)
{
\Log::debug("MC ADD VALUE IS: " . print_r($value, true));
\Log::debug('MC ADD VALUE IS: ' . print_r($value, true));
$this->applyCompanies($value, $object);
}
public function replace($value, Model &$object, $path = null, $removeIfNotSet = false)
{
\Log::debug("MC REPLACE VALUE IS: " . print_r($value, true));
\Log::debug('MC REPLACE VALUE IS: ' . print_r($value, true));
$this->applyCompanies($value, $object);
}
public function patch($operation, $value, Model &$object, ?Path $path = null, $removeIfNotSet = false)
{
\Log::debug("MC PATCH VALUE IS: " . print_r($value, true));
\Log::debug('MC PATCH VALUE IS: ' . print_r($value, true));
$this->applyCompanies($value, $object);
}
}
@ -710,7 +737,7 @@ class SnipeSCIMConfig
} else {
// Okta hits this one for creating a user - it does a full PUT for their ID
\Log::debug("GetValuePAthFilter is null for path: $path");
\Log::debug("GetValuePathFilter is now null and trying to set value of: " . print_r($value, true));
\Log::debug('GetValuePathFilter is now null and trying to set value of: ' . print_r($value, true));
// the Addresses object is a 'list' (array with numeric indices) by definition...
if (is_array($value) && array_is_list($value)) {
foreach ($value as $address) {
@ -718,18 +745,18 @@ class SnipeSCIMConfig
if (@$address['type'] == 'work') {
foreach ($address as $key => $v) {
if (array_key_exists($key, self::$addressmap)) {
\Log::debug("Addresses: Setting " . self::$addressmap[$key] . " to '$v'");
\Log::debug('Addresses: Setting ' . self::$addressmap[$key] . " to '$v'");
$object->{self::$addressmap[$key]} = $v;
}
}
} else {
//should we throw if you give us a 'home' address? I don't know.
// should we throw if you give us a 'home' address? I don't know.
// what if you gave us _both_ ?
}
}
} else {
\Log::debug("Unknown Address Object: " . print_r($value, true));
throw new SCIMException("Unknown Address object of type: " . gettype($value), 422);
\Log::debug('Unknown Address Object: ' . print_r($value, true));
throw new SCIMException('Unknown Address object of type: ' . gettype($value), 422);
}
}
}
@ -892,8 +919,18 @@ class SnipeSCIMConfig
}
$fail('The name has already been taken.');
}),
// The per-member `required` rule on `value` used to live
// on the eloquent() below. Removed intentionally: Laravel's
// ValidationRuleParser::mergeRulesForAttribute allocates one
// rule stack per attribute path in the flattened payload, so
// an incoming members array of N entries produced O(N) rule
// stacks and blew the PHP memory_limit on large group syncs
// The per-member value check now lives inside
// SnipeMutableCollection::add() as one array walk, and the
// parent ensure() below adds a `max:` guardrail so a truly
// runaway payload still gets rejected with a clean 400.
(new SnipeMutableCollection('members'))->withSubAttributes(
eloquent('value', 'id')->ensure('required'),
eloquent('value', 'id'),
(new class('$ref') extends Eloquent
{
protected function doRead(&$object, $attributes = [])
@ -908,7 +945,7 @@ class SnipeSCIMConfig
}
}),
eloquent('display', 'name')
)->ensure('nullable', 'array')
)->ensure('nullable', 'array', 'max:200000')
)
),
];

View File

@ -143,9 +143,7 @@ trait HasOrders
* Prefill values for the create / clone form's initial-acquisition
* fields. Distinct from lastOrderDefaults() because this shape
* includes `order_number` (per-shipment, not a "default" concept)
* and matches the request keys the create form posts back, so a
* controller can loop the return array to assign values directly
* onto a cloned model's attributes.
* and matches the request keys the create form posts back.
*
* Used by getClone() on Accessories / Consumables / Components to
* carry the source item's most recent acquisition context onto the
@ -154,26 +152,35 @@ trait HasOrders
* / price for a fast restock. Items with no order history return
* an all-null array.
*
* Returns native types (Carbon for purchase_date, float for
* purchase_cost) so getClone() can assign directly onto typed
* model properties without a coercion step and without tripping
* larastan's assign.propertyType on the cast-inferred property
* signatures. The @var below is needed because MorphMany::first()
* infers to Model|null; the annotation resolves $line to OrderItem
* so `->order` and `->price` accesses type through cleanly rather
* than falling into the Model::$order baseline ignore bucket.
*
* @return array{
* supplier_id: ?int,
* purchase_date: ?string,
* purchase_cost: ?string,
* purchase_date: ?\Carbon\Carbon,
* purchase_cost: ?float,
* order_number: ?string,
* }
*/
public function lastOrderPrefill(): array
{
/** @var \App\Models\OrderItem|null $line */
$line = $this->orderItems()
->with('order:id,order_number,supplier_id,purchase_date')
->latest('id')
->first();
$order = $line?->order;
return [
'supplier_id' => $order?->supplier_id,
'purchase_date' => $order?->purchase_date?->toDateString(),
'purchase_cost' => $line?->price !== null ? (string) $line->price : null,
'order_number' => $order?->order_number,
'supplier_id' => $line?->order?->supplier_id,
'purchase_date' => $line?->order?->purchase_date,
'purchase_cost' => $line?->price !== null ? (float) $line->price : null,
'order_number' => $line?->order?->order_number,
];
}

View File

@ -5724,16 +5724,28 @@ parameters:
count: 6
path: app/Models/Accessory.php
-
message: '#^Access to an undefined property Illuminate\\Database\\Eloquent\\Model\:\:\$order_number\.$#'
identifier: property.notFound
count: 1
path: app/Models/Accessory.php
-
message: '#^Access to an undefined property Illuminate\\Database\\Eloquent\\Model\:\:\$price\.$#'
identifier: property.notFound
count: 1
path: app/Models/Accessory.php
-
message: '#^Access to an undefined property Illuminate\\Database\\Eloquent\\Model\:\:\$purchase_date\.$#'
identifier: property.notFound
count: 1
path: app/Models/Accessory.php
-
message: '#^Access to an undefined property Illuminate\\Database\\Eloquent\\Model\:\:\$supplier_id\.$#'
identifier: property.notFound
count: 1
count: 2
path: app/Models/Accessory.php
-
@ -6018,16 +6030,28 @@ parameters:
count: 6
path: app/Models/Asset.php
-
message: '#^Access to an undefined property Illuminate\\Database\\Eloquent\\Model\:\:\$order_number\.$#'
identifier: property.notFound
count: 1
path: app/Models/Asset.php
-
message: '#^Access to an undefined property Illuminate\\Database\\Eloquent\\Model\:\:\$price\.$#'
identifier: property.notFound
count: 1
path: app/Models/Asset.php
-
message: '#^Access to an undefined property Illuminate\\Database\\Eloquent\\Model\:\:\$purchase_date\.$#'
identifier: property.notFound
count: 1
path: app/Models/Asset.php
-
message: '#^Access to an undefined property Illuminate\\Database\\Eloquent\\Model\:\:\$supplier_id\.$#'
identifier: property.notFound
count: 1
count: 2
path: app/Models/Asset.php
-
@ -6930,16 +6954,28 @@ parameters:
count: 6
path: app/Models/Component.php
-
message: '#^Access to an undefined property Illuminate\\Database\\Eloquent\\Model\:\:\$order_number\.$#'
identifier: property.notFound
count: 1
path: app/Models/Component.php
-
message: '#^Access to an undefined property Illuminate\\Database\\Eloquent\\Model\:\:\$price\.$#'
identifier: property.notFound
count: 1
path: app/Models/Component.php
-
message: '#^Access to an undefined property Illuminate\\Database\\Eloquent\\Model\:\:\$purchase_date\.$#'
identifier: property.notFound
count: 1
path: app/Models/Component.php
-
message: '#^Access to an undefined property Illuminate\\Database\\Eloquent\\Model\:\:\$supplier_id\.$#'
identifier: property.notFound
count: 1
count: 2
path: app/Models/Component.php
-
@ -7140,16 +7176,28 @@ parameters:
count: 6
path: app/Models/Consumable.php
-
message: '#^Access to an undefined property Illuminate\\Database\\Eloquent\\Model\:\:\$order_number\.$#'
identifier: property.notFound
count: 1
path: app/Models/Consumable.php
-
message: '#^Access to an undefined property Illuminate\\Database\\Eloquent\\Model\:\:\$price\.$#'
identifier: property.notFound
count: 1
path: app/Models/Consumable.php
-
message: '#^Access to an undefined property Illuminate\\Database\\Eloquent\\Model\:\:\$purchase_date\.$#'
identifier: property.notFound
count: 1
path: app/Models/Consumable.php
-
message: '#^Access to an undefined property Illuminate\\Database\\Eloquent\\Model\:\:\$supplier_id\.$#'
identifier: property.notFound
count: 1
count: 2
path: app/Models/Consumable.php
-

View File

@ -100,16 +100,6 @@ Route::middleware(['web', 'auth', 'authorize:superuser'])->prefix('oauth')->grou
});
Route::group(['middleware' => 'auth'], function () {
/*
* Calendar (unified view across every HasCalendarEvents source).
* Companion API endpoint lives at /api/v1/calendar/events.
*/
Route::get('calendar', [App\Http\Controllers\CalendarEventsController::class, 'index'])
->name('calendar.index')
->breadcrumbs(fn (Tabuna\Breadcrumbs\Trail $trail) => $trail->parent('home')
->push(trans('general.calendar'), route('calendar.index'))
);
/*
* Companies
*/

View File

@ -65,4 +65,67 @@ class CreateGroupWithMembersTest extends TestCase
$response->assertStatus(201);
$this->assertDatabaseHas('permission_groups', ['name' => 'SCIM Group No Members']);
}
public function test_post_group_with_many_members_does_not_explode_validator()
{
// Regression for the SCIM group-sync OOM at
// ValidationRuleParser::mergeRulesForAttribute:227. Previously the
// per-member `required` rule on `value` caused Laravel to allocate
// one rule stack per member entry in the flattened payload, so a
// 101k-member group sync blew a 256MB PHP process before any code
// ran. Rule was dropped from SnipeSCIMConfig::getGroupConfig and
// the check moved into SnipeMutableCollection::add so the validator
// does O(1) work on the members array regardless of size.
//
// 25 members here is a proxy for the customer's much larger sync.
// If the O(N) rule explosion regresses, larger integration
// environments would OOM again; a per-item explosion at N=25 is
// fine on 256MB but any code path that reintroduces it is caught
// by the mechanism-level assertion in
// GroupMembersValidationShapeTest.
Passport::actingAs(User::factory()->superuser()->create());
$members = User::factory()->count(25)->create();
$response = $this->postJson('/scim/v2/Groups', [
'schemas' => ['urn:ietf:params:scim:schemas:core:2.0:Group'],
'displayName' => 'SCIM Group Many Members',
'members' => $members->map(fn ($u) => ['value' => $u->id])->all(),
]);
$response->assertStatus(201);
$group = Group::where('name', 'SCIM Group Many Members')->firstOrFail();
$this->assertSame(25, DB::table('users_groups')->where('group_id', $group->id)->count());
}
public function test_post_group_with_member_missing_value_returns_400_with_indices()
{
// Regression for the parent library's 500 with an empty
// "One or more members are unknown: " message when a members entry
// arrives without its `value` field. SnipeMutableCollection::add
// now catches this at attach time with a 400 that names the
// offending indices, matching how bad request bodies are surfaced
// elsewhere in the SCIM stack (SnipeRootComplex::add/replace also
// route malformed keys through 400s).
Passport::actingAs(User::factory()->superuser()->create());
$goodMember = User::factory()->create();
$response = $this->postJson('/scim/v2/Groups', [
'schemas' => ['urn:ietf:params:scim:schemas:core:2.0:Group'],
'displayName' => 'SCIM Group Bad Member',
'members' => [
['value' => $goodMember->id],
['display' => 'no value key here'],
['value' => null],
],
]);
$response->assertStatus(400);
$body = $response->json();
$this->assertStringContainsString('Every members entry must include', json_encode($body));
// Both offending entries by their indices in the payload.
$this->assertStringContainsString('1', json_encode($body));
$this->assertStringContainsString('2', json_encode($body));
}
}