Skip to content

Commit 284cf8f

Browse files
committed
Added presenter output tests
1 parent 254e290 commit 284cf8f

2 files changed

Lines changed: 119 additions & 3 deletions

File tree

app/Presenters/DepartmentPresenter.php

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -147,9 +147,13 @@ public function viewUrl()
147147
{
148148
if (auth()->user()->can('view', ['\App\Models\Department', $this])) {
149149
return '<a href="'.route('departments.show', $this->id).'">'.e($this->display_name).'</a>';
150-
} else {
151-
return $this->display_name;
152150
}
151+
152+
// Escape the fallback too. Current callers pipe this into Slack/webhook
153+
// payloads where XSS does not apply, but a future caller rendering it
154+
// into a Blade {!! !!} context would reintroduce the same class of bug
155+
// fixed in formattedNameLink (FD-56438).
156+
return e($this->display_name);
153157
}
154158

155159
public function formattedNameLink()
@@ -159,7 +163,7 @@ public function formattedNameLink()
159163
return ($this->tag_color ? "<i class='fa-solid fa-fw fa-square' style='color: ".e($this->tag_color)."' aria-hidden='true'></i>" : '').'<a href="'.route('departments.show', e($this->id)).'">'.e($this->name).'</a>';
160164
}
161165

162-
return ($this->tag_color ? "<i class='fa-solid fa-fw fa-square' style='color: " . e($this->tag_color) . "' aria-hidden='true'></i>" : '') . e($this->name);
166+
return ($this->tag_color ? "<i class='fa-solid fa-fw fa-square' style='color: ".e($this->tag_color)."' aria-hidden='true'></i>" : '').e($this->name);
163167
}
164168

165169
public function nameUrl()
Lines changed: 112 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,112 @@
1+
<?php
2+
3+
namespace Tests\Unit\Presenters;
4+
5+
use App\Models\Accessory;
6+
use App\Models\Asset;
7+
use App\Models\AssetModel;
8+
use App\Models\Category;
9+
use App\Models\Company;
10+
use App\Models\Component;
11+
use App\Models\Consumable;
12+
use App\Models\CustomFieldset;
13+
use App\Models\Department;
14+
use App\Models\Depreciation;
15+
use App\Models\License;
16+
use App\Models\Location;
17+
use App\Models\Manufacturer;
18+
use App\Models\PredefinedKit;
19+
use App\Models\Supplier;
20+
use App\Models\User;
21+
use PHPUnit\Framework\Attributes\DataProvider;
22+
use Tests\TestCase;
23+
24+
/**
25+
* Sweep test that locks in HTML-escaping of user-controlled model attributes
26+
* across every presenter that renders a name/display_name into an HTML sink.
27+
*
28+
* The motivating bug (FD-56438, fixed in 36d472489c) was a stored XSS where
29+
* DepartmentPresenter::formattedNameLink()'s fallback branch (rendered for
30+
* users without departments.view) returned the department name unescaped.
31+
* The shape "permission-gated branch selection where only one branch
32+
* escapes" is easy to miss in review, so this test exercises both branches
33+
* of every listed presenter method with a script-tag payload and asserts
34+
* the raw payload never appears in the output.
35+
*
36+
* Adding a new presenter method that emits a user-controlled string into
37+
* HTML? Add it to the cases below so a future refactor cannot silently
38+
* drop an e(...) call.
39+
*/
40+
class PresenterEscapingTest extends TestCase
41+
{
42+
private const PAYLOAD = '<script>alert(1)</script>';
43+
44+
private const EXPECTED_ESCAPED = '&lt;script&gt;alert(1)&lt;/script&gt;';
45+
46+
/**
47+
* @return array<string, array{class-string, array<string, mixed>, list<string>}>
48+
*/
49+
public static function presenterCases(): array
50+
{
51+
return [
52+
// Simple name-based presenters.
53+
'Accessory::nameUrl' => [Accessory::class, ['name' => self::PAYLOAD], ['nameUrl']],
54+
'AssetModel' => [AssetModel::class, ['name' => self::PAYLOAD], ['nameUrl', 'formattedNameLink']],
55+
'Asset' => [Asset::class, ['name' => self::PAYLOAD], ['nameUrl', 'formattedNameLink']],
56+
'Category' => [Category::class, ['name' => self::PAYLOAD], ['nameUrl', 'formattedNameLink']],
57+
'Company' => [Company::class, ['name' => self::PAYLOAD], ['nameUrl', 'formattedNameLink']],
58+
'Component::nameUrl' => [Component::class, ['name' => self::PAYLOAD], ['nameUrl']],
59+
'Consumable::nameUrl' => [Consumable::class, ['name' => self::PAYLOAD], ['nameUrl']],
60+
'CustomFieldset::nameUrl' => [CustomFieldset::class, ['name' => self::PAYLOAD], ['nameUrl']],
61+
'Department' => [Department::class, ['name' => self::PAYLOAD], ['nameUrl', 'formattedNameLink', 'viewUrl']],
62+
'Depreciation' => [Depreciation::class, ['name' => self::PAYLOAD], ['nameUrl', 'formattedNameLink']],
63+
'License::nameUrl' => [License::class, ['name' => self::PAYLOAD], ['nameUrl']],
64+
'Location' => [Location::class, ['name' => self::PAYLOAD], ['nameUrl', 'formattedNameLink']],
65+
'Manufacturer' => [Manufacturer::class, ['name' => self::PAYLOAD], ['nameUrl', 'formattedNameLink']],
66+
'PredefinedKit::nameUrl' => [PredefinedKit::class, ['name' => self::PAYLOAD], ['nameUrl']],
67+
'Supplier' => [Supplier::class, ['name' => self::PAYLOAD], ['nameUrl', 'formattedNameLink', 'viewUrl']],
68+
69+
// User composes display_name from first_name + last_name, so plant
70+
// the payload in first_name (last_name kept plain).
71+
'User' => [User::class, ['first_name' => self::PAYLOAD, 'last_name' => 'X'], ['nameUrl', 'formattedNameLink']],
72+
];
73+
}
74+
75+
/**
76+
* Both branches must escape: the fallback (viewer lacks the resource's
77+
* view ability) AND the can-view branch (viewer has it, tested via
78+
* superuser which bypasses every ability check).
79+
*/
80+
#[DataProvider('presenterCases')]
81+
public function test_presenter_methods_escape_user_input(string $modelClass, array $attributes, array $methods): void
82+
{
83+
$item = $modelClass::factory()->create($attributes);
84+
85+
// Fallback branch: authenticate as a permissionless user.
86+
$this->actingAs(User::factory()->create());
87+
$this->assertMethodsEscape($item, $methods, 'fallback branch (viewer lacks resource view ability)');
88+
89+
// Can-view branch: authenticate as a superuser to bypass every gate.
90+
$this->actingAs(User::factory()->superuser()->create());
91+
$this->assertMethodsEscape($item, $methods, 'can-view branch');
92+
}
93+
94+
private function assertMethodsEscape(object $item, array $methods, string $branchLabel): void
95+
{
96+
foreach ($methods as $method) {
97+
$output = $item->present()->{$method}();
98+
99+
$this->assertIsString($output, "{$branchLabel}: {$method} should return a string");
100+
$this->assertStringContainsString(
101+
self::EXPECTED_ESCAPED,
102+
$output,
103+
"{$branchLabel}: {$method} did not emit the HTML-escaped payload"
104+
);
105+
$this->assertStringNotContainsString(
106+
self::PAYLOAD,
107+
$output,
108+
"{$branchLabel}: {$method} emitted the raw payload unescaped"
109+
);
110+
}
111+
}
112+
}

0 commit comments

Comments
 (0)