Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
31 changes: 29 additions & 2 deletions lib/Horde/Core/Factory/Identity.php
Original file line number Diff line number Diff line change
Expand Up @@ -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.');
}
}
Expand Down
239 changes: 239 additions & 0 deletions test/Unit/Factory/IdentityTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,239 @@
<?php

declare(strict_types=1);

/**
* Copyright 2026 Horde LLC (http://www.horde.org/)
*
* See the enclosed file LICENSE for license information (LGPL). If you
* did not receive this file, see http://www.horde.org/licenses/lgpl21.
*
* @category Horde
* @package Core
* @license http://www.horde.org/licenses/lgpl21 LGPL 2.1
*/

namespace Horde\Core\Test\Unit\Factory;

use Horde_Exception;
use Horde\Test\TestCase;
use Horde_Core_Factory_Identity;
use Horde_Core_Factory_Prefs;
use Horde_Injector;
use Horde_Prefs;
use Horde_Prefs_Storage_Null;
use Horde_Registry;
use IMP_Mailbox;
use PHPUnit\Framework\Attributes\CoversClass;

/**
* Unit tests for Horde_Core_Factory_Identity.
*
* The identity factory resolves application-specific identity drivers by
* constructing a class name from the driver (application) name, e.g.
* {@see Horde_String::ucfirst()}('imp') . '_Prefs_Identity'}. Applications
* whose PHP class prefix is an acronym (IMP → IMP_Prefs_Identity, not
* Imp_Prefs_Identity) require additional resolution logic. These tests
* guard that behaviour and the fileroot fallback used when the class has
* not been autoloaded yet.
*/
#[CoversClass(Horde_Core_Factory_Identity::class)]
class IdentityTest extends TestCase
{
private ?Horde_Injector $injector = null;

private $registry = null;

private ?Horde_Prefs $prefs = null;

/**
* Prepare a minimal Horde runtime for factory calls.
*
* Sets up mocked registry and injector dependencies, plus a real
* Horde_Prefs instance (null storage) in $GLOBALS['prefs'] so that
* Horde_Core_Factory_Identity::create() can instantiate identity
* objects without touching a database.
*/
protected function setUp(): void
{
parent::setUp();

$this->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,
'<?php class ' . $class . ' extends Horde_Core_Prefs_Identity { public function init() {} }'
);

try {
$this->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);
}
}
}
Loading