1717use Auth \Repositories \ITwoFactorAuditLogRepository ;
1818use Auth \Repositories \IUserRecoveryCodeRepository ;
1919use Auth \Repositories \IUserTrustedDeviceRepository ;
20- use Auth \Repositories \IUserRepository ;
2120use Auth \User ;
2221use Illuminate \Support \Facades \App ;
2322use LaravelDoctrine \ORM \Facades \EntityManager ;
@@ -33,12 +32,32 @@ class TwoFactorRepositoriesTest extends TestCase
3332 public function setUp (): void
3433 {
3534 parent ::setUp ();
36- // Pull any persisted user; tests don't assert on user fields, only on FK linkage
37- $ userRepo = App::make (IUserRepository::class);
38- $ this ->user = $ userRepo ->findOneBy ([]);
39- if (is_null ($ this ->user )) {
40- $ this ->markTestSkipped ('No User exists; database must be seeded. ' );
35+ $ user = new User ();
36+ $ user ->setFirstName ('Test ' );
37+ $ user ->setLastName ('TwoFactor ' );
38+ $ user ->setEmail ('test.twofactor. ' . uniqid () . '@test.invalid ' );
39+ $ user ->setAddress1 ('123 Test St ' );
40+ $ user ->setState ('CA ' );
41+ $ user ->setCity ('Testville ' );
42+ $ user ->setPostCode ('00000 ' );
43+ $ user ->setCountryIsoCode ('US ' );
44+ $ user ->setPic ('' );
45+ $ user ->setLastLoginDate (new \DateTime ('now ' , new \DateTimeZone ('UTC ' )));
46+ EntityManager::persist ($ user );
47+ EntityManager::flush ();
48+ $ this ->user = $ user ;
49+ }
50+
51+ public function tearDown (): void
52+ {
53+ if ($ this ->user !== null ) {
54+ $ managed = EntityManager::find (User::class, $ this ->user ->getId ());
55+ if ($ managed !== null ) {
56+ EntityManager::remove ($ managed );
57+ EntityManager::flush ();
58+ }
4159 }
60+ parent ::tearDown ();
4261 }
4362
4463 public function testTrustedDeviceRoundTrip (): void
@@ -136,6 +155,193 @@ public function testRecoveryCodeRoundTrip(): void
136155 $ this ->assertTrue ($ reload ->isUsed ());
137156
138157 $ deleted = $ repo ->deleteAllForUser ($ this ->user );
139- $ this ->assertGreaterThanOrEqual (1 , $ deleted );
158+ $ this ->assertEquals (1 , $ deleted );
159+ }
160+
161+ // -------------------------------------------------------------------------
162+ // Targeted behaviour tests
163+ // -------------------------------------------------------------------------
164+
165+ public function testExpiredTrustedDeviceIsExcluded (): void
166+ {
167+ $ repo = App::make (IUserTrustedDeviceRepository::class);
168+ $ now = new \DateTime ('now ' , new \DateTimeZone ('UTC ' ));
169+ $ expired = (clone $ now )->modify ('-1 minute ' );
170+ $ deviceId = hash ('sha256 ' , 'expired-device- ' . uniqid ());
171+
172+ $ device = $ this ->buildDevice ($ deviceId , $ now , $ expired );
173+ EntityManager::persist ($ device );
174+ EntityManager::flush ();
175+ $ id = $ device ->getId ();
176+ EntityManager::clear ();
177+
178+ $ this ->assertNull (
179+ $ repo ->getActiveByUserAndIdentifier ($ this ->user , $ deviceId ),
180+ 'getActiveByUserAndIdentifier must return null for an expired device. '
181+ );
182+
183+ $ ids = array_map (
184+ fn (UserTrustedDevice $ d ) => $ d ->getDeviceIdentifier (),
185+ $ repo ->getActiveByUser ($ this ->user )
186+ );
187+ $ this ->assertNotContains ($ deviceId , $ ids , 'getActiveByUser must not include expired devices. ' );
188+
189+ $ stale = EntityManager::find (UserTrustedDevice::class, $ id );
190+ if ($ stale ) { EntityManager::remove ($ stale ); EntityManager::flush (); }
191+ }
192+
193+ public function testRevokedTrustedDeviceIsExcluded (): void
194+ {
195+ $ repo = App::make (IUserTrustedDeviceRepository::class);
196+ $ now = new \DateTime ('now ' , new \DateTimeZone ('UTC ' ));
197+ $ expires = (clone $ now )->modify ('+30 days ' );
198+ $ deviceId = hash ('sha256 ' , 'revoked-device- ' . uniqid ());
199+
200+ $ device = $ this ->buildDevice ($ deviceId , $ now , $ expires );
201+ $ device ->setIsRevoked (true );
202+ EntityManager::persist ($ device );
203+ EntityManager::flush ();
204+ $ id = $ device ->getId ();
205+ EntityManager::clear ();
206+
207+ $ this ->assertNull (
208+ $ repo ->getActiveByUserAndIdentifier ($ this ->user , $ deviceId ),
209+ 'getActiveByUserAndIdentifier must return null for a revoked device. '
210+ );
211+
212+ $ ids = array_map (
213+ fn (UserTrustedDevice $ d ) => $ d ->getDeviceIdentifier (),
214+ $ repo ->getActiveByUser ($ this ->user )
215+ );
216+ $ this ->assertNotContains ($ deviceId , $ ids , 'getActiveByUser must not include revoked devices. ' );
217+
218+ $ stale = EntityManager::find (UserTrustedDevice::class, $ id );
219+ if ($ stale ) { EntityManager::remove ($ stale ); EntityManager::flush (); }
220+ }
221+
222+ public function testDuplicateDeviceIdentifierCannotOccur (): void
223+ {
224+ $ connection = EntityManager::getConnection ();
225+ $ indexes = $ connection ->createSchemaManager ()->listTableIndexes ('user_trusted_devices ' );
226+
227+ $ hasUnique = false ;
228+ foreach ($ indexes as $ index ) {
229+ if ($ index ->isUnique ()) {
230+ $ cols = $ index ->getColumns ();
231+ if (in_array ('user_id ' , $ cols ) && in_array ('device_identifier ' , $ cols )) {
232+ $ hasUnique = true ;
233+ break ;
234+ }
235+ }
236+ }
237+
238+ $ this ->assertTrue (
239+ $ hasUnique ,
240+ 'user_trusted_devices must have a UNIQUE index on (user_id, device_identifier). '
241+ );
242+ }
243+
244+ public function testRecoveryCodeDeletionRemovesUsedAndUnusedCodes (): void
245+ {
246+ $ repo = App::make (IUserRecoveryCodeRepository::class);
247+
248+ $ unused = new UserRecoveryCode ();
249+ $ unused ->setUser ($ this ->user );
250+ $ unused ->setCodeHash (password_hash ('UNUSED_ ' . uniqid (), PASSWORD_BCRYPT ));
251+
252+ $ used = new UserRecoveryCode ();
253+ $ used ->setUser ($ this ->user );
254+ $ used ->setCodeHash (password_hash ('USED_ ' . uniqid (), PASSWORD_BCRYPT ));
255+ $ used ->markUsed ();
256+
257+ EntityManager::persist ($ unused );
258+ EntityManager::persist ($ used );
259+ EntityManager::flush ();
260+ $ unusedId = $ unused ->getId ();
261+ $ usedId = $ used ->getId ();
262+
263+ $ deleted = $ repo ->deleteAllForUser ($ this ->user );
264+ $ this ->assertGreaterThanOrEqual (2 , $ deleted , 'deleteAllForUser must remove both used and unused codes. ' );
265+
266+ EntityManager::clear ();
267+ $ this ->assertNull (
268+ EntityManager::find (UserRecoveryCode::class, $ unusedId ),
269+ 'Unused recovery code must be deleted. '
270+ );
271+ $ this ->assertNull (
272+ EntityManager::find (UserRecoveryCode::class, $ usedId ),
273+ 'Used recovery code must also be deleted. '
274+ );
275+ }
276+
277+ public function testAuditLogsReturnedMostRecentFirst (): void
278+ {
279+ $ repo = App::make (ITwoFactorAuditLogRepository::class);
280+ $ createdIds = [];
281+
282+ $ timestamps = [
283+ new \DateTime ('2020-01-01 01:00:00 ' , new \DateTimeZone ('UTC ' )),
284+ new \DateTime ('2020-01-01 02:00:00 ' , new \DateTimeZone ('UTC ' )),
285+ new \DateTime ('2020-01-01 03:00:00 ' , new \DateTimeZone ('UTC ' )),
286+ ];
287+
288+ $ setCreatedAt = static function (TwoFactorAuditLog $ log , \DateTime $ dt ): void {
289+ $ prop = new \ReflectionProperty (TwoFactorAuditLog::class, 'created_at ' );
290+ $ prop ->setAccessible (true );
291+ $ prop ->setValue ($ log , $ dt );
292+ };
293+
294+ foreach ($ timestamps as $ ts ) {
295+ $ entry = new TwoFactorAuditLog ();
296+ $ entry ->setUser ($ this ->user );
297+ $ entry ->setEventType (TwoFactorAuditLog::EventChallengeIssued);
298+ $ entry ->setMethod (TwoFactorAuditLog::MethodEmailOtp);
299+ $ entry ->setIpAddress ('127.0.0.1 ' );
300+ $ entry ->setUserAgent ('Mozilla/5.0 (test) ' );
301+ $ setCreatedAt ($ entry , $ ts );
302+ EntityManager::persist ($ entry );
303+ EntityManager::flush ();
304+ $ createdIds [] = $ entry ->getId ();
305+ }
306+
307+ EntityManager::clear ();
308+
309+ $ all = $ repo ->getRecentByUser ($ this ->user , 200 );
310+ $ ours = array_values (array_filter ($ all , fn (TwoFactorAuditLog $ e ) => in_array ($ e ->getId (), $ createdIds )));
311+
312+ $ this ->assertCount (3 , $ ours , 'All three seeded audit entries must be returned. ' );
313+
314+ for ($ i = 0 ; $ i < count ($ ours ) - 1 ; $ i ++) {
315+ $ this ->assertGreaterThanOrEqual (
316+ $ ours [$ i + 1 ]->getCreatedAt ()->getTimestamp (),
317+ $ ours [$ i ]->getCreatedAt ()->getTimestamp (),
318+ 'Audit logs must be ordered most-recent first. '
319+ );
320+ }
321+
322+ // cleanup
323+ foreach ($ createdIds as $ logId ) {
324+ $ log = EntityManager::find (TwoFactorAuditLog::class, $ logId );
325+ if ($ log ) { EntityManager::remove ($ log ); }
326+ }
327+ EntityManager::flush ();
328+ }
329+
330+ // -------------------------------------------------------------------------
331+ // Helpers
332+ // -------------------------------------------------------------------------
333+
334+ private function buildDevice (string $ deviceId , \DateTime $ now , \DateTime $ expires ): UserTrustedDevice
335+ {
336+ $ device = new UserTrustedDevice ();
337+ $ device ->setUser ($ this ->user );
338+ $ device ->setDeviceIdentifier ($ deviceId );
339+ $ device ->setDeviceName ('Test Browser ' );
340+ $ device ->setIpAddress ('127.0.0.1 ' );
341+ $ device ->setUserAgent ('Mozilla/5.0 (test) ' );
342+ $ device ->setTrustedAt ($ now );
343+ $ device ->setExpiresAt ($ expires );
344+ $ device ->setLastSeenAt ($ now );
345+ return $ device ;
140346 }
141347}
0 commit comments