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