Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 1 addition & 7 deletions src/AttributeReader/UploadableAttributeReader.php
Original file line number Diff line number Diff line change
Expand Up @@ -102,13 +102,7 @@ public function getConfiguredProperties(object|string $data, bool $skipUploadabl
// same file. Fail loudly instead of corrupting data: each field needs a distinct
// `property:` (with a matching nullable string entity property; the bundle maps the column).
if (isset($seenStorageProperties[$config->property])) {
throw new UnsupportedAnnotationException(\sprintf(
'Uploadable %s has two UploadableField properties ("%s" and "%s") sharing the storage property "%s". Set a distinct `property:` on each UploadableField so uploads do not overwrite each other.',
\is_string($data) ? $data : $data::class,
$seenStorageProperties[$config->property],
$reflectionProperty->getName(),
$config->property
));
throw new UnsupportedAnnotationException(\sprintf('Uploadable %s has two UploadableField properties ("%s" and "%s") sharing the storage property "%s". Set a distinct `property:` on each UploadableField so uploads do not overwrite each other.', \is_string($data) ? $data : $data::class, $seenStorageProperties[$config->property], $reflectionProperty->getName(), $config->property));
}
$seenStorageProperties[$config->property] = $reflectionProperty->getName();

Expand Down
4 changes: 2 additions & 2 deletions src/Serializer/Normalizer/Trait/ManifestDepthGroupTrait.php
Original file line number Diff line number Diff line change
Expand Up @@ -50,8 +50,8 @@ private function buildDepthGroups(array $resource): array
* or already-seen (deduplicated) resource — so blank-node metadata and back-references never
* appear as nodes, matching the flat behaviour this replaced.
*
* @param array<int, array> $parentResources collected boundary resources (by reference)
* @param array<string, true> $seen per-depth IRI dedup set (by reference)
* @param array<int, array> $parentResources collected boundary resources (by reference)
* @param array<string, true> $seen per-depth IRI dedup set (by reference)
*
* @return list<array{iri: string, children: array}>
*/
Expand Down
245 changes: 245 additions & 0 deletions tests/EventListener/Api/CacheHeadersEventListenerTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,245 @@
<?php

/*
* This file is part of the Silverback API Components Bundle Project
*
* (c) Daniel West <daniel@silverback.is>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/

namespace Silverback\ApiComponentsBundle\Tests\EventListener\Api;

use Doctrine\Persistence\ManagerRegistry;
use PHPUnit\Framework\TestCase;
use Silverback\ApiComponentsBundle\Annotation\Publishable;
use Silverback\ApiComponentsBundle\AttributeReader\PublishableAttributeReader;
use Silverback\ApiComponentsBundle\EventListener\Api\CacheHeadersEventListener;
use Silverback\ApiComponentsBundle\Helper\Publishable\PublishableStatusChecker;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\HttpKernel\Event\ResponseEvent;
use Symfony\Component\HttpKernel\HttpKernelInterface;
use Symfony\Component\Security\Core\Authentication\Token\TokenInterface;
use Symfony\Component\Security\Core\Authentication\Token\Storage\TokenStorageInterface;
use Symfony\Component\Security\Core\User\UserInterface;

/**
* @author Daniel West <daniel@silverback.is>
*/
class CacheHeadersEventListenerTest extends TestCase
{
private TokenStorageInterface $tokenStorage;

protected function setUp(): void
{
$this->tokenStorage = $this->createStub(TokenStorageInterface::class);
}

public function test_authenticated_request_for_configured_resource_is_marked_private_no_store(): void
{
$this->authenticateAsUser();
$response = $this->dispatch(
resourceClass: CacheHeadersConfiguredResource::class,
method: Request::METHOD_GET,
personalisedResourceClasses: [CacheHeadersConfiguredResource::class],
initialSharedMaxAge: 3600,
);

self::assertTrue($response->headers->hasCacheControlDirective('private'));
self::assertFalse($response->headers->hasCacheControlDirective('public'));
self::assertTrue($response->headers->getCacheControlDirective('no-store'));
self::assertFalse($response->headers->hasCacheControlDirective('s-maxage'));
}

public function test_authenticated_request_for_subclass_of_configured_resource_is_marked_private(): void
{
// is_a(..., true) must match subclasses; a FalseValue mutant on the third arg breaks this.
$this->authenticateAsUser();
$response = $this->dispatch(
resourceClass: CacheHeadersConfiguredChildResource::class,
method: Request::METHOD_GET,
personalisedResourceClasses: [CacheHeadersConfiguredResource::class],
);

self::assertTrue($response->headers->hasCacheControlDirective('private'));
self::assertTrue($response->headers->getCacheControlDirective('no-store'));
}

public function test_authenticated_request_for_publishable_resource_is_marked_private(): void
{
// Not in the configured list — matched via the PublishableAttributeReader fallback only.
$this->authenticateAsUser();
$response = $this->dispatch(
resourceClass: CacheHeadersPublishableResource::class,
method: Request::METHOD_GET,
personalisedResourceClasses: [CacheHeadersConfiguredResource::class],
);

self::assertTrue($response->headers->hasCacheControlDirective('private'));
self::assertTrue($response->headers->getCacheControlDirective('no-store'));
}

public function test_authenticated_request_for_unaffected_resource_keeps_public_cache(): void
{
$this->authenticateAsUser();
$response = $this->dispatch(
resourceClass: CacheHeadersUnaffectedResource::class,
method: Request::METHOD_GET,
personalisedResourceClasses: [CacheHeadersConfiguredResource::class],
initialSharedMaxAge: 3600,
);

self::assertFalse($response->headers->hasCacheControlDirective('private'));
self::assertFalse($response->headers->hasCacheControlDirective('no-store'));
self::assertTrue($response->headers->hasCacheControlDirective('s-maxage'));
}

public function test_non_cacheable_method_is_left_untouched_even_for_configured_resource(): void
{
$this->authenticateAsUser();
$response = $this->dispatch(
resourceClass: CacheHeadersConfiguredResource::class,
method: Request::METHOD_POST,
personalisedResourceClasses: [CacheHeadersConfiguredResource::class],
initialSharedMaxAge: 3600,
);

self::assertFalse($response->headers->hasCacheControlDirective('private'));
self::assertFalse($response->headers->hasCacheControlDirective('no-store'));
self::assertTrue($response->headers->hasCacheControlDirective('s-maxage'));
}

public function test_missing_resource_class_is_left_untouched(): void
{
// The token storage must never be consulted once the resource class check bails out.
$tokenStorage = $this->createMock(TokenStorageInterface::class);
$tokenStorage->expects(self::never())->method('getToken');
$this->tokenStorage = $tokenStorage;
$response = $this->dispatch(
resourceClass: null,
method: Request::METHOD_GET,
personalisedResourceClasses: [CacheHeadersConfiguredResource::class],
initialSharedMaxAge: 3600,
);

self::assertFalse($response->headers->hasCacheControlDirective('private'));
self::assertTrue($response->headers->hasCacheControlDirective('s-maxage'));
}

public function test_non_string_resource_class_is_left_untouched(): void
{
$response = $this->dispatch(
resourceClass: ['not', 'a', 'string'],
method: Request::METHOD_GET,
personalisedResourceClasses: [CacheHeadersConfiguredResource::class],
initialSharedMaxAge: 3600,
);

self::assertFalse($response->headers->hasCacheControlDirective('private'));
self::assertTrue($response->headers->hasCacheControlDirective('s-maxage'));
}

public function test_anonymous_request_for_configured_resource_keeps_public_cache(): void
{
$this->tokenStorage->method('getToken')->willReturn(null);
$response = $this->dispatch(
resourceClass: CacheHeadersConfiguredResource::class,
method: Request::METHOD_GET,
personalisedResourceClasses: [CacheHeadersConfiguredResource::class],
initialSharedMaxAge: 3600,
);

self::assertFalse($response->headers->hasCacheControlDirective('private'));
self::assertFalse($response->headers->hasCacheControlDirective('no-store'));
self::assertTrue($response->headers->hasCacheControlDirective('s-maxage'));
}

public function test_token_without_user_interface_keeps_public_cache(): void
{
// A token exists but its user is not a UserInterface (e.g. a string 'anon.'); the resource
// must stay public. Kills the LogicalAnd->LogicalOr and instanceof mutants in isAuthenticated().
$token = $this->createStub(TokenInterface::class);
$token->method('getUser')->willReturn(null);
$this->tokenStorage->method('getToken')->willReturn($token);

$response = $this->dispatch(
resourceClass: CacheHeadersConfiguredResource::class,
method: Request::METHOD_GET,
personalisedResourceClasses: [CacheHeadersConfiguredResource::class],
initialSharedMaxAge: 3600,
);

self::assertFalse($response->headers->hasCacheControlDirective('private'));
self::assertFalse($response->headers->hasCacheControlDirective('no-store'));
self::assertTrue($response->headers->hasCacheControlDirective('s-maxage'));
}

private function authenticateAsUser(): void
{
$token = $this->createStub(TokenInterface::class);
$token->method('getUser')->willReturn($this->createStub(UserInterface::class));
$this->tokenStorage->method('getToken')->willReturn($token);
}

/**
* @param array<class-string> $personalisedResourceClasses
*/
private function dispatch(
mixed $resourceClass,
string $method,
array $personalisedResourceClasses,
?int $initialSharedMaxAge = null,
): Response {
$publishableReader = new PublishableAttributeReader($this->createStub(ManagerRegistry::class));
$statusChecker = $this->createStub(PublishableStatusChecker::class);
$statusChecker->method('getAttributeReader')->willReturn($publishableReader);

$listener = new CacheHeadersEventListener(
$this->tokenStorage,
$statusChecker,
$personalisedResourceClasses,
);

$request = new Request();
$request->setMethod($method);
if (null !== $resourceClass) {
$request->attributes->set('_api_resource_class', $resourceClass);
}

$response = new Response();
$response->setPublic();
if (null !== $initialSharedMaxAge) {
$response->setSharedMaxAge($initialSharedMaxAge);
}

$event = new ResponseEvent(
$this->createStub(HttpKernelInterface::class),
$request,
HttpKernelInterface::MAIN_REQUEST,
$response,
);

$listener->onPostRespond($event);

return $event->getResponse();
}
}

#[Publishable]
class CacheHeadersPublishableResource
{
}

class CacheHeadersConfiguredResource
{
}

class CacheHeadersConfiguredChildResource extends CacheHeadersConfiguredResource
{
}

class CacheHeadersUnaffectedResource
{
}
22 changes: 16 additions & 6 deletions tests/Serializer/Normalizer/ManifestDepthGroupTraitTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -192,12 +192,22 @@ public function test_containment_is_preserved_as_nesting(): void
];

$this->assertSame(
[$this->n('/_/routes/home',
$this->n('/page_data/pd1',
$this->n('/_/pages/p1',
$this->n('/_/component_groups/cg1',
$this->n('/_/component_positions/cp1',
$this->n('/component/dummy/c1')))))), ],
[$this->n(
'/_/routes/home',
$this->n(
'/page_data/pd1',
$this->n(
'/_/pages/p1',
$this->n(
'/_/component_groups/cg1',
$this->n(
'/_/component_positions/cp1',
$this->n('/component/dummy/c1')
)
)
)
)
), ],
$this->subject->groups($resource)
);
}
Expand Down
Loading