setConfig($this->makeConfig()); $manager->setLogger(new NullLogger()); return $manager; } public function test_generate_returns_requested_count(): void { $manager = $this->makeManager(); $codes = $manager->generate(5); self::assertCount(5, $codes); foreach ($codes as $code) { self::assertIsString($code); // codes are lowercase alphanumeric self::assertMatchesRegularExpression('/^[a-z0-9]+$/', $code); } } public function test_generate_default_count(): void { $manager = $this->makeManager(); $codes = $manager->generate(); self::assertCount(10, $codes); } public function test_generate_zero_returns_empty_array(): void { $manager = $this->makeManager(); $codes = $manager->generate(0); self::assertSame([], $codes); } public function test_generated_codes_are_stored_in_cache(): void { $pool = new ArrayAdapter(); $manager = $this->makeManager($pool); $codes = $manager->generate(3); // each code should be stored as a backup_ key foreach ($codes as $code) { $key = 'backup_'.strtolower($code); // the manager uses makeCacheKey which sanitizes, but for alphanumeric it's identity $item = $pool->getItem($key); self::assertTrue($item->isHit(), "Expected cache hit for key: $key"); self::assertTrue($item->get(), 'Expected code to be marked valid (true)'); } } public function test_generated_codes_have_far_future_expiry(): void { $pool = new ArrayAdapter(); $manager = $this->makeManager($pool); $codes = $manager->generate(1); $code = $codes[0]; $item = $pool->getItem('backup_'.strtolower($code)); $expiry = $item->getMetadata()['expiry']; self::assertGreaterThan((new DateTimeImmutable('+10 years'))->getTimestamp(), (int) $expiry); } public function test_verify_and_consume_valid_code(): void { $manager = $this->makeManager(); $codes = $manager->generate(2); $code = $codes[0]; self::assertTrue($manager->verifyAndConsume($code)); } public function test_verify_and_consume_marks_code_as_used(): void { $pool = new ArrayAdapter(); $manager = $this->makeManager($pool); $codes = $manager->generate(1); $code = $codes[0]; // first use succeeds self::assertTrue($manager->verifyAndConsume($code)); // second use fails (already consumed) self::assertFalse($manager->verifyAndConsume($code)); } public function test_verify_and_consume_invalid_code(): void { $manager = $this->makeManager(); self::assertFalse($manager->verifyAndConsume('nonexistent_code')); } public function test_verify_and_consume_is_case_insensitive(): void { $manager = $this->makeManager(); $codes = $manager->generate(1); $code = $codes[0]; // uppercase version should still work self::assertTrue($manager->verifyAndConsume(strtoupper($code))); } public function test_verify_and_consume_strips_invalid_characters(): void { $manager = $this->makeManager(); $codes = $manager->generate(1); $code = $codes[0]; // inject spaces and special chars — should be stripped self::assertTrue($manager->verifyAndConsume(' '.$code.'!!')); } public function test_expire_removes_all_backup_codes(): void { $pool = new ArrayAdapter(); $manager = $this->makeManager($pool); $codes = $manager->generate(5); $manager->expire(); // all backup keys should be gone foreach ($codes as $code) { self::assertFalse($pool->hasItem('backup_'.strtolower($code))); } } public function test_expire_when_no_backup_codes_is_noop(): void { $pool = new ArrayAdapter(); $manager = $this->makeManager($pool); // should not throw $manager->expire(); // this passes if no exception was thrown self::assertTrue(true); } public function test_expire_removes_only_backup_prefixed_keys(): void { $pool = new ArrayAdapter(); $manager = $this->makeManager($pool); $codes = $manager->generate(3); // add a non-backup key $item = $pool->getItem('cookie_session'); $item->set('data'); $pool->save($item); $manager->expire(); // non-backup key survives self::assertTrue($pool->hasItem('cookie_session')); // backup keys are gone foreach ($codes as $code) { self::assertFalse($pool->hasItem('backup_'.strtolower($code))); } } public function test_verify_and_consume_empty_string_returns_false(): void { $manager = $this->makeManager(); // empty string after preg_replace becomes 'backup_' with nothing after it self::assertFalse($manager->verifyAndConsume('')); } public function test_verify_and_consume_code_with_value_false_returns_false(): void { $pool = new ArrayAdapter(); $manager = $this->makeManager($pool); $codes = $manager->generate(1); $code = $codes[0]; // first use succeeds self::assertTrue($manager->verifyAndConsume($code)); // the code is now marked as false (used); isHit is true but get() is false $key = 'backup_'.strtolower($code); $item = $pool->getItem($key); self::assertTrue($item->isHit()); self::assertFalse($item->get()); // second use should fail because get() returns false self::assertFalse($manager->verifyAndConsume($code)); } public function test_generate_produces_unique_codes(): void { $manager = $this->makeManager(); $codes = $manager->generate(50); self::assertCount(50, $codes); self::assertCount(50, array_unique($codes), 'All generated codes should be unique'); } public function test_generate_code_length_is_digits_plus_two(): void { $manager = $this->makeManager(); $codes = $manager->generate(1); // default TOTP digits is 6, so code length should be 6 + 2 = 8 self::assertSame(8, \strlen($codes[0])); } }