diff --git a/lib/Horde/Core/Factory/Identity.php b/lib/Horde/Core/Factory/Identity.php index 77722e86..acf7501f 100644 --- a/lib/Horde/Core/Factory/Identity.php +++ b/lib/Horde/Core/Factory/Identity.php @@ -57,8 +57,35 @@ public function create($user = null, $driver = null) default: if (!is_null($driver)) { - $class = Horde_String::ucfirst($driver) . '_Prefs_Identity'; - if (!class_exists($class)) { + $class = null; + foreach ([ + Horde_String::ucfirst($driver) . '_Prefs_Identity', + strtoupper($driver) . '_Prefs_Identity', + ] as $candidate) { + if (class_exists($candidate)) { + $class = $candidate; + break; + } + } + if (is_null($class)) { + $fileroot = $registry->get('fileroot', $driver); + $file = $fileroot + ? $fileroot . '/lib/Prefs/Identity.php' + : null; + if ($file && is_readable($file)) { + require_once $file; + foreach ([ + Horde_String::ucfirst($driver) . '_Prefs_Identity', + strtoupper($driver) . '_Prefs_Identity', + ] as $candidate) { + if (class_exists($candidate, false)) { + $class = $candidate; + break; + } + } + } + } + if (is_null($class)) { throw new Horde_Exception($driver . ' identity driver does not exist.'); } } diff --git a/test/Unit/Factory/IdentityTest.php b/test/Unit/Factory/IdentityTest.php new file mode 100644 index 00000000..43c32948 --- /dev/null +++ b/test/Unit/Factory/IdentityTest.php @@ -0,0 +1,239 @@ +registry = $this->getMockSkipConstructor(Horde_Registry::class); + $this->registry->method('getAuth')->willReturn('user@example.com'); + $this->registry->method('getApp')->willReturn('horde'); + + $prefsFactory = $this->getMockSkipConstructor(Horde_Core_Factory_Prefs::class); + $prefsFactory->method('create')->willReturnCallback( + function ($app) { + return $this->createPrefs($app); + } + ); + + $this->injector = $this->getMockSkipConstructor(Horde_Injector::class); + $this->injector->method('getInstance')->willReturnCallback( + function ($class) use ($prefsFactory) { + return match ($class) { + 'Horde_Core_Factory_Prefs' => $prefsFactory, + 'Horde_Registry' => $this->registry, + default => throw new Horde_Exception('Unexpected getInstance: ' . $class), + }; + } + ); + + $this->prefs = $this->createPrefs('horde'); + $GLOBALS['prefs'] = $this->prefs; + $GLOBALS['registry'] = $this->registry; + } + + protected function tearDown(): void + { + unset($GLOBALS['prefs'], $GLOBALS['registry']); + + parent::tearDown(); + } + + /** + * Build a Horde_Prefs object with values sufficient for identity init(). + * + * IMP_Prefs_Identity::verify() reads IMP-specific preference keys during + * init(); those are pre-populated here when horde/imp is available. + * + * @param string $app Application scope for the prefs object. + * + * @return Horde_Prefs Prefs usable by Horde_Core_Prefs_Identity subclasses. + */ + private function createPrefs(string $app): Horde_Prefs + { + $prefs = new Horde_Prefs($app, new Horde_Prefs_Storage_Null('user@example.com')); + $prefs->retrieve(); + $prefs->changeScope($app); + $scope = $prefs->getScopeObject(); + $scope->set('from_addr', 'user@example.com'); + $scope->set('fullname', 'Test User'); + $scope->set('identities', ''); + $scope->set('default_identity', 0); + $scope->set('id', 'Default Identity'); + $scope->set('replyto_addr', ''); + $scope->set('alias_addr', ''); + $scope->set('tieto_addr', ''); + $scope->set('bcc_addr', ''); + $scope->set('signature', ''); + $scope->set('signature_html', ''); + $scope->set('save_sent_mail', false); + + if (class_exists('IMP_Mailbox')) { + $scope->set(IMP_Mailbox::MBOX_SENT, 'Sent'); + } + + return $prefs; + } + + private function createFactory(): Horde_Core_Factory_Identity + { + return new Horde_Core_Factory_Identity($this->injector); + } + + /** + * The imp driver must resolve to IMP_Prefs_Identity, not Imp_Prefs_Identity. + * + * Background: Horde_Core_Factory_Identity used to build only + * Horde_String::ucfirst('imp') . '_Prefs_Identity' → Imp_Prefs_Identity. + * The real IMP class is IMP_Prefs_Identity (all-caps acronym prefix). + * On a cold request, class_exists('Imp_Prefs_Identity') is false and the + * factory threw "{driver} identity driver does not exist", which broke + * IMP_Identity injection (e.g. when opening Ingo from smart mobile view). + * + * This test calls create(null, 'imp') end-to-end and asserts the returned + * object is an IMP_Prefs_Identity instance, proving the strtoupper() + * candidate in the factory resolves the correct class. + * + * Skipped when horde/imp is not installed (optional dependency). + */ + public function testCreateImpDriverReturnsImpPrefsIdentity(): void + { + if (!class_exists('IMP_Prefs_Identity')) { + $this->markTestSkipped('horde/imp is not installed'); + } + + $identity = $this->createFactory()->create(null, 'imp'); + + $this->assertInstanceOf('IMP_Prefs_Identity', $identity); + } + + /** + * An unknown driver must fail with a clear Horde_Exception. + * + * After trying ucfirst/strtoupper class names and an optional fileroot + * fallback, the factory must still reject drivers that have no identity + * class and no lib/Prefs/Identity.php on disk. This ensures callers + * (e.g. Horde_Core_Prefs_Ui) receive the same error message as before + * rather than a generic class-instantiation failure later. + * + * Registry::get('fileroot', 'nonexistent_app') returns null so the + * fileroot fallback path is also exercised and correctly skipped. + */ + public function testCreateUnknownDriverThrows(): void + { + $this->expectException(Horde_Exception::class); + $this->expectExceptionMessage('nonexistent_app identity driver does not exist'); + + $this->registry->method('get')->willReturnMap([ + ['fileroot', 'nonexistent_app', null], + ]); + + $this->createFactory()->create(null, 'nonexistent_app'); + } + + /** + * Identity classes must be loadable from the application fileroot fallback. + * + * When neither Horde_String::ucfirst($driver) nor strtoupper($driver) + * finds an already-autoloaded class, the factory loads + * {fileroot}/lib/Prefs/Identity.php via require_once and re-checks both + * naming conventions. This mirrors production behaviour when IMP (or + * another app) has been registered in the registry but its libraries + * have not yet been touched by the autoloader. + * + * Uses a synthetic driver name (impfileroottest) and a temporary fileroot + * tree so the test does not depend on horde/imp and cannot conflict with + * IMP_Prefs_Identity. The stub class IMPFILEROOTTEST_Prefs_Identity is + * created on disk; the factory must require that file and instantiate it. + * + * Skipped if the synthetic class is already defined (e.g. repeated run + * in the same PHP process without process isolation). + */ + public function testCreateLoadsIdentityClassFromApplicationFileroot(): void + { + $driver = 'impfileroottest'; + $class = 'IMPFILEROOTTEST_Prefs_Identity'; + + if (class_exists($class, false)) { + $this->markTestSkipped($class . ' is already defined'); + } + + $fileroot = sys_get_temp_dir() . '/horde-identity-test-' . getmypid(); + $identityDir = $fileroot . '/lib/Prefs'; + mkdir($identityDir, 0777, true); + + $file = $identityDir . '/Identity.php'; + file_put_contents( + $file, + 'registry->method('get')->willReturnMap([ + ['fileroot', $driver, $fileroot], + ]); + + $identity = $this->createFactory()->create(null, $driver); + + $this->assertInstanceOf($class, $identity); + } finally { + @unlink($file); + @rmdir($identityDir); + @rmdir($fileroot . '/lib'); + @rmdir($fileroot); + } + } +}