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,102 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Unit;
|
||||
|
||||
use App\Support\FixedPoint;
|
||||
use InvalidArgumentException;
|
||||
use OverflowException;
|
||||
use Tests\TestCase;
|
||||
|
||||
class FixedPointTest extends TestCase
|
||||
{
|
||||
public function test_parses_decimal_string_to_scaled_integer(): void
|
||||
{
|
||||
$this->assertSame(1500, FixedPoint::toScaled('1.5', 3));
|
||||
$this->assertSame(-1500, FixedPoint::toScaled('-1.5', 3));
|
||||
$this->assertSame(1000000, FixedPoint::toScaled('1', 6));
|
||||
$this->assertSame(0, FixedPoint::toScaled('0', 4));
|
||||
}
|
||||
|
||||
public function test_rejects_malformed_decimal_strings(): void
|
||||
{
|
||||
$this->expectException(InvalidArgumentException::class);
|
||||
FixedPoint::toScaled('1.5.3', 3);
|
||||
}
|
||||
|
||||
public function test_rejects_non_numeric_strings(): void
|
||||
{
|
||||
$this->expectException(InvalidArgumentException::class);
|
||||
FixedPoint::toScaled('abc', 3);
|
||||
}
|
||||
|
||||
public function test_rejects_values_exceeding_bound(): void
|
||||
{
|
||||
$this->expectException(InvalidArgumentException::class);
|
||||
FixedPoint::toScaled('100000000', 6, maxAbs: 1_000_000);
|
||||
}
|
||||
|
||||
public function test_rejects_negative_values_when_disallowed(): void
|
||||
{
|
||||
$this->expectException(InvalidArgumentException::class);
|
||||
FixedPoint::toScaled('-5', 3, allowNegative: false);
|
||||
}
|
||||
|
||||
public function test_multiplies_two_scaled_values_and_rescales_with_round_half_up(): void
|
||||
{
|
||||
// 2.5 grams (scale 3) * 4.0 rate per kg (scale 6) => product scale 9, then /1000 for g->kg => rescale to scale 4.
|
||||
$grams = FixedPoint::toScaled('2.500', 3);
|
||||
$rate = FixedPoint::toScaled('4.000000', 6);
|
||||
$productScale9 = FixedPoint::multiply($grams, $rate);
|
||||
// raw cost = 2.5g * (4.0/1000 per g) = 0.01 -> scale 4 => 100
|
||||
$result = FixedPoint::rescale($productScale9, 9 + 3, 4);
|
||||
$this->assertSame(100, $result);
|
||||
$this->assertSame('0.0100', FixedPoint::format($result, 4));
|
||||
}
|
||||
|
||||
public function test_rescale_rounds_half_up(): void
|
||||
{
|
||||
// 0.00005 at scale 5 rescaled to scale 4 should round up to 0.0001 (not banker's rounding).
|
||||
$this->assertSame(1, FixedPoint::rescale(5, 5, 4));
|
||||
$this->assertSame(0, FixedPoint::rescale(4, 5, 4));
|
||||
$this->assertSame(-1, FixedPoint::rescale(-5, 5, 4));
|
||||
}
|
||||
|
||||
public function test_add_and_subtract_scaled_values(): void
|
||||
{
|
||||
$a = FixedPoint::toScaled('10.1234', 4);
|
||||
$b = FixedPoint::toScaled('0.0001', 4);
|
||||
$this->assertSame(101235, FixedPoint::add($a, $b));
|
||||
$this->assertSame(101233, FixedPoint::subtract($a, $b));
|
||||
}
|
||||
|
||||
public function test_add_detects_overflow(): void
|
||||
{
|
||||
$this->expectException(OverflowException::class);
|
||||
FixedPoint::add(PHP_INT_MAX, 1);
|
||||
}
|
||||
|
||||
public function test_multiply_detects_overflow(): void
|
||||
{
|
||||
$this->expectException(OverflowException::class);
|
||||
FixedPoint::multiply(PHP_INT_MAX, 2);
|
||||
}
|
||||
|
||||
public function test_divide_scaled_value_by_positive_integer_with_round_half_up(): void
|
||||
{
|
||||
$amount = FixedPoint::toScaled('10.0000', 4);
|
||||
$this->assertSame(FixedPoint::toScaled('3.3333', 4), FixedPoint::divideByInt($amount, 3));
|
||||
}
|
||||
|
||||
public function test_divide_by_zero_is_rejected(): void
|
||||
{
|
||||
$this->expectException(InvalidArgumentException::class);
|
||||
FixedPoint::divideByInt(FixedPoint::toScaled('1', 4), 0);
|
||||
}
|
||||
|
||||
public function test_format_renders_fixed_decimal_places_without_floats(): void
|
||||
{
|
||||
$this->assertSame('1234.5000', FixedPoint::format(FixedPoint::toScaled('1234.5', 4), 4));
|
||||
$this->assertSame('-0.0100', FixedPoint::format(FixedPoint::toScaled('-0.01', 4), 4));
|
||||
$this->assertSame('0.0000', FixedPoint::format(0, 4));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,592 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Unit;
|
||||
|
||||
use App\Services\ManufacturingFileValidator;
|
||||
use Illuminate\Http\UploadedFile;
|
||||
use RuntimeException;
|
||||
use Tests\TestCase;
|
||||
use ZipArchive;
|
||||
|
||||
class ManufacturingFileValidatorTest extends TestCase
|
||||
{
|
||||
private ManufacturingFileValidator $validator;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
parent::setUp();
|
||||
$this->validator = new ManufacturingFileValidator;
|
||||
}
|
||||
|
||||
private function uploadedFile(string $path, string $clientName): UploadedFile
|
||||
{
|
||||
return new UploadedFile($path, $clientName, null, null, true);
|
||||
}
|
||||
|
||||
public function test_accepts_valid_ascii_stl(): void
|
||||
{
|
||||
$path = tempnam(sys_get_temp_dir(), 'stl');
|
||||
file_put_contents($path, "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");
|
||||
|
||||
$result = $this->validator->validate($this->uploadedFile($path, 'cube.stl'));
|
||||
|
||||
$this->assertSame('stl', $result['extension']);
|
||||
unlink($path);
|
||||
}
|
||||
|
||||
public function test_accepts_valid_binary_stl(): void
|
||||
{
|
||||
$path = $this->makeBinaryStl(triangles: 2);
|
||||
|
||||
$result = $this->validator->validate($this->uploadedFile($path, 'part.stl'));
|
||||
|
||||
$this->assertSame('stl', $result['extension']);
|
||||
unlink($path);
|
||||
}
|
||||
|
||||
public function test_rejects_binary_stl_with_triangle_count_mismatch(): void
|
||||
{
|
||||
$path = $this->makeBinaryStl(triangles: 2, declareExtraTriangle: true);
|
||||
|
||||
$this->expectException(RuntimeException::class);
|
||||
try {
|
||||
$this->validator->validate($this->uploadedFile($path, 'part.stl'));
|
||||
} finally {
|
||||
unlink($path);
|
||||
}
|
||||
}
|
||||
|
||||
public function test_rejects_ascii_stl_with_zero_facets(): void
|
||||
{
|
||||
// The exact review example: a single line with "solid" and "endsolid"
|
||||
// tokens but no actual facet/vertex data must not be accepted.
|
||||
$path = tempnam(sys_get_temp_dir(), 'stl');
|
||||
file_put_contents($path, "solid garbage endsolid\n");
|
||||
|
||||
$this->expectException(RuntimeException::class);
|
||||
try {
|
||||
$this->validator->validate($this->uploadedFile($path, 'garbage.stl'));
|
||||
} finally {
|
||||
unlink($path);
|
||||
}
|
||||
}
|
||||
|
||||
public function test_accepts_ascii_stl_with_multiple_facets(): void
|
||||
{
|
||||
$facet = "facet normal 0 0 1\nouter loop\nvertex 0 0 0\nvertex 1 0 0\nvertex 0 1 0\nendloop\nendfacet\n";
|
||||
$path = tempnam(sys_get_temp_dir(), 'stl');
|
||||
file_put_contents($path, "solid cube\n".$facet.$facet."endsolid cube\n");
|
||||
|
||||
$result = $this->validator->validate($this->uploadedFile($path, 'cube.stl'));
|
||||
|
||||
$this->assertSame('stl', $result['extension']);
|
||||
unlink($path);
|
||||
}
|
||||
|
||||
public function test_rejects_ascii_stl_missing_outer_loop(): void
|
||||
{
|
||||
$path = tempnam(sys_get_temp_dir(), 'stl');
|
||||
file_put_contents($path, "solid cube\nfacet normal 0 0 1\nvertex 0 0 0\nvertex 1 0 0\nvertex 0 1 0\nendloop\nendfacet\nendsolid cube\n");
|
||||
|
||||
$this->expectException(RuntimeException::class);
|
||||
try {
|
||||
$this->validator->validate($this->uploadedFile($path, 'cube.stl'));
|
||||
} finally {
|
||||
unlink($path);
|
||||
}
|
||||
}
|
||||
|
||||
public function test_rejects_ascii_stl_with_non_finite_vertex_value(): void
|
||||
{
|
||||
$path = tempnam(sys_get_temp_dir(), 'stl');
|
||||
file_put_contents($path, "solid cube\nfacet normal 0 0 1\nouter loop\nvertex nan 0 0\nvertex 1 0 0\nvertex 0 1 0\nendloop\nendfacet\nendsolid cube\n");
|
||||
|
||||
$this->expectException(RuntimeException::class);
|
||||
try {
|
||||
$this->validator->validate($this->uploadedFile($path, 'cube.stl'));
|
||||
} finally {
|
||||
unlink($path);
|
||||
}
|
||||
}
|
||||
|
||||
public function test_rejects_ascii_stl_missing_a_vertex(): void
|
||||
{
|
||||
$path = tempnam(sys_get_temp_dir(), 'stl');
|
||||
file_put_contents($path, "solid cube\nfacet normal 0 0 1\nouter loop\nvertex 0 0 0\nvertex 1 0 0\nendloop\nendfacet\nendsolid cube\n");
|
||||
|
||||
$this->expectException(RuntimeException::class);
|
||||
try {
|
||||
$this->validator->validate($this->uploadedFile($path, 'cube.stl'));
|
||||
} finally {
|
||||
unlink($path);
|
||||
}
|
||||
}
|
||||
|
||||
public function test_rejects_binary_stl_with_zero_triangles(): void
|
||||
{
|
||||
$path = tempnam(sys_get_temp_dir(), 'stl');
|
||||
file_put_contents($path, str_repeat('A', 80).pack('V', 0));
|
||||
|
||||
$this->expectException(RuntimeException::class);
|
||||
try {
|
||||
$this->validator->validate($this->uploadedFile($path, 'empty.stl'));
|
||||
} finally {
|
||||
unlink($path);
|
||||
}
|
||||
}
|
||||
|
||||
public function test_rejects_binary_stl_with_non_finite_values(): void
|
||||
{
|
||||
$path = tempnam(sys_get_temp_dir(), 'stl');
|
||||
$handle = fopen($path, 'wb');
|
||||
fwrite($handle, str_repeat('A', 80));
|
||||
fwrite($handle, pack('V', 1));
|
||||
fwrite($handle, str_repeat(pack('f', NAN), 12));
|
||||
fwrite($handle, pack('v', 0));
|
||||
fclose($handle);
|
||||
|
||||
$this->expectException(RuntimeException::class);
|
||||
try {
|
||||
$this->validator->validate($this->uploadedFile($path, 'nan.stl'));
|
||||
} finally {
|
||||
unlink($path);
|
||||
}
|
||||
}
|
||||
|
||||
public function test_rejects_binary_stl_with_absurd_triangle_count(): void
|
||||
{
|
||||
$path = tempnam(sys_get_temp_dir(), 'stl');
|
||||
$header = str_repeat('A', 80);
|
||||
file_put_contents($path, $header.pack('V', 100_000_000));
|
||||
|
||||
$this->expectException(RuntimeException::class);
|
||||
try {
|
||||
$this->validator->validate($this->uploadedFile($path, 'huge.stl'));
|
||||
} finally {
|
||||
unlink($path);
|
||||
}
|
||||
}
|
||||
|
||||
public function test_rejects_file_with_mismatched_extension_and_content(): void
|
||||
{
|
||||
$path = tempnam(sys_get_temp_dir(), 'fake');
|
||||
file_put_contents($path, "G1 X10 Y10\nG1 X20 Y20\n");
|
||||
|
||||
$this->expectException(RuntimeException::class);
|
||||
try {
|
||||
$this->validator->validate($this->uploadedFile($path, 'fake.stl'));
|
||||
} finally {
|
||||
unlink($path);
|
||||
}
|
||||
}
|
||||
|
||||
public function test_rejects_unsupported_extension(): void
|
||||
{
|
||||
$path = tempnam(sys_get_temp_dir(), 'exe');
|
||||
file_put_contents($path, 'MZ'.str_repeat("\x00", 100));
|
||||
|
||||
$this->expectException(RuntimeException::class);
|
||||
try {
|
||||
$this->validator->validate($this->uploadedFile($path, 'payload.exe'));
|
||||
} finally {
|
||||
unlink($path);
|
||||
}
|
||||
}
|
||||
|
||||
public function test_accepts_plain_text_gcode(): void
|
||||
{
|
||||
$path = tempnam(sys_get_temp_dir(), 'gcode');
|
||||
file_put_contents($path, "; generated by slicer\nG28\nG1 X0 Y0 Z0.2 F3000\nM104 S200\n");
|
||||
|
||||
$result = $this->validator->validate($this->uploadedFile($path, 'part.gcode'));
|
||||
|
||||
$this->assertSame('gcode', $result['extension']);
|
||||
unlink($path);
|
||||
}
|
||||
|
||||
public function test_rejects_gcode_containing_binary_bytes(): void
|
||||
{
|
||||
$path = tempnam(sys_get_temp_dir(), 'gcode');
|
||||
file_put_contents($path, "G1 X0\0\x01\x02 binary disguised as text");
|
||||
|
||||
$this->expectException(RuntimeException::class);
|
||||
try {
|
||||
$this->validator->validate($this->uploadedFile($path, 'part.gcode'));
|
||||
} finally {
|
||||
unlink($path);
|
||||
}
|
||||
}
|
||||
|
||||
public function test_accepts_valid_3mf_archive(): void
|
||||
{
|
||||
$path = $this->make3mf([
|
||||
'[Content_Types].xml' => '<?xml version="1.0"?><Types xmlns="x"/>',
|
||||
'3D/3dmodel.model' => '<?xml version="1.0"?><model xmlns="http://schemas.microsoft.com/3dmanufacturing/core/2015/02"><resources></resources><build></build></model>',
|
||||
'_rels/.rels' => '<?xml version="1.0"?><Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships"><Relationship Target="/3D/3dmodel.model" Id="rel0" Type="y"/></Relationships>',
|
||||
]);
|
||||
|
||||
$result = $this->validator->validate($this->uploadedFile($path, 'part.3mf'));
|
||||
|
||||
$this->assertSame('3mf', $result['extension']);
|
||||
unlink($path);
|
||||
}
|
||||
|
||||
public function test_rejects_3mf_model_missing_required_resources_or_build_elements(): void
|
||||
{
|
||||
$path = $this->make3mf([
|
||||
'[Content_Types].xml' => '<?xml version="1.0"?><Types xmlns="x"/>',
|
||||
'3D/3dmodel.model' => '<?xml version="1.0"?><model xmlns="http://schemas.microsoft.com/3dmanufacturing/core/2015/02"></model>',
|
||||
'_rels/.rels' => '<?xml version="1.0"?><Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships"><Relationship Target="/3D/3dmodel.model" Id="rel0" Type="y"/></Relationships>',
|
||||
]);
|
||||
|
||||
$this->expectException(RuntimeException::class);
|
||||
try {
|
||||
$this->validator->validate($this->uploadedFile($path, 'incomplete.3mf'));
|
||||
} finally {
|
||||
unlink($path);
|
||||
}
|
||||
}
|
||||
|
||||
public function test_rejects_3mf_model_with_malformed_not_well_formed_xml(): void
|
||||
{
|
||||
$path = $this->make3mf([
|
||||
'[Content_Types].xml' => '<?xml version="1.0"?><Types xmlns="x"/>',
|
||||
'3D/3dmodel.model' => '<?xml version="1.0"?><model xmlns="http://schemas.microsoft.com/3dmanufacturing/core/2015/02"><resources></resources><build></model>',
|
||||
'_rels/.rels' => '<?xml version="1.0"?><Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships"><Relationship Target="/3D/3dmodel.model" Id="rel0" Type="y"/></Relationships>',
|
||||
]);
|
||||
|
||||
$this->expectException(RuntimeException::class);
|
||||
try {
|
||||
$this->validator->validate($this->uploadedFile($path, 'malformed.3mf'));
|
||||
} finally {
|
||||
unlink($path);
|
||||
}
|
||||
}
|
||||
|
||||
public function test_rejects_3mf_relationship_with_single_quoted_external_target(): void
|
||||
{
|
||||
// The old validator only regex-matched double-quoted Target="..." attributes,
|
||||
// so a single-quoted external target slipped straight through unparsed.
|
||||
$path = $this->make3mf([
|
||||
'[Content_Types].xml' => '<?xml version="1.0"?><Types xmlns="x"/>',
|
||||
'3D/3dmodel.model' => '<?xml version="1.0"?><model xmlns="http://schemas.microsoft.com/3dmanufacturing/core/2015/02"><resources></resources><build></build></model>',
|
||||
'_rels/.rels' => "<?xml version='1.0'?><Relationships xmlns='http://schemas.openxmlformats.org/package/2006/relationships'><Relationship Target='https://evil.example.test/payload' Id='rel0' Type='y'/></Relationships>",
|
||||
]);
|
||||
|
||||
$this->expectException(RuntimeException::class);
|
||||
try {
|
||||
$this->validator->validate($this->uploadedFile($path, 'evil.3mf'));
|
||||
} finally {
|
||||
unlink($path);
|
||||
}
|
||||
}
|
||||
|
||||
public function test_rejects_3mf_relationship_with_external_target_mode_regardless_of_target_value(): void
|
||||
{
|
||||
$path = $this->make3mf([
|
||||
'[Content_Types].xml' => '<?xml version="1.0"?><Types xmlns="x"/>',
|
||||
'3D/3dmodel.model' => '<?xml version="1.0"?><model xmlns="http://schemas.microsoft.com/3dmanufacturing/core/2015/02"><resources></resources><build></build></model>',
|
||||
'_rels/.rels' => '<?xml version="1.0"?><Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships"><Relationship Target="safe/internal/part.xml" TargetMode="External" Id="rel0" Type="y"/></Relationships>',
|
||||
]);
|
||||
|
||||
$this->expectException(RuntimeException::class);
|
||||
try {
|
||||
$this->validator->validate($this->uploadedFile($path, 'evil.3mf'));
|
||||
} finally {
|
||||
unlink($path);
|
||||
}
|
||||
}
|
||||
|
||||
public function test_rejects_3mf_nested_rels_part_with_external_target(): void
|
||||
{
|
||||
// Per-part relationship files (not just the root _rels/.rels) must be
|
||||
// inspected too; a nested one pointing outside the package must still
|
||||
// be rejected.
|
||||
$path = $this->make3mf([
|
||||
'[Content_Types].xml' => '<?xml version="1.0"?><Types xmlns="x"/>',
|
||||
'3D/3dmodel.model' => '<?xml version="1.0"?><model xmlns="http://schemas.microsoft.com/3dmanufacturing/core/2015/02"><resources></resources><build></build></model>',
|
||||
'_rels/.rels' => '<?xml version="1.0"?><Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships"><Relationship Target="/3D/3dmodel.model" Id="rel0" Type="y"/></Relationships>',
|
||||
'3D/_rels/3dmodel.model.rels' => '<?xml version="1.0"?><Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships"><Relationship Target="http://evil.example.test/texture.png" Id="rel1" Type="y"/></Relationships>',
|
||||
]);
|
||||
|
||||
$this->expectException(RuntimeException::class);
|
||||
try {
|
||||
$this->validator->validate($this->uploadedFile($path, 'evil.3mf'));
|
||||
} finally {
|
||||
unlink($path);
|
||||
}
|
||||
}
|
||||
|
||||
public function test_rejects_3mf_with_an_encrypted_entry(): void
|
||||
{
|
||||
$zip = new ZipArchive;
|
||||
$path = tempnam(sys_get_temp_dir(), 'tmf').'.3mf';
|
||||
$zip->open($path, ZipArchive::CREATE);
|
||||
$zip->addFromString('[Content_Types].xml', '<?xml version="1.0"?><Types xmlns="x"/>');
|
||||
$zip->addFromString('3D/3dmodel.model', '<?xml version="1.0"?><model xmlns="x"><resources></resources><build></build></model>');
|
||||
$zip->addFromString('secret.bin', 'payload');
|
||||
$zip->setEncryptionName('secret.bin', ZipArchive::EM_AES_256, 'password');
|
||||
$zip->close();
|
||||
|
||||
$this->expectException(RuntimeException::class);
|
||||
try {
|
||||
$this->validator->validate($this->uploadedFile($path, 'evil.3mf'));
|
||||
} finally {
|
||||
unlink($path);
|
||||
}
|
||||
}
|
||||
|
||||
public function test_rejects_3mf_entry_exceeding_the_per_entry_xml_size_cap(): void
|
||||
{
|
||||
// Low-compressibility padding keeps this under the ratio-bomb threshold
|
||||
// while still being an individually oversized XML-ish part.
|
||||
$zip = new ZipArchive;
|
||||
$path = tempnam(sys_get_temp_dir(), 'tmf').'.3mf';
|
||||
$zip->open($path, ZipArchive::CREATE);
|
||||
$zip->addFromString('[Content_Types].xml', '<?xml version="1.0"?><Types xmlns="x"/>');
|
||||
$zip->addFromString('3D/3dmodel.model', '<?xml version="1.0"?><model xmlns="x"><resources></resources><build></build></model>');
|
||||
$zip->addFromString('_rels/.rels', '<?xml version="1.0"?><Relationships xmlns="x"><Relationship Target="/3D/3dmodel.model" Id="rel0" Type="y"/></Relationships>');
|
||||
$zip->addFromString('huge.xml', '<x>'.base64_encode(random_bytes(18 * 1024 * 1024)).'</x>');
|
||||
$zip->setCompressionName('huge.xml', ZipArchive::CM_STORE);
|
||||
$zip->close();
|
||||
|
||||
$this->expectException(RuntimeException::class);
|
||||
try {
|
||||
$this->validator->validate($this->uploadedFile($path, 'huge.3mf'));
|
||||
} finally {
|
||||
unlink($path);
|
||||
}
|
||||
}
|
||||
|
||||
public function test_rejects_3mf_with_a_duplicate_entry_path(): void
|
||||
{
|
||||
// ZipArchive's own API dedupes entries by name, so a genuine duplicate
|
||||
// (two local file headers + two central directory records sharing one
|
||||
// name — a known zip-parser confusion vector) has to be hand-built.
|
||||
$path = tempnam(sys_get_temp_dir(), 'tmf').'.3mf';
|
||||
file_put_contents($path, $this->rawZipWithDuplicateEntryName());
|
||||
|
||||
$this->expectException(RuntimeException::class);
|
||||
try {
|
||||
$this->validator->validate($this->uploadedFile($path, 'dup.3mf'));
|
||||
} finally {
|
||||
unlink($path);
|
||||
}
|
||||
}
|
||||
|
||||
private function rawZipWithDuplicateEntryName(): string
|
||||
{
|
||||
// An otherwise entirely valid, acceptable 3MF archive, plus a duplicate
|
||||
// pair of an unrelated entry name — so this only fails if duplicate
|
||||
// detection itself exists, not for any other unrelated reason.
|
||||
$entries = [
|
||||
['[Content_Types].xml', '<?xml version="1.0"?><Types xmlns="x"/>'],
|
||||
['3D/3dmodel.model', '<?xml version="1.0"?><model xmlns="http://schemas.microsoft.com/3dmanufacturing/core/2015/02"><resources></resources><build></build></model>'],
|
||||
['_rels/.rels', '<?xml version="1.0"?><Relationships xmlns="x"><Relationship Target="/3D/3dmodel.model" Id="rel0" Type="y"/></Relationships>'],
|
||||
['same.txt', 'first'],
|
||||
['same.txt', 'second'],
|
||||
];
|
||||
|
||||
$local = '';
|
||||
$central = '';
|
||||
$offset = 0;
|
||||
foreach ($entries as [$name, $content]) {
|
||||
$crc = crc32($content);
|
||||
$len = strlen($content);
|
||||
$nameLen = strlen($name);
|
||||
$lfh = pack('VvvvvvVVVvv', 0x04034B50, 20, 0, 0, 0, 0, $crc, $len, $len, $nameLen, 0).$name.$content;
|
||||
$cdfh = pack('VvvvvvvVVVvvvvvVV', 0x02014B50, 20, 20, 0, 0, 0, 0, $crc, $len, $len, $nameLen, 0, 0, 0, 0, 0, $offset).$name;
|
||||
$local .= $lfh;
|
||||
$central .= $cdfh;
|
||||
$offset += strlen($lfh);
|
||||
}
|
||||
|
||||
$eocd = pack('VvvvvVVv', 0x06054B50, 0, 0, count($entries), count($entries), strlen($central), $offset, 0);
|
||||
|
||||
return $local.$central.$eocd;
|
||||
}
|
||||
|
||||
public function test_rejects_3mf_missing_required_model_part(): void
|
||||
{
|
||||
$path = $this->make3mf([
|
||||
'[Content_Types].xml' => '<?xml version="1.0"?><Types xmlns="x"/>',
|
||||
]);
|
||||
|
||||
$this->expectException(RuntimeException::class);
|
||||
try {
|
||||
$this->validator->validate($this->uploadedFile($path, 'broken.3mf'));
|
||||
} finally {
|
||||
unlink($path);
|
||||
}
|
||||
}
|
||||
|
||||
public function test_rejects_3mf_with_path_traversal_entry(): void
|
||||
{
|
||||
$path = $this->make3mf([
|
||||
'[Content_Types].xml' => '<?xml version="1.0"?><Types xmlns="x"/>',
|
||||
'3D/3dmodel.model' => '<?xml version="1.0"?><model xmlns="x"></model>',
|
||||
'../../../etc/evil.txt' => 'pwned',
|
||||
]);
|
||||
|
||||
$this->expectException(RuntimeException::class);
|
||||
try {
|
||||
$this->validator->validate($this->uploadedFile($path, 'evil.3mf'));
|
||||
} finally {
|
||||
unlink($path);
|
||||
}
|
||||
}
|
||||
|
||||
public function test_rejects_3mf_with_absolute_path_entry(): void
|
||||
{
|
||||
$zip = new ZipArchive;
|
||||
$path = tempnam(sys_get_temp_dir(), 'tmf').'.3mf';
|
||||
$zip->open($path, ZipArchive::CREATE);
|
||||
$zip->addFromString('[Content_Types].xml', '<?xml version="1.0"?><Types xmlns="x"/>');
|
||||
$zip->addFromString('3D/3dmodel.model', '<?xml version="1.0"?><model xmlns="x"></model>');
|
||||
// ZipArchive normalises leading slashes away, so write the entry via a raw local file header hack:
|
||||
// fall back to asserting the validator rejects entries containing backslashes / drive letters instead.
|
||||
$zip->addFromString('C:\\Windows\\evil.dll', 'pwned');
|
||||
$zip->close();
|
||||
|
||||
$this->expectException(RuntimeException::class);
|
||||
try {
|
||||
$this->validator->validate($this->uploadedFile($path, 'evil.3mf'));
|
||||
} finally {
|
||||
unlink($path);
|
||||
}
|
||||
}
|
||||
|
||||
public function test_rejects_3mf_model_part_with_doctype_declaration(): void
|
||||
{
|
||||
$path = $this->make3mf([
|
||||
'[Content_Types].xml' => '<?xml version="1.0"?><Types xmlns="x"/>',
|
||||
'3D/3dmodel.model' => '<?xml version="1.0"?><!DOCTYPE model [<!ENTITY xxe SYSTEM "file:///etc/passwd">]><model xmlns="x">&xxe;</model>',
|
||||
]);
|
||||
|
||||
$this->expectException(RuntimeException::class);
|
||||
try {
|
||||
$this->validator->validate($this->uploadedFile($path, 'xxe.3mf'));
|
||||
} finally {
|
||||
unlink($path);
|
||||
}
|
||||
}
|
||||
|
||||
public function test_rejects_3mf_relationship_with_external_target(): void
|
||||
{
|
||||
$path = $this->make3mf([
|
||||
'[Content_Types].xml' => '<?xml version="1.0"?><Types xmlns="x"/>',
|
||||
'3D/3dmodel.model' => '<?xml version="1.0"?><model xmlns="x"></model>',
|
||||
'_rels/.rels' => '<?xml version="1.0"?><Relationships xmlns="x"><Relationship Target="https://evil.example.test/payload" Id="rel0" Type="y"/></Relationships>',
|
||||
]);
|
||||
|
||||
$this->expectException(RuntimeException::class);
|
||||
try {
|
||||
$this->validator->validate($this->uploadedFile($path, 'evil.3mf'));
|
||||
} finally {
|
||||
unlink($path);
|
||||
}
|
||||
}
|
||||
|
||||
public function test_rejects_3mf_with_symlink_entry(): void
|
||||
{
|
||||
$zip = new ZipArchive;
|
||||
$path = tempnam(sys_get_temp_dir(), 'tmf').'.3mf';
|
||||
$zip->open($path, ZipArchive::CREATE);
|
||||
$zip->addFromString('[Content_Types].xml', '<?xml version="1.0"?><Types xmlns="x"/>');
|
||||
$zip->addFromString('3D/3dmodel.model', '<?xml version="1.0"?><model xmlns="x"></model>');
|
||||
$zip->addFromString('evil-link', '/etc/passwd');
|
||||
$zip->setExternalAttributesName('evil-link', ZipArchive::OPSYS_UNIX, 0120777 << 16);
|
||||
$zip->close();
|
||||
|
||||
$this->expectException(RuntimeException::class);
|
||||
try {
|
||||
$this->validator->validate($this->uploadedFile($path, 'evil.3mf'));
|
||||
} finally {
|
||||
unlink($path);
|
||||
}
|
||||
}
|
||||
|
||||
public function test_rejects_3mf_with_too_many_entries(): void
|
||||
{
|
||||
$entries = ['[Content_Types].xml' => '<?xml version="1.0"?><Types xmlns="x"/>', '3D/3dmodel.model' => '<?xml version="1.0"?><model xmlns="x"></model>'];
|
||||
for ($i = 0; $i < 5000; $i++) {
|
||||
$entries["junk/{$i}.txt"] = 'x';
|
||||
}
|
||||
$path = $this->make3mf($entries);
|
||||
|
||||
$this->expectException(RuntimeException::class);
|
||||
try {
|
||||
$this->validator->validate($this->uploadedFile($path, 'bomb.3mf'));
|
||||
} finally {
|
||||
unlink($path);
|
||||
}
|
||||
}
|
||||
|
||||
public function test_rejects_3mf_entry_with_extreme_compression_ratio(): void
|
||||
{
|
||||
$zip = new ZipArchive;
|
||||
$path = tempnam(sys_get_temp_dir(), 'tmf').'.3mf';
|
||||
$zip->open($path, ZipArchive::CREATE);
|
||||
$zip->addFromString('[Content_Types].xml', '<?xml version="1.0"?><Types xmlns="x"/>');
|
||||
$zip->addFromString('3D/3dmodel.model', '<?xml version="1.0"?><model xmlns="x"></model>');
|
||||
$zip->addFromString('bomb.bin', str_repeat("\0", 20 * 1024 * 1024));
|
||||
$zip->setCompressionName('bomb.bin', ZipArchive::CM_DEFLATE, 9);
|
||||
$zip->close();
|
||||
|
||||
$this->expectException(RuntimeException::class);
|
||||
try {
|
||||
$this->validator->validate($this->uploadedFile($path, 'bomb.3mf'));
|
||||
} finally {
|
||||
unlink($path);
|
||||
}
|
||||
}
|
||||
|
||||
public function test_rejects_file_exceeding_maximum_size(): void
|
||||
{
|
||||
$path = tempnam(sys_get_temp_dir(), 'stl');
|
||||
$handle = fopen($path, 'wb');
|
||||
fwrite($handle, str_repeat('A', 80));
|
||||
fwrite($handle, pack('V', 1));
|
||||
fseek($handle, (100 * 1024 * 1024) + 200);
|
||||
fwrite($handle, 'x');
|
||||
fclose($handle);
|
||||
|
||||
$this->expectException(RuntimeException::class);
|
||||
try {
|
||||
$this->validator->validate($this->uploadedFile($path, 'huge.stl'));
|
||||
} finally {
|
||||
unlink($path);
|
||||
}
|
||||
}
|
||||
|
||||
private function makeBinaryStl(int $triangles, bool $declareExtraTriangle = false): string
|
||||
{
|
||||
$path = tempnam(sys_get_temp_dir(), 'stl');
|
||||
$handle = fopen($path, 'wb');
|
||||
fwrite($handle, str_repeat('A', 80));
|
||||
fwrite($handle, pack('V', $declareExtraTriangle ? $triangles + 1 : $triangles));
|
||||
for ($i = 0; $i < $triangles; $i++) {
|
||||
fwrite($handle, str_repeat(pack('f', 0.0), 12));
|
||||
fwrite($handle, pack('v', 0));
|
||||
}
|
||||
fclose($handle);
|
||||
|
||||
return $path;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, string> $entries
|
||||
*/
|
||||
private function make3mf(array $entries): string
|
||||
{
|
||||
$zip = new ZipArchive;
|
||||
$path = tempnam(sys_get_temp_dir(), 'tmf').'.3mf';
|
||||
$zip->open($path, ZipArchive::CREATE);
|
||||
foreach ($entries as $name => $contents) {
|
||||
$zip->addFromString($name, $contents);
|
||||
}
|
||||
$zip->close();
|
||||
|
||||
return $path;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,184 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Unit;
|
||||
|
||||
use App\Services\ProjectCosting;
|
||||
use InvalidArgumentException;
|
||||
use Tests\TestCase;
|
||||
|
||||
class ProjectCostingTest extends TestCase
|
||||
{
|
||||
public function test_filament_line_converts_grams_to_kilograms_before_pricing(): void
|
||||
{
|
||||
// 250g at £20.00/kg = £5.00
|
||||
$line = ProjectCosting::lineTotal('filament', '250', 'g', '20.000000');
|
||||
$this->assertSame('5.0000', $line);
|
||||
}
|
||||
|
||||
public function test_filament_line_includes_explicit_waste_without_double_counting(): void
|
||||
{
|
||||
// 250g consumed + 10g explicit purge/support waste = 260g at £20.00/kg
|
||||
$line = ProjectCosting::lineTotal('filament', '250', 'g', '20.000000', waste: '10');
|
||||
$this->assertSame('5.2000', $line);
|
||||
}
|
||||
|
||||
public function test_packaging_line_is_quantity_times_unit_rate(): void
|
||||
{
|
||||
$line = ProjectCosting::lineTotal('packaging', '3', 'unit', '0.750000');
|
||||
$this->assertSame('2.2500', $line);
|
||||
}
|
||||
|
||||
public function test_shipping_line_total_is_quantity_times_parcel_rate(): void
|
||||
{
|
||||
$line = ProjectCosting::lineTotal('shipping', '1', 'parcel', '4.990000');
|
||||
$this->assertSame('4.9900', $line);
|
||||
}
|
||||
|
||||
public function test_shipping_unit_contribution_divides_rate_by_units_per_parcel_not_batch_yield(): void
|
||||
{
|
||||
// One £4.99 parcel covering 2 saleable units ships at £2.495 per unit,
|
||||
// independent of however many units the manufacturing batch itself yielded.
|
||||
$this->assertSame('2.4950', ProjectCosting::shippingUnitContribution('4.990000', 2));
|
||||
}
|
||||
|
||||
public function test_shipping_unit_contribution_rejects_zero_or_negative_units_per_parcel(): void
|
||||
{
|
||||
$this->expectException(InvalidArgumentException::class);
|
||||
ProjectCosting::shippingUnitContribution('4.990000', 0);
|
||||
}
|
||||
|
||||
public function test_filament_rejects_a_quantity_unit_not_in_the_category_allowlist(): void
|
||||
{
|
||||
$this->expectException(InvalidArgumentException::class);
|
||||
ProjectCosting::lineTotal('filament', '250', 'G', '20.000000');
|
||||
}
|
||||
|
||||
public function test_filament_rejects_kilograms_as_the_quantity_unit(): void
|
||||
{
|
||||
$this->expectException(InvalidArgumentException::class);
|
||||
ProjectCosting::lineTotal('filament', '0.25', 'kg', '20.000000');
|
||||
}
|
||||
|
||||
public function test_packaging_rejects_a_quantity_unit_not_in_the_category_allowlist(): void
|
||||
{
|
||||
$this->expectException(InvalidArgumentException::class);
|
||||
ProjectCosting::lineTotal('packaging', '1', 'units', '0.650000');
|
||||
}
|
||||
|
||||
public function test_shipping_rejects_a_quantity_unit_not_in_the_category_allowlist(): void
|
||||
{
|
||||
$this->expectException(InvalidArgumentException::class);
|
||||
ProjectCosting::lineTotal('shipping', '1', 'box', '4.990000');
|
||||
}
|
||||
|
||||
public function test_aggregate_rejects_a_category_total_exceeding_the_storable_decimal_precision(): void
|
||||
{
|
||||
// decimal(14,4) tops out around 9,999,999,999.9999; two large-but-individually-valid
|
||||
// lines can still sum past that bound, so the aggregate step must re-check it.
|
||||
$this->expectException(InvalidArgumentException::class);
|
||||
ProjectCosting::aggregate([
|
||||
['category' => 'filament', 'line_total' => '9999999999.0000'],
|
||||
['category' => 'filament', 'line_total' => '9999999999.0000'],
|
||||
]);
|
||||
}
|
||||
|
||||
public function test_aggregate_rejects_a_grand_total_exceeding_the_storable_decimal_precision(): void
|
||||
{
|
||||
$this->expectException(InvalidArgumentException::class);
|
||||
ProjectCosting::aggregate([
|
||||
['category' => 'filament', 'line_total' => '9999999999.0000'],
|
||||
['category' => 'packaging', 'line_total' => '9999999999.0000'],
|
||||
]);
|
||||
}
|
||||
|
||||
public function test_zero_quantity_is_a_valid_intentional_zero(): void
|
||||
{
|
||||
$this->assertSame('0.0000', ProjectCosting::lineTotal('packaging', '0', 'unit', '5.000000'));
|
||||
}
|
||||
|
||||
public function test_negative_quantity_is_rejected(): void
|
||||
{
|
||||
$this->expectException(InvalidArgumentException::class);
|
||||
ProjectCosting::lineTotal('packaging', '-1', 'unit', '5.000000');
|
||||
}
|
||||
|
||||
public function test_negative_rate_is_rejected(): void
|
||||
{
|
||||
$this->expectException(InvalidArgumentException::class);
|
||||
ProjectCosting::lineTotal('packaging', '1', 'unit', '-5.000000');
|
||||
}
|
||||
|
||||
public function test_absurd_quantity_overflows_and_is_rejected(): void
|
||||
{
|
||||
$this->expectException(\Throwable::class);
|
||||
ProjectCosting::lineTotal('filament', '999999999999', 'g', '999999999999.000000');
|
||||
}
|
||||
|
||||
public function test_aggregate_distinguishes_missing_category_from_intentional_zero(): void
|
||||
{
|
||||
$totals = ProjectCosting::aggregate([
|
||||
['category' => 'filament', 'line_total' => '5.0000'],
|
||||
['category' => 'packaging', 'line_total' => '0.0000'],
|
||||
]);
|
||||
|
||||
$this->assertSame('5.0000', $totals['filament_total']);
|
||||
$this->assertSame('0.0000', $totals['packaging_total']);
|
||||
$this->assertNull($totals['shipping_total']);
|
||||
$this->assertSame('5.0000', $totals['grand_total']);
|
||||
}
|
||||
|
||||
public function test_aggregate_grand_total_is_null_when_nothing_costed(): void
|
||||
{
|
||||
$totals = ProjectCosting::aggregate([]);
|
||||
|
||||
$this->assertNull($totals['filament_total']);
|
||||
$this->assertNull($totals['packaging_total']);
|
||||
$this->assertNull($totals['shipping_total']);
|
||||
$this->assertNull($totals['grand_total']);
|
||||
}
|
||||
|
||||
public function test_aggregate_sums_multiple_lines_per_category(): void
|
||||
{
|
||||
$totals = ProjectCosting::aggregate([
|
||||
['category' => 'filament', 'line_total' => '5.0000'],
|
||||
['category' => 'filament', 'line_total' => '2.5000'],
|
||||
]);
|
||||
|
||||
$this->assertSame('7.5000', $totals['filament_total']);
|
||||
}
|
||||
|
||||
public function test_per_unit_divides_grand_total_by_batch_yield_with_rounding(): void
|
||||
{
|
||||
$this->assertSame('3.3333', ProjectCosting::perUnit('10.0000', 3));
|
||||
}
|
||||
|
||||
public function test_per_unit_rejects_zero_or_negative_yield(): void
|
||||
{
|
||||
$this->expectException(InvalidArgumentException::class);
|
||||
ProjectCosting::perUnit('10.0000', 0);
|
||||
}
|
||||
|
||||
public function test_rejects_unknown_category(): void
|
||||
{
|
||||
$this->expectException(InvalidArgumentException::class);
|
||||
ProjectCosting::lineTotal('labour', '1', 'unit', '1.000000');
|
||||
}
|
||||
|
||||
public function test_rejects_mixed_currency_summation(): void
|
||||
{
|
||||
$this->expectException(InvalidArgumentException::class);
|
||||
ProjectCosting::assertSameCurrency(['GBP', 'USD']);
|
||||
}
|
||||
|
||||
public function test_allows_single_consistent_currency(): void
|
||||
{
|
||||
ProjectCosting::assertSameCurrency(['GBP', 'GBP', 'GBP']);
|
||||
$this->addToAssertionCount(1);
|
||||
}
|
||||
|
||||
public function test_validates_iso_currency_allowlist(): void
|
||||
{
|
||||
$this->assertTrue(ProjectCosting::isValidCurrency('GBP'));
|
||||
$this->assertFalse(ProjectCosting::isValidCurrency('XYZ'));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user