88use Doctrine \DBAL \Connection ;
99use Doctrine \ORM \EntityManagerInterface ;
1010use Doctrine \ORM \Tools \SchemaTool ;
11+ use ITKDev \EntityBundle \Privacy \AnonymizationRule ;
12+ use ITKDev \EntityBundle \Privacy \AuditScrubber ;
13+ use ITKDev \EntityBundle \Privacy \Strategy ;
1114use ITKDev \EntityBundle \Privacy \SubjectAnonymizer ;
1215use ITKDev \EntityBundle \Tests \Fixtures \Entity \FixtureEntity ;
1316use 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 ();
0 commit comments