diff --git a/src/API/InvoiceController.php b/src/API/InvoiceController.php index fb935833..a8b6406b 100644 --- a/src/API/InvoiceController.php +++ b/src/API/InvoiceController.php @@ -151,4 +151,39 @@ final class InvoiceController extends BaseApiController return $this->viewHandler->handle($view); } + + /** + * Download invoice + */ + #[IsGranted('view_invoice', 'invoice')] + #[OA\Response( + response: 200, + description: 'Downloads the invoice document as an attachment. The content type depends on the configured invoice renderer.', + headers: [ + new OA\Header(header: 'Content-Disposition', description: 'Attachment filename', schema: new OA\Schema(type: 'string')), + ], + content: [ + new OA\MediaType(mediaType: 'application/pdf', schema: new OA\Schema(type: 'string', format: 'binary')), + new OA\MediaType(mediaType: 'application/vnd.openxmlformats-officedocument.wordprocessingml.document', schema: new OA\Schema(type: 'string', format: 'binary')), + new OA\MediaType(mediaType: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', schema: new OA\Schema(type: 'string', format: 'binary')), + new OA\MediaType(mediaType: 'application/vnd.oasis.opendocument.spreadsheet', schema: new OA\Schema(type: 'string', format: 'binary')), + new OA\MediaType(mediaType: 'text/html', schema: new OA\Schema(type: 'string')), + new OA\MediaType(mediaType: 'application/xml', schema: new OA\Schema(type: 'string')), + new OA\MediaType(mediaType: 'text/xml', schema: new OA\Schema(type: 'string')), + new OA\MediaType(mediaType: 'application/octet-stream', schema: new OA\Schema(type: 'string', format: 'binary')), + ] + )] + #[Route(path: '/{id}/download', name: 'download_invoice', requirements: ['id' => '\d+'], methods: ['GET'])] + public function downloadAction(Invoice $invoice, InvoiceService $service): Response + { + $file = $service->getInvoiceFile($invoice); + + if (null === $file) { + throw $this->createNotFoundException( + \sprintf('Invoice file could not be found for invoice ID "%s"', $invoice->getId()) + ); + } + + return $this->file($file->getRealPath(), $file->getBasename()); + } } diff --git a/src/Entity/Invoice.php b/src/Entity/Invoice.php index 3928b258..4be084f4 100644 --- a/src/Entity/Invoice.php +++ b/src/Entity/Invoice.php @@ -124,6 +124,8 @@ class Invoice implements EntityWithMetaFields #[ORM\Column(name: 'invoice_filename', type: Types::STRING, length: 150, nullable: false)] #[Assert\NotNull] #[Assert\Length(min: 1, max: 150)] + #[Serializer\Expose] + #[Serializer\Groups(['Default'])] #[Exporter\Expose(label: 'file', type: 'string')] private ?string $invoiceFilename = null; private bool $localized = false; diff --git a/tests/API/APIControllerBaseTestCase.php b/tests/API/APIControllerBaseTestCase.php index 2d2229cf..a7f3e13f 100644 --- a/tests/API/APIControllerBaseTestCase.php +++ b/tests/API/APIControllerBaseTestCase.php @@ -315,6 +315,7 @@ abstract class APIControllerBaseTestCase extends AbstractControllerBaseTestCase 'user' => ['result' => 'object', 'type' => '@User'], 'dueDays' => 'int', 'invoiceNumber' => 'string', + 'invoiceFilename' => 'string', 'paymentDate' => '@datetime', 'status' => 'string', 'tax' => 'float', diff --git a/tests/API/ApiDocControllerTest.php b/tests/API/ApiDocControllerTest.php index 336ce5bc..02deb7aa 100644 --- a/tests/API/ApiDocControllerTest.php +++ b/tests/API/ApiDocControllerTest.php @@ -77,6 +77,7 @@ class ApiDocControllerTest extends AbstractControllerBaseTestCase '/api/invoices', '/api/invoices/{id}', '/api/invoices/{id}/custom-fields', + '/api/invoices/{id}/download', '/api/projects', '/api/projects/{id}', '/api/projects/{id}/meta', diff --git a/tests/API/InvoiceControllerTest.php b/tests/API/InvoiceControllerTest.php index e375a396..d0034b7a 100644 --- a/tests/API/InvoiceControllerTest.php +++ b/tests/API/InvoiceControllerTest.php @@ -16,8 +16,10 @@ use App\Entity\User; use App\Repository\TeamRepository; use App\Tests\DataFixtures\InvoiceFixtures; use App\Tests\Mocks\InvoiceTestMetaFieldSubscriberMock; +use App\Utils\FileHelper; use PHPUnit\Framework\Attributes\Group; use Symfony\Component\EventDispatcher\EventDispatcher; +use Symfony\Component\HttpFoundation\BinaryFileResponse; use Symfony\Component\HttpFoundation\Response; #[Group('integration')] @@ -133,7 +135,7 @@ class InvoiceControllerTest extends APIControllerBaseTestCase $this->assertEntityNotFound(User::ROLE_USER, '/api/invoices/' . PHP_INT_MAX); } - public function testDownloadRespectsCustomerPermission(): void + public function testGetEntityRespectsCustomerPermission(): void { $client = $this->getClientForAuthenticatedUser(User::ROLE_TEAMLEAD); @@ -163,6 +165,79 @@ class InvoiceControllerTest extends APIControllerBaseTestCase $this->assertApiAccessDenied($client, '/api/invoices/' . $invoice->getId()); } + public function testDownload(): void + { + $client = $this->getClientForAuthenticatedUser(User::ROLE_ADMIN); + $invoice = $this->importInvoiceFixtures(1)[0]; + $filename = $invoice->getInvoiceFilename() . '.pdf'; + $invoice->setFilename($filename); + + $em = $this->getEntityManager(); + $em->persist($invoice); + $em->flush(); + + /** @var FileHelper $fileHelper */ + $fileHelper = $this->getPrivateService(FileHelper::class); + $path = $fileHelper->getDataDirectory('invoices') . $filename; + file_put_contents($path, '%PDF-1.4 test'); + + try { + $this->assertAccessIsGranted($client, '/api/invoices/' . $invoice->getId() . '/download'); + + $response = $client->getResponse(); + self::assertInstanceOf(BinaryFileResponse::class, $response); + self::assertEquals('application/pdf', $response->headers->get('Content-Type')); + self::assertStringContainsString('attachment; filename=' . $filename, $response->headers->get('Content-Disposition') ?? ''); + } finally { + $fileHelper->removeFile($path); + } + } + + public function testDownloadIsSecure(): void + { + $client = $this->getClientForAuthenticatedUser(); + $invoices = $this->importInvoiceFixtures(1); + + $this->assertApiAccessDenied($client, '/api/invoices/' . $invoices[0]->getId() . '/download'); + } + + public function testDownloadRespectsCustomerPermission(): void + { + $client = $this->getClientForAuthenticatedUser(User::ROLE_TEAMLEAD); + + $invoice = $this->importInvoiceFixtures(1, [Invoice::STATUS_NEW])[0]; + $filename = $invoice->getInvoiceFilename() . '.pdf'; + $invoice->setFilename($filename); + + $em = $this->getEntityManager(); + $em->persist($invoice); + $em->flush(); + + /** @var FileHelper $fileHelper */ + $fileHelper = $this->getPrivateService(FileHelper::class); + $path = $fileHelper->getDataDirectory('invoices') . $filename; + file_put_contents($path, '%PDF-1.4 test'); + + try { + $this->assertAccessIsGranted($client, '/api/invoices/' . $invoice->getId() . '/download'); + + $customer = $invoice->getCustomer(); + self::assertInstanceOf(Customer::class, $customer); + + $team = new Team('foo'); + $team->addTeamlead($this->getUserByRole(User::ROLE_ADMIN)); + $team->addCustomer($customer); + + /** @var TeamRepository $repository */ + $repository = $em->getRepository(Team::class); + $repository->saveTeam($team); + + $this->assertApiAccessDenied($client, '/api/invoices/' . $invoice->getId() . '/download'); + } finally { + $fileHelper->removeFile($path); + } + } + public function testCollectionRespectsCustomerPermission(): void { $client = $this->getClientForAuthenticatedUser(User::ROLE_TEAMLEAD);