Files
projectsend/tests/Feature/Audit/DashboardWidgetPreferencesTest.php
denkfabrik-li 1ed29ec072 Bound the two preference endpoints by their own registries
Both preference writers validated their array as ['required', 'array']
and looped updateOrCreate over it:

    'widgets' => ['required', 'array'],
    'widgets.*.widget_key' => ['required', 'string', Rule::in(WIDGET_KEYS)],

Rule::in answers "is this a key I know", once per element. It says
nothing about how many elements there are, and nothing about whether they
repeat -- so a request could name the same valid key any number of times
and buy a SELECT and an UPDATE for each one.

Measured on this base, sent as JSON (a form-encoded array that size is
truncated by max_input_vars long before it reaches the controller):

    widgets        10 entries    37 queries   1 row
                  500 entries  1044 queries   1 row
                 3000 entries  7051 queries   1 row

    notifications   10 entries    23 queries   1 row
                 2000 entries  2025 queries   1 row

One row, every time. The work is not even data growth -- 3000 entries
write the same single row 3000 times, because updateOrCreate matches on
(user_id, widget_key) and every element after the first is an update of
what the one before it just wrote.

Neither route is behind a throttle: bootstrap/app.php applies
throttleApi() to the API group only, /dashboard/widgets is behind `auth`
alone and /settings/notifications is deliberately outside the `staff`
group, since every account manages its own. So the weakest account on the
installation -- a client with no permission at all -- can reach both, and
the only ceiling is post_max_size.

Both are bounded by the list they already validate against, not by a
number:

  - widgets by count(self::WIDGET_KEYS), the same constant Rule::in reads.
  - preferences by count($this->emailableKeys()), because
    NotificationTypeRegistry is deliberately open -- "never a closed enum,
    since core must not need to know a package's notification type keys at
    compile time" -- so a literal would be wrong the day a module
    registers one.

`distinct` on the key does the other half: a layout has at most one entry
per widget, which is what the screen sends and what the loop assumes.

After: 3000 entries cost 30 queries and write nothing, refused with a 422
instead of half-applied.

Two findings, one cause, one change -- they are the same three words in
two modules, and splitting them would leave the rule stated once and
broken once. Tests live with each controller: two refusals each, both
failing against the unfixed controllers, plus one for the largest
legitimate submission -- a full nine-widget layout, and every emailable
type at once -- so the bound can never be tighter than the screen.
2026-08-28 23:53:12 +02:00

182 lines
7.2 KiB
PHP

<?php
declare(strict_types=1);
use App\Models\User;
use App\Modules\Audit\Models\DashboardWidgetPreference;
use App\Modules\Identity\Models\Role;
use App\Modules\Identity\Models\RolePermission;
use Illuminate\Support\Facades\DB;
use Inertia\Testing\AssertableInertia;
beforeEach(function () {
$this->admin = User::factory()->create();
});
test('a first-time user gets the documented default layout', function () {
$this->actingAs($this->admin)->get('/dashboard')->assertInertia(
fn (AssertableInertia $page) => $page
->where('dashboard_columns', 2)
->where('widget_layout.counters', ['enabled' => true, 'column_index' => 0, 'position' => 0])
->where('widget_layout.transfers', ['enabled' => true, 'column_index' => 0, 'position' => 1])
->where('widget_layout.recent', ['enabled' => true, 'column_index' => 0, 'position' => 2])
->where('widget_layout.top_clients_by_storage', ['enabled' => true, 'column_index' => 1, 'position' => 0])
->where('widget_layout.largest_files', ['enabled' => true, 'column_index' => 1, 'position' => 1]),
);
});
test('saving a layout persists it and is honored on the next load', function () {
$this->actingAs($this->admin)->put('/dashboard/widgets', [
'columns' => 2,
'widgets' => [
['widget_key' => 'counters', 'enabled' => true, 'column_index' => 1, 'position' => 0],
['widget_key' => 'transfers', 'enabled' => false, 'column_index' => 0, 'position' => 0],
],
])->assertRedirect();
expect($this->admin->fresh()->dashboard_columns)->toBe(2);
$this->actingAs($this->admin)->get('/dashboard')->assertInertia(
fn (AssertableInertia $page) => $page
->where('dashboard_columns', 2)
->where('widget_layout.counters', ['enabled' => true, 'column_index' => 1, 'position' => 0])
->where('transfers', null),
);
});
test('saving accepts the expired_files widget key', function () {
// Regression: expired_files was added to the frontend's widget list
// after this endpoint's validation allowlist was written, so every
// save (which always round-trips the full layout, including this
// key) was silently rejected with a 422.
$this->actingAs($this->admin)->put('/dashboard/widgets', [
'columns' => 2,
'widgets' => [
['widget_key' => 'expired_files', 'enabled' => false, 'column_index' => 0, 'position' => 3],
],
])->assertRedirect()->assertSessionHasNoErrors();
$this->assertDatabaseHas('dashboard_widget_preferences', [
'user_id' => $this->admin->id,
'widget_key' => 'expired_files',
'enabled' => false,
]);
});
test('a widget preference for a key the viewer lacks permission for has no effect on read', function () {
// Saved directly, bypassing the endpoint entirely — the read-side
// permission check must hold regardless of how a row got there
// (same "runtime gate must hold" property already proven for
// external storage and mail transport).
DashboardWidgetPreference::query()->create([
'user_id' => $this->admin->id,
'widget_key' => 'system',
'enabled' => true,
'column_index' => 0,
'position' => 0,
]);
$role = Role::query()->create(['name' => 'No System Info', 'is_administrator' => false, 'is_system' => false]);
RolePermission::query()->insert(['role_id' => $role->id, 'permission' => 'edit_files']);
$staff = User::factory()->create(['role_id' => $role->id]);
$this->actingAs($staff)->get('/dashboard')->assertInertia(
fn (AssertableInertia $page) => $page
->where('system', null)
->missing('widget_layout.system'),
);
});
test('disabling a widget hides it from the dashboard payload', function () {
DashboardWidgetPreference::query()->create([
'user_id' => $this->admin->id,
'widget_key' => 'news',
'enabled' => false,
'column_index' => 2,
'position' => 1,
]);
$this->actingAs($this->admin)->get('/dashboard')->assertInertia(
fn (AssertableInertia $page) => $page
->where('news', null)
->where('widget_layout.news.enabled', false),
);
});
test('saving rejects an invalid column count', function () {
$this->actingAs($this->admin)->put('/dashboard/widgets', [
'columns' => 5,
'widgets' => [],
])->assertSessionHasErrors(['columns']);
});
test('saving rejects an unknown widget key', function () {
$this->actingAs($this->admin)->put('/dashboard/widgets', [
'columns' => 3,
'widgets' => [
['widget_key' => 'not_a_real_widget', 'enabled' => true, 'column_index' => 0, 'position' => 0],
],
])->assertSessionHasErrors(['widgets.0.widget_key']);
});
test('saving rejects a layout longer than the widget list', function () {
// The allowlist checks each value, not how many there are, and the
// loop wrote a row per element -- so one request could be made to
// cost a query per entry with nothing to show for it. Sent as JSON:
// a form-encoded array this large is truncated by max_input_vars long
// before it reaches the rule under test.
$widgets = [];
for ($i = 0; $i < 3000; $i++) {
$widgets[] = ['widget_key' => 'counters', 'enabled' => true, 'column_index' => 0, 'position' => $i];
}
DB::enableQueryLog();
$this->actingAs($this->admin)
->putJson('/dashboard/widgets', ['columns' => 2, 'widgets' => $widgets])
->assertJsonValidationErrors(['widgets']);
$queries = count(DB::getQueryLog());
DB::disableQueryLog();
// Refused before the loop, so the cost is the session's own reads
// rather than one write per element.
expect($queries)->toBeLessThan(20)
->and(DashboardWidgetPreference::query()->count())->toBe(0);
});
test('saving rejects the same widget key twice', function () {
// Rule::in passes on both, and updateOrCreate made the second a
// pointless rewrite of the row the first had just created.
$this->actingAs($this->admin)->putJson('/dashboard/widgets', [
'columns' => 2,
'widgets' => [
['widget_key' => 'counters', 'enabled' => true, 'column_index' => 0, 'position' => 0],
['widget_key' => 'counters', 'enabled' => false, 'column_index' => 1, 'position' => 1],
],
])->assertJsonValidationErrors(['widgets.0.widget_key']);
expect(DashboardWidgetPreference::query()->count())->toBe(0);
});
test('a full layout of every widget still saves', function () {
// The bound is the allowlist, so the largest legitimate layout -- one
// entry per widget this screen knows -- has to pass.
$keys = ['counters', 'transfers', 'top_clients_by_storage', 'largest_files',
'recent', 'system', 'news', 'expired_files', 'api'];
$widgets = [];
foreach ($keys as $index => $key) {
$widgets[] = ['widget_key' => $key, 'enabled' => true, 'column_index' => 0, 'position' => $index];
}
$this->actingAs($this->admin)
->put('/dashboard/widgets', ['columns' => 2, 'widgets' => $widgets])
->assertSessionHasNoErrors();
expect(DashboardWidgetPreference::query()->where('user_id', $this->admin->id)->count())
->toBe(count($keys));
});