@@ -138,4 +138,191 @@ public function testRecoveryCodeRoundTrip(): void
138138 $ deleted = $ repo ->deleteAllForUser ($ this ->user );
139139 $ this ->assertGreaterThanOrEqual (1 , $ deleted );
140140 }
141+
142+ // -------------------------------------------------------------------------
143+ // Targeted behaviour tests
144+ // -------------------------------------------------------------------------
145+
146+ public function testExpiredTrustedDeviceIsExcluded (): void
147+ {
148+ $ repo = App::make (IUserTrustedDeviceRepository::class);
149+ $ now = new \DateTime ('now ' , new \DateTimeZone ('UTC ' ));
150+ $ expired = (clone $ now )->modify ('-1 minute ' );
151+ $ deviceId = hash ('sha256 ' , 'expired-device- ' . uniqid ());
152+
153+ $ device = $ this ->buildDevice ($ deviceId , $ now , $ expired );
154+ EntityManager::persist ($ device );
155+ EntityManager::flush ();
156+ $ id = $ device ->getId ();
157+ EntityManager::clear ();
158+
159+ $ this ->assertNull (
160+ $ repo ->getActiveByUserAndIdentifier ($ this ->user , $ deviceId ),
161+ 'getActiveByUserAndIdentifier must return null for an expired device. '
162+ );
163+
164+ $ ids = array_map (
165+ fn (UserTrustedDevice $ d ) => $ d ->getDeviceIdentifier (),
166+ $ repo ->getActiveByUser ($ this ->user )
167+ );
168+ $ this ->assertNotContains ($ deviceId , $ ids , 'getActiveByUser must not include expired devices. ' );
169+
170+ $ stale = EntityManager::find (UserTrustedDevice::class, $ id );
171+ if ($ stale ) { EntityManager::remove ($ stale ); EntityManager::flush (); }
172+ }
173+
174+ public function testRevokedTrustedDeviceIsExcluded (): void
175+ {
176+ $ repo = App::make (IUserTrustedDeviceRepository::class);
177+ $ now = new \DateTime ('now ' , new \DateTimeZone ('UTC ' ));
178+ $ expires = (clone $ now )->modify ('+30 days ' );
179+ $ deviceId = hash ('sha256 ' , 'revoked-device- ' . uniqid ());
180+
181+ $ device = $ this ->buildDevice ($ deviceId , $ now , $ expires );
182+ $ device ->setIsRevoked (true );
183+ EntityManager::persist ($ device );
184+ EntityManager::flush ();
185+ $ id = $ device ->getId ();
186+ EntityManager::clear ();
187+
188+ $ this ->assertNull (
189+ $ repo ->getActiveByUserAndIdentifier ($ this ->user , $ deviceId ),
190+ 'getActiveByUserAndIdentifier must return null for a revoked device. '
191+ );
192+
193+ $ ids = array_map (
194+ fn (UserTrustedDevice $ d ) => $ d ->getDeviceIdentifier (),
195+ $ repo ->getActiveByUser ($ this ->user )
196+ );
197+ $ this ->assertNotContains ($ deviceId , $ ids , 'getActiveByUser must not include revoked devices. ' );
198+
199+ $ stale = EntityManager::find (UserTrustedDevice::class, $ id );
200+ if ($ stale ) { EntityManager::remove ($ stale ); EntityManager::flush (); }
201+ }
202+
203+ public function testDuplicateDeviceIdentifierCannotOccur (): void
204+ {
205+ $ connection = EntityManager::getConnection ();
206+ $ indexes = $ connection ->createSchemaManager ()->listTableIndexes ('user_trusted_devices ' );
207+
208+ $ hasUnique = false ;
209+ foreach ($ indexes as $ index ) {
210+ if ($ index ->isUnique ()) {
211+ $ cols = $ index ->getColumns ();
212+ if (in_array ('user_id ' , $ cols ) && in_array ('device_identifier ' , $ cols )) {
213+ $ hasUnique = true ;
214+ break ;
215+ }
216+ }
217+ }
218+
219+ $ this ->assertTrue (
220+ $ hasUnique ,
221+ 'user_trusted_devices must have a UNIQUE index on (user_id, device_identifier). '
222+ );
223+ }
224+
225+ public function testRecoveryCodeDeletionRemovesUsedAndUnusedCodes (): void
226+ {
227+ $ repo = App::make (IUserRecoveryCodeRepository::class);
228+
229+ $ unused = new UserRecoveryCode ();
230+ $ unused ->setUser ($ this ->user );
231+ $ unused ->setCodeHash (password_hash ('UNUSED_ ' . uniqid (), PASSWORD_BCRYPT ));
232+
233+ $ used = new UserRecoveryCode ();
234+ $ used ->setUser ($ this ->user );
235+ $ used ->setCodeHash (password_hash ('USED_ ' . uniqid (), PASSWORD_BCRYPT ));
236+ $ used ->markUsed ();
237+
238+ EntityManager::persist ($ unused );
239+ EntityManager::persist ($ used );
240+ EntityManager::flush ();
241+ $ unusedId = $ unused ->getId ();
242+ $ usedId = $ used ->getId ();
243+
244+ $ deleted = $ repo ->deleteAllForUser ($ this ->user );
245+ $ this ->assertGreaterThanOrEqual (2 , $ deleted , 'deleteAllForUser must remove both used and unused codes. ' );
246+
247+ EntityManager::clear ();
248+ $ this ->assertNull (
249+ EntityManager::find (UserRecoveryCode::class, $ unusedId ),
250+ 'Unused recovery code must be deleted. '
251+ );
252+ $ this ->assertNull (
253+ EntityManager::find (UserRecoveryCode::class, $ usedId ),
254+ 'Used recovery code must also be deleted. '
255+ );
256+ }
257+
258+ public function testAuditLogsReturnedMostRecentFirst (): void
259+ {
260+ $ repo = App::make (ITwoFactorAuditLogRepository::class);
261+ $ createdIds = [];
262+
263+ $ timestamps = [
264+ new \DateTime ('2020-01-01 01:00:00 ' , new \DateTimeZone ('UTC ' )),
265+ new \DateTime ('2020-01-01 02:00:00 ' , new \DateTimeZone ('UTC ' )),
266+ new \DateTime ('2020-01-01 03:00:00 ' , new \DateTimeZone ('UTC ' )),
267+ ];
268+
269+ $ setCreatedAt = static function (TwoFactorAuditLog $ log , \DateTime $ dt ): void {
270+ $ prop = new \ReflectionProperty (TwoFactorAuditLog::class, 'created_at ' );
271+ $ prop ->setAccessible (true );
272+ $ prop ->setValue ($ log , $ dt );
273+ };
274+
275+ foreach ($ timestamps as $ ts ) {
276+ $ entry = new TwoFactorAuditLog ();
277+ $ entry ->setUser ($ this ->user );
278+ $ entry ->setEventType (TwoFactorAuditLog::EventChallengeIssued);
279+ $ entry ->setMethod (TwoFactorAuditLog::MethodEmailOtp);
280+ $ entry ->setIpAddress ('127.0.0.1 ' );
281+ $ entry ->setUserAgent ('Mozilla/5.0 (test) ' );
282+ $ setCreatedAt ($ entry , $ ts );
283+ EntityManager::persist ($ entry );
284+ EntityManager::flush ();
285+ $ createdIds [] = $ entry ->getId ();
286+ }
287+
288+ EntityManager::clear ();
289+
290+ $ all = $ repo ->getRecentByUser ($ this ->user , 200 );
291+ $ ours = array_values (array_filter ($ all , fn (TwoFactorAuditLog $ e ) => in_array ($ e ->getId (), $ createdIds )));
292+
293+ $ this ->assertCount (3 , $ ours , 'All three seeded audit entries must be returned. ' );
294+
295+ for ($ i = 0 ; $ i < count ($ ours ) - 1 ; $ i ++) {
296+ $ this ->assertGreaterThanOrEqual (
297+ $ ours [$ i + 1 ]->getCreatedAt ()->getTimestamp (),
298+ $ ours [$ i ]->getCreatedAt ()->getTimestamp (),
299+ 'Audit logs must be ordered most-recent first. '
300+ );
301+ }
302+
303+ // cleanup
304+ foreach ($ createdIds as $ logId ) {
305+ $ log = EntityManager::find (TwoFactorAuditLog::class, $ logId );
306+ if ($ log ) { EntityManager::remove ($ log ); }
307+ }
308+ EntityManager::flush ();
309+ }
310+
311+ // -------------------------------------------------------------------------
312+ // Helpers
313+ // -------------------------------------------------------------------------
314+
315+ private function buildDevice (string $ deviceId , \DateTime $ now , \DateTime $ expires ): UserTrustedDevice
316+ {
317+ $ device = new UserTrustedDevice ();
318+ $ device ->setUser ($ this ->user );
319+ $ device ->setDeviceIdentifier ($ deviceId );
320+ $ device ->setDeviceName ('Test Browser ' );
321+ $ device ->setIpAddress ('127.0.0.1 ' );
322+ $ device ->setUserAgent ('Mozilla/5.0 (test) ' );
323+ $ device ->setTrustedAt ($ now );
324+ $ device ->setExpiresAt ($ expires );
325+ $ device ->setLastSeenAt ($ now );
326+ return $ device ;
327+ }
141328}
0 commit comments