Invoice download via API (#5926)
This commit is contained in:
@@ -151,4 +151,39 @@ final class InvoiceController extends BaseApiController
|
|||||||
|
|
||||||
return $this->viewHandler->handle($view);
|
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());
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -124,6 +124,8 @@ class Invoice implements EntityWithMetaFields
|
|||||||
#[ORM\Column(name: 'invoice_filename', type: Types::STRING, length: 150, nullable: false)]
|
#[ORM\Column(name: 'invoice_filename', type: Types::STRING, length: 150, nullable: false)]
|
||||||
#[Assert\NotNull]
|
#[Assert\NotNull]
|
||||||
#[Assert\Length(min: 1, max: 150)]
|
#[Assert\Length(min: 1, max: 150)]
|
||||||
|
#[Serializer\Expose]
|
||||||
|
#[Serializer\Groups(['Default'])]
|
||||||
#[Exporter\Expose(label: 'file', type: 'string')]
|
#[Exporter\Expose(label: 'file', type: 'string')]
|
||||||
private ?string $invoiceFilename = null;
|
private ?string $invoiceFilename = null;
|
||||||
private bool $localized = false;
|
private bool $localized = false;
|
||||||
|
|||||||
@@ -315,6 +315,7 @@ abstract class APIControllerBaseTestCase extends AbstractControllerBaseTestCase
|
|||||||
'user' => ['result' => 'object', 'type' => '@User'],
|
'user' => ['result' => 'object', 'type' => '@User'],
|
||||||
'dueDays' => 'int',
|
'dueDays' => 'int',
|
||||||
'invoiceNumber' => 'string',
|
'invoiceNumber' => 'string',
|
||||||
|
'invoiceFilename' => 'string',
|
||||||
'paymentDate' => '@datetime',
|
'paymentDate' => '@datetime',
|
||||||
'status' => 'string',
|
'status' => 'string',
|
||||||
'tax' => 'float',
|
'tax' => 'float',
|
||||||
|
|||||||
@@ -77,6 +77,7 @@ class ApiDocControllerTest extends AbstractControllerBaseTestCase
|
|||||||
'/api/invoices',
|
'/api/invoices',
|
||||||
'/api/invoices/{id}',
|
'/api/invoices/{id}',
|
||||||
'/api/invoices/{id}/custom-fields',
|
'/api/invoices/{id}/custom-fields',
|
||||||
|
'/api/invoices/{id}/download',
|
||||||
'/api/projects',
|
'/api/projects',
|
||||||
'/api/projects/{id}',
|
'/api/projects/{id}',
|
||||||
'/api/projects/{id}/meta',
|
'/api/projects/{id}/meta',
|
||||||
|
|||||||
@@ -16,8 +16,10 @@ use App\Entity\User;
|
|||||||
use App\Repository\TeamRepository;
|
use App\Repository\TeamRepository;
|
||||||
use App\Tests\DataFixtures\InvoiceFixtures;
|
use App\Tests\DataFixtures\InvoiceFixtures;
|
||||||
use App\Tests\Mocks\InvoiceTestMetaFieldSubscriberMock;
|
use App\Tests\Mocks\InvoiceTestMetaFieldSubscriberMock;
|
||||||
|
use App\Utils\FileHelper;
|
||||||
use PHPUnit\Framework\Attributes\Group;
|
use PHPUnit\Framework\Attributes\Group;
|
||||||
use Symfony\Component\EventDispatcher\EventDispatcher;
|
use Symfony\Component\EventDispatcher\EventDispatcher;
|
||||||
|
use Symfony\Component\HttpFoundation\BinaryFileResponse;
|
||||||
use Symfony\Component\HttpFoundation\Response;
|
use Symfony\Component\HttpFoundation\Response;
|
||||||
|
|
||||||
#[Group('integration')]
|
#[Group('integration')]
|
||||||
@@ -133,7 +135,7 @@ class InvoiceControllerTest extends APIControllerBaseTestCase
|
|||||||
$this->assertEntityNotFound(User::ROLE_USER, '/api/invoices/' . PHP_INT_MAX);
|
$this->assertEntityNotFound(User::ROLE_USER, '/api/invoices/' . PHP_INT_MAX);
|
||||||
}
|
}
|
||||||
|
|
||||||
public function testDownloadRespectsCustomerPermission(): void
|
public function testGetEntityRespectsCustomerPermission(): void
|
||||||
{
|
{
|
||||||
$client = $this->getClientForAuthenticatedUser(User::ROLE_TEAMLEAD);
|
$client = $this->getClientForAuthenticatedUser(User::ROLE_TEAMLEAD);
|
||||||
|
|
||||||
@@ -163,6 +165,79 @@ class InvoiceControllerTest extends APIControllerBaseTestCase
|
|||||||
$this->assertApiAccessDenied($client, '/api/invoices/' . $invoice->getId());
|
$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
|
public function testCollectionRespectsCustomerPermission(): void
|
||||||
{
|
{
|
||||||
$client = $this->getClientForAuthenticatedUser(User::ROLE_TEAMLEAD);
|
$client = $this->getClientForAuthenticatedUser(User::ROLE_TEAMLEAD);
|
||||||
|
|||||||
Reference in New Issue
Block a user