Files
HickLab3D_Dashboard/tests/Feature/ProjectSecurityTest.php
T
hermes d4928789c4
Deploy HickLab3D Dashboard / Deploy to production (push) Failing after 23s
Add HL3D branding, Etsy Live/Draft paging and Projects lifecycle
Replace the old touchmark/PNG identity with the supplied lockup and
monogram, segmented role-aware navigation, Live/Draft listing filters
with generation-based cache invalidation, and admin Projects covering
immutable file revisions, listing live pointers, and exact-decimal
filament/packaging/shipping costing. Additive schema only; no Etsy
publication. Isolated clone tests: 301 tests, 1162 assertions.
2026-11-08 12:50:56 +00:00

137 lines
7.1 KiB
PHP

<?php
namespace Tests\Feature;
use App\Models\Project;
use App\Models\ProjectFile;
use App\Models\ProjectListingLink;
use App\Models\ProjectRelease;
use App\Models\ProjectRevision;
use App\Models\User;
use Illuminate\Foundation\Testing\LazilyRefreshDatabase;
use Illuminate\Http\UploadedFile;
use Illuminate\Support\Facades\Storage;
use Tests\TestCase;
class ProjectSecurityTest extends TestCase
{
use LazilyRefreshDatabase;
public function test_disabled_admin_is_logged_out_before_reaching_project_routes(): void
{
$admin = User::factory()->create(['role' => 'admin', 'disabled' => true]);
$this->actingAs($admin)->get(route('projects.index'))
->assertRedirect(route('login'));
$this->assertGuest();
}
public function test_project_notes_with_script_tags_are_rendered_escaped_not_executed(): void
{
$admin = User::factory()->create(['role' => 'admin']);
$project = Project::factory()->create(['notes' => '<script>alert(1)</script>']);
$response = $this->actingAs($admin)->get(route('projects.show', $project));
$response->assertOk()
->assertDontSee('<script>alert(1)</script>', false)
->assertSee('&lt;script&gt;alert(1)&lt;/script&gt;', false);
}
public function test_revision_change_notes_with_html_are_escaped_in_the_project_view(): void
{
$admin = User::factory()->create(['role' => 'admin']);
$project = Project::factory()->create();
ProjectRevision::factory()->for($project)->create(['change_notes' => '<img src=x onerror=alert(1)>']);
$this->actingAs($admin)->get(route('projects.show', $project))->assertOk()
->assertDontSee('<img src=x onerror=alert(1)>', false);
}
public function test_every_mutating_project_endpoint_rejects_guests(): void
{
$project = Project::factory()->create();
$revision = ProjectRevision::factory()->for($project)->create();
$file = ProjectFile::factory()->for($revision, 'revision')->create();
$listingLink = ProjectListingLink::factory()->for($project)->create();
$release = ProjectRelease::factory()->for($project)->create();
$this->post(route('projects.store'), [])->assertRedirect(route('login'));
$this->patch(route('projects.update', $project), [])->assertRedirect(route('login'));
$this->post(route('projects.archive', $project))->assertRedirect(route('login'));
$this->post(route('projects.revisions.store', $project), [])->assertRedirect(route('login'));
$this->post(route('projects.revisions.approve', [$project, $revision]))->assertRedirect(route('login'));
$this->get(route('projects.files.download', [$project, $file]))->assertRedirect(route('login'));
$this->post(route('projects.listings.store', $project), [])->assertRedirect(route('login'));
$this->delete(route('projects.listings.destroy', [$project, $listingLink]))->assertRedirect(route('login'));
$this->post(route('projects.cost-snapshots.store', $project), [])->assertRedirect(route('login'));
$this->post(route('projects.listings.release', [$project, $listingLink]), [])->assertRedirect(route('login'));
$this->post(route('projects.listings.revert', [$project, $listingLink]), ['release_id' => $release->id])->assertRedirect(route('login'));
}
public function test_every_mutating_project_endpoint_rejects_non_admin_users(): void
{
$user = User::factory()->create();
$project = Project::factory()->create();
$revision = ProjectRevision::factory()->for($project)->create();
$file = ProjectFile::factory()->for($revision, 'revision')->create();
$listingLink = ProjectListingLink::factory()->for($project)->create();
$release = ProjectRelease::factory()->for($project)->create();
$this->actingAs($user)->post(route('projects.store'), [])->assertForbidden();
$this->actingAs($user)->patch(route('projects.update', $project), [])->assertForbidden();
$this->actingAs($user)->post(route('projects.archive', $project))->assertForbidden();
$this->actingAs($user)->post(route('projects.revisions.store', $project), [])->assertForbidden();
$this->actingAs($user)->post(route('projects.revisions.approve', [$project, $revision]))->assertForbidden();
$this->actingAs($user)->get(route('projects.files.download', [$project, $file]))->assertForbidden();
$this->actingAs($user)->post(route('projects.listings.store', $project), [])->assertForbidden();
$this->actingAs($user)->delete(route('projects.listings.destroy', [$project, $listingLink]))->assertForbidden();
$this->actingAs($user)->post(route('projects.cost-snapshots.store', $project), [])->assertForbidden();
$this->actingAs($user)->post(route('projects.listings.release', [$project, $listingLink]), [])->assertForbidden();
$this->actingAs($user)->post(route('projects.listings.revert', [$project, $listingLink]), ['release_id' => $release->id])->assertForbidden();
}
public function test_disabled_admin_cannot_use_any_mutating_project_endpoint(): void
{
$admin = User::factory()->create(['role' => 'admin', 'disabled' => true]);
$project = Project::factory()->create();
$listingLink = ProjectListingLink::factory()->for($project)->create();
$this->actingAs($admin)->post(route('projects.store'), [])->assertRedirect(route('login'));
$this->actingAs($admin)->post(route('projects.listings.release', [$project, $listingLink]), [])->assertRedirect(route('login'));
}
public function test_cross_project_revision_id_is_rejected_rather_than_silently_creating_a_new_revision(): void
{
$admin = User::factory()->create(['role' => 'admin']);
$projectA = Project::factory()->create();
$projectB = Project::factory()->create();
$foreignRevision = ProjectRevision::factory()->for($projectB)->create(['revision_number' => 1]);
Storage::fake('project_files');
$stl = UploadedFile::fake()->createWithContent(
'part.stl',
"solid cube\nfacet normal 0 0 0\nouter loop\nvertex 0 0 0\nvertex 1 0 0\nvertex 0 1 0\nendloop\nendfacet\nendsolid cube\n",
);
$this->actingAs($admin)->post(route('projects.revisions.store', $projectA), [
'files' => [$stl], 'roles' => ['body'], 'revision_id' => $foreignRevision->id,
])->assertRedirect();
// A revision_id that doesn't belong to this project must never be written to;
// it must fall through to creating a fresh revision scoped to this project.
$this->assertDatabaseHas('project_revisions', ['project_id' => $projectA->id, 'revision_number' => 1]);
$this->assertSame(0, $foreignRevision->fresh()->files()->count());
}
public function test_missing_csrf_token_is_rejected_when_verification_actually_runs(): void
{
$this->app['env'] = 'local';
$admin = User::factory()->create(['role' => 'admin']);
$project = Project::factory()->create();
$response = $this->actingAs($admin)->post(route('projects.archive', $project));
$response->assertStatus(419);
}
}