Add HL3D branding, Etsy Live/Draft paging and Projects lifecycle
Deploy HickLab3D Dashboard / Deploy to production (push) Failing after 23s
Deploy HickLab3D Dashboard / Deploy to production (push) Failing after 23s
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.
This commit is contained in:
@@ -0,0 +1,136 @@
|
||||
<?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('<script>alert(1)</script>', 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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user