Skip to content

Commit df542c8

Browse files
committed
100 % test coverage
1 parent af773c9 commit df542c8

11 files changed

Lines changed: 461 additions & 10 deletions

src/Privacy/AuditScrubber.php

Lines changed: 1 addition & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -185,12 +185,9 @@ private function clearDiffValues(array $diffs): array
185185
private function discoverAuditTables(): array
186186
{
187187
$tables = [];
188+
/** @var DoctrineAuditConfiguration $configuration */
188189
$configuration = $this->auditProvider->getConfiguration();
189-
\assert($configuration instanceof DoctrineAuditConfiguration);
190190
foreach ($configuration->getEntities() as $entry) {
191-
if (!\is_array($entry) || !isset($entry['audit_table_name'])) {
192-
continue;
193-
}
194191
$tables[] = (string) $entry['audit_table_name'];
195192
}
196193

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace ITKDev\EntityBundle\Tests\Fixtures\Entity;
6+
7+
use Doctrine\ORM\Mapping as ORM;
8+
use ITKDev\EntityBundle\Audit\Attribute\Auditable;
9+
use ITKDev\EntityBundle\Entity\AbstractITKDevEntity;
10+
use ITKDev\EntityBundle\Entity\Contract\AnonymizationStatusInterface;
11+
use ITKDev\EntityBundle\Entity\Contract\TimestampableInterface;
12+
use ITKDev\EntityBundle\Entity\Trait\AnonymizationStatusTrait;
13+
use ITKDev\EntityBundle\Entity\Trait\TimestampableTrait;
14+
use ITKDev\EntityBundle\Privacy\Attribute\Anonymize;
15+
use ITKDev\EntityBundle\Privacy\Strategy;
16+
17+
/**
18+
* Anonymizable entity with no foreign key to TestUser, exercising SubjectAnonymizer's
19+
* "skip class that has no user FK" branch.
20+
*/
21+
#[ORM\Entity]
22+
#[ORM\Table(name: 'test_orphan_anonymizable')]
23+
#[Auditable]
24+
class OrphanAnonymizableEntity extends AbstractITKDevEntity implements TimestampableInterface, AnonymizationStatusInterface
25+
{
26+
use TimestampableTrait;
27+
use AnonymizationStatusTrait;
28+
29+
#[ORM\Column(type: 'string', length: 64, nullable: true)]
30+
#[Anonymize(strategy: Strategy::Redact)]
31+
private ?string $payload = null;
32+
33+
public function setPayload(?string $payload): void
34+
{
35+
$this->payload = $payload;
36+
}
37+
38+
public function getPayload(): ?string
39+
{
40+
return $this->payload;
41+
}
42+
}

tests/Fixtures/Entity/TestUser.php

Lines changed: 24 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,13 +7,26 @@
77
use Doctrine\ORM\Mapping as ORM;
88
use ITKDev\EntityBundle\Audit\Attribute\Auditable;
99
use ITKDev\EntityBundle\Entity\AbstractITKDevEntity;
10+
use ITKDev\EntityBundle\Entity\Contract\AnonymizationStatusInterface;
11+
use ITKDev\EntityBundle\Entity\Contract\TimestampableInterface;
12+
use ITKDev\EntityBundle\Entity\Trait\AnonymizationStatusTrait;
13+
use ITKDev\EntityBundle\Entity\Trait\TimestampableTrait;
14+
use ITKDev\EntityBundle\Privacy\Attribute\Anonymize;
15+
use ITKDev\EntityBundle\Privacy\Strategy;
1016
use Symfony\Component\Security\Core\User\UserInterface;
1117

1218
#[ORM\Entity]
1319
#[ORM\Table(name: 'test_user')]
1420
#[Auditable]
15-
class TestUser extends AbstractITKDevEntity implements UserInterface
21+
class TestUser extends AbstractITKDevEntity implements UserInterface, TimestampableInterface, AnonymizationStatusInterface
1622
{
23+
use TimestampableTrait;
24+
use AnonymizationStatusTrait;
25+
26+
#[ORM\Column(type: 'string', length: 191, nullable: true)]
27+
#[Anonymize(strategy: Strategy::Redact)]
28+
private ?string $email = null;
29+
1730
public function getUserIdentifier(): string
1831
{
1932
return (string) $this->getId();
@@ -27,4 +40,14 @@ public function getRoles(): array
2740
public function eraseCredentials(): void
2841
{
2942
}
43+
44+
public function getEmail(): ?string
45+
{
46+
return $this->email;
47+
}
48+
49+
public function setEmail(?string $email): void
50+
{
51+
$this->email = $email;
52+
}
3053
}

tests/Integration/AbstractITKDevEntityIntegrationTest.php

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,9 @@
77
use Doctrine\ORM\EntityManagerInterface;
88
use Doctrine\ORM\Tools\SchemaTool;
99
use ITKDev\EntityBundle\Entity\Contract\IdentifiableInterface;
10+
use ITKDev\EntityBundle\Tests\Fixtures\Entity\AttributeOnlyEntity;
1011
use ITKDev\EntityBundle\Tests\Fixtures\Entity\FixtureEntity;
12+
use ITKDev\EntityBundle\Tests\Fixtures\Entity\NonAuditableEntity;
1113
use ITKDev\EntityBundle\Tests\Fixtures\Entity\TestUser;
1214
use Symfony\Component\Security\Core\Authentication\Token\Storage\TokenStorageInterface;
1315
use Symfony\Component\Security\Core\Authentication\Token\UsernamePasswordToken;
@@ -246,6 +248,62 @@ public function testArchivableFilterHidesArchivedRowsWhenEnabled(): void
246248
self::assertSame('live', $visible[0]->getLabel());
247249
}
248250

251+
public function testArchivableFilterReturnsNoConstraintForNonArchivableEntity(): void
252+
{
253+
$entity = new AttributeOnlyEntity();
254+
$this->em->persist($entity);
255+
$this->em->flush();
256+
$this->em->clear();
257+
258+
$this->em->getFilters()->enable('archivable');
259+
260+
// AttributeOnlyEntity does not implement ArchivableInterface, so the filter must
261+
// short-circuit with an empty constraint — otherwise the query would emit SQL
262+
// referencing a non-existent archived_at column on test_attribute_only.
263+
$rows = $this->em->getRepository(AttributeOnlyEntity::class)->findAll();
264+
self::assertCount(1, $rows);
265+
}
266+
267+
public function testSoftDeleteFilterReturnsNoConstraintForNonSoftDeletableEntity(): void
268+
{
269+
$entity = new AttributeOnlyEntity();
270+
$this->em->persist($entity);
271+
$this->em->flush();
272+
$this->em->clear();
273+
274+
// soft_delete is enabled by default. AttributeOnlyEntity does not implement
275+
// SoftDeletableInterface, so the filter must short-circuit rather than reference
276+
// a non-existent deleted_at column.
277+
$rows = $this->em->getRepository(AttributeOnlyEntity::class)->findAll();
278+
self::assertCount(1, $rows);
279+
}
280+
281+
public function testListenersSkipEntitiesWithoutTheirRespectiveInterface(): void
282+
{
283+
// NonAuditableEntity implements none of Timestampable/Blameable/SoftDeletable, so
284+
// every per-feature onFlush listener must `continue` past it during insert, update
285+
// and delete flushes. Exercises the skip-branches of all three listeners and the
286+
// soft-delete filter's empty-constraint path for the same entity.
287+
$alice = new TestUser();
288+
$this->em->persist($alice);
289+
$this->em->flush();
290+
$this->loginAs($alice);
291+
292+
$entity = new NonAuditableEntity();
293+
$entity->setLabel('initial');
294+
$this->em->persist($entity);
295+
$this->em->flush();
296+
297+
$entity->setLabel('changed');
298+
$this->em->flush();
299+
300+
$this->em->remove($entity);
301+
$this->em->flush();
302+
$this->em->clear();
303+
304+
self::assertCount(0, $this->em->getRepository(NonAuditableEntity::class)->findAll());
305+
}
306+
249307
/**
250308
* @param list<FixtureEntity> $entities
251309
*
Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,59 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace ITKDev\EntityBundle\Tests\Integration\Privacy;
6+
7+
use Doctrine\ORM\EntityManagerInterface;
8+
use Doctrine\ORM\Tools\SchemaTool;
9+
use ITKDev\EntityBundle\Privacy\Anonymizer;
10+
use ITKDev\EntityBundle\Tests\Fixtures\Entity\FixtureEntity;
11+
use ITKDev\EntityBundle\Tests\Fixtures\Entity\NonAuditableEntity;
12+
use ITKDev\EntityBundle\Tests\Integration\IntegrationTestCase;
13+
14+
final class AnonymizerTest extends IntegrationTestCase
15+
{
16+
private EntityManagerInterface $em;
17+
private Anonymizer $anonymizer;
18+
19+
protected function setUp(): void
20+
{
21+
self::bootKernel();
22+
$container = static::getContainer();
23+
24+
$this->em = $container->get(EntityManagerInterface::class);
25+
$this->anonymizer = $container->get(Anonymizer::class);
26+
27+
$tool = new SchemaTool($this->em);
28+
$metadata = $this->em->getMetadataFactory()->getAllMetadata();
29+
$tool->dropSchema($metadata);
30+
$tool->createSchema($metadata);
31+
}
32+
33+
public function testReturnsFalseWhenEntityClassHasNoRules(): void
34+
{
35+
$entity = new NonAuditableEntity();
36+
$entity->setLabel('untouched');
37+
$this->em->persist($entity);
38+
$this->em->flush();
39+
40+
// NonAuditableEntity carries no #[Anonymize] attributes, so the registry has
41+
// no rules for it and anonymize() must return false without flushing changes.
42+
self::assertFalse($this->anonymizer->anonymize($entity));
43+
self::assertSame('untouched', $entity->getLabel());
44+
}
45+
46+
public function testReturnsFalseWhenEntityIsAlreadyAnonymized(): void
47+
{
48+
$entity = new FixtureEntity();
49+
$entity->setLabel('original');
50+
$entity->markAnonymized(new \DateTimeImmutable('2025-01-01'));
51+
$this->em->persist($entity);
52+
$this->em->flush();
53+
54+
// Rules exist for FixtureEntity but isAnonymized() is true, so anonymize()
55+
// must short-circuit with false and leave the label untouched.
56+
self::assertFalse($this->anonymizer->anonymize($entity));
57+
self::assertSame('original', $entity->getLabel());
58+
}
59+
}

tests/Integration/Privacy/AuditScrubberTest.php

Lines changed: 96 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,9 @@
88
use Doctrine\DBAL\Connection;
99
use Doctrine\ORM\EntityManagerInterface;
1010
use Doctrine\ORM\Tools\SchemaTool;
11+
use ITKDev\EntityBundle\Privacy\AnonymizationRule;
12+
use ITKDev\EntityBundle\Privacy\AuditScrubber;
13+
use ITKDev\EntityBundle\Privacy\Strategy;
1114
use ITKDev\EntityBundle\Privacy\SubjectAnonymizer;
1215
use ITKDev\EntityBundle\Tests\Fixtures\Entity\FixtureEntity;
1316
use ITKDev\EntityBundle\Tests\Fixtures\Entity\TestUser;
@@ -143,6 +146,94 @@ public function testNullsIpOnAuditRowsWhereSubjectWasActor(): void
143146
self::assertSame('198.51.100.20', $bobIp, "other users' IPs must stay intact");
144147
}
145148

149+
public function testScrubEntityHistoryIsANoopWhenNoRules(): void
150+
{
151+
$alice = $this->aUser();
152+
$this->loginAs($alice);
153+
154+
$entity = new FixtureEntity();
155+
$entity->setLabel('keepme');
156+
$this->em->persist($entity);
157+
$this->em->flush();
158+
159+
$before = $this->concatAuditDiffs((string) $entity->getId());
160+
$this->scrubber()->scrubEntityHistory($entity, []);
161+
$after = $this->concatAuditDiffs((string) $entity->getId());
162+
163+
self::assertSame($before, $after);
164+
self::assertStringContainsString('keepme', $after);
165+
}
166+
167+
public function testScrubEntityHistorySkipsRowsWithInvalidJsonOrNullSideValues(): void
168+
{
169+
$alice = $this->aUser();
170+
$this->loginAs($alice);
171+
172+
$entity = new FixtureEntity();
173+
$entity->setLabel('pii');
174+
$this->em->persist($entity);
175+
$this->em->flush();
176+
177+
// Row 1: diffs is not valid JSON. Row 2: label side values are null.
178+
// Both must be left untouched (no error, no DB update) by the scrubber.
179+
$this->conn->executeStatement(
180+
"INSERT INTO test_fixture_entity_audit (type, object_id, diffs, blame_id, created_at) VALUES ('update', :oid, 'null', :uid, NOW())",
181+
['oid' => (string) $entity->getId(), 'uid' => (string) $alice->getId()],
182+
);
183+
$this->conn->executeStatement(
184+
"INSERT INTO test_fixture_entity_audit (type, object_id, diffs, blame_id, created_at) VALUES ('update', :oid, :diffs, :uid, NOW())",
185+
[
186+
'oid' => (string) $entity->getId(),
187+
'uid' => (string) $alice->getId(),
188+
'diffs' => json_encode(['label' => ['old' => null, 'new' => null]], JSON_THROW_ON_ERROR),
189+
],
190+
);
191+
192+
$this->scrubber()->scrubEntityHistory($entity, [
193+
new AnonymizationRule('label', Strategy::Redact, '[REDACTED]'),
194+
]);
195+
196+
$rows = $this->conn->fetchAllAssociative(
197+
'SELECT diffs FROM test_fixture_entity_audit WHERE object_id = :oid AND type = :t',
198+
['oid' => (string) $entity->getId(), 't' => 'update'],
199+
);
200+
$diffs = array_column($rows, 'diffs');
201+
self::assertContains('null', $diffs);
202+
self::assertContains('{"label":{"old":null,"new":null}}', $diffs);
203+
}
204+
205+
public function testScrubAuditOlderThanPreservesScalarDiffEntries(): void
206+
{
207+
$alice = $this->aUser();
208+
$this->loginAs($alice);
209+
210+
$entity = new FixtureEntity();
211+
$entity->setLabel('pii');
212+
$this->em->persist($entity);
213+
$this->em->flush();
214+
215+
// Inject a row whose `diffs` JSON has a scalar (non-shape) entry. clearDiffValues
216+
// must copy the scalar through verbatim rather than treat it as an old/new shape.
217+
$this->conn->executeStatement(
218+
"INSERT INTO test_fixture_entity_audit (type, object_id, diffs, blame_id, created_at) VALUES ('insert', :oid, :diffs, :uid, '2020-01-01 00:00:00')",
219+
[
220+
'oid' => (string) $entity->getId(),
221+
'uid' => (string) $alice->getId(),
222+
'diffs' => json_encode(['note' => 'scalar-marker', 'label' => ['old' => 'pii', 'new' => null]], JSON_THROW_ON_ERROR),
223+
],
224+
);
225+
226+
$touched = $this->scrubber()->scrubAuditOlderThan(FixtureEntity::class, new \DateTimeImmutable('2025-01-01'));
227+
self::assertGreaterThan(0, $touched);
228+
229+
$row = $this->conn->fetchAssociative(
230+
"SELECT diffs FROM test_fixture_entity_audit WHERE object_id = :oid AND created_at = '2020-01-01 00:00:00'",
231+
['oid' => (string) $entity->getId()],
232+
);
233+
self::assertIsArray($row);
234+
self::assertStringContainsString('scalar-marker', (string) $row['diffs']);
235+
}
236+
146237
public function testLeavesNonAnonymizableFieldsIntact(): void
147238
{
148239
$alice = $this->aUser();
@@ -169,6 +260,11 @@ public function testLeavesNonAnonymizableFieldsIntact(): void
169260
self::assertStringContainsString('updatedAt', $allDiffs, 'updatedAt change is recorded and kept');
170261
}
171262

263+
private function scrubber(): AuditScrubber
264+
{
265+
return self::getContainer()->get(AuditScrubber::class);
266+
}
267+
172268
private function aUser(): TestUser
173269
{
174270
$user = new TestUser();

tests/Integration/Privacy/PrivacyAnonymizeCommandTest.php

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -54,7 +54,8 @@ public function testHappyPath(): void
5454
$exit = $this->tester->execute(['subject' => (string) $alice->getId()]);
5555

5656
self::assertSame(Command::SUCCESS, $exit);
57-
self::assertStringContainsString('Anonymized 1 row(s)', $this->tester->getDisplay());
57+
// Subject (TestUser is anonymizable) + the FixtureEntity it created.
58+
self::assertStringContainsString('Anonymized 2 row(s)', $this->tester->getDisplay());
5859
}
5960

6061
public function testInvalidUlidExitsFailure(): void

tests/Integration/Privacy/SubjectAnonymizerTest.php

Lines changed: 8 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -51,8 +51,9 @@ public function testAnonymizesRowsLinkedToSubject(): void
5151

5252
$report = $this->anonymizer->anonymize($alice);
5353

54-
self::assertSame(1, $report->rowsAnonymized);
55-
self::assertSame(1, $report->classesAffected);
54+
// alice herself + her one FixtureEntity row.
55+
self::assertSame(2, $report->rowsAnonymized);
56+
self::assertSame(2, $report->classesAffected);
5657

5758
$this->em->clear();
5859

@@ -80,7 +81,9 @@ public function testSkipsAlreadyAnonymizedRows(): void
8081

8182
$report = $this->anonymizer->anonymize($alice);
8283

83-
self::assertSame(0, $report->rowsAnonymized);
84+
// The pre-anonymized FixtureEntity is filtered out by the query (anonymizedAt IS NULL),
85+
// so only alice herself is anonymized.
86+
self::assertSame(1, $report->rowsAnonymized);
8487
}
8588

8689
public function testAnonymizesSoftDeletedRows(): void
@@ -97,7 +100,8 @@ public function testAnonymizesSoftDeletedRows(): void
97100
$this->em->flush();
98101

99102
$report = $this->anonymizer->anonymize($alice);
100-
self::assertSame(1, $report->rowsAnonymized);
103+
// alice + her soft-deleted FixtureEntity.
104+
self::assertSame(2, $report->rowsAnonymized);
101105

102106
$this->em->clear();
103107
$this->em->getFilters()->disable('soft_delete');

0 commit comments

Comments
 (0)