Ctrl K
ComunidadGuías…

tests/Repository/UserRepositoryTest.php

Rama develop · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
<?php

declare(strict_types=1);

/**
 * vim:set softtabstop=4 shiftwidth=4 expandtab:
 *
 * LICENSE: GNU Affero General Public License, version 3 (AGPL-3.0-or-later)
 * Copyright Ampache.org, 2001-2026
 *
 * This program is free software: you can redistribute it and/or modify
 * it under the terms of the GNU Affero General Public License as published by
 * the Free Software Foundation, either version 3 of the License, or
 * (at your option) any later version.
 *
 * This program is distributed in the hope that it will be useful,
 * but WITHOUT ANY WARRANTY; without even the implied warranty of
 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
 * GNU Affero General Public License for more details.
 *
 * You should have received a copy of the GNU Affero General Public License
 * along with this program.  If not, see <https://www.gnu.org/licenses/>.
 *
 */

namespace Ampache\Repository;

use Ampache\Module\Authorization\AccessLevelEnum;
use Ampache\Module\Database\DatabaseConnectionInterface;
use Ampache\Module\Database\Exception\QueryFailedException;
use Ampache\Repository\Model\User;
use Ampache\Repository\Model\UserFieldEnum;
use PDO;
use PDOStatement;
use PHPUnit\Framework\MockObject\MockObject;
use PHPUnit\Framework\TestCase;
use Psr\Log\LoggerInterface;
use SEEC\PhpUnit\Helper\ConsecutiveParams;

class UserRepositoryTest extends TestCase
{
    use ConsecutiveParams;

    private DatabaseConnectionInterface&MockObject $connection;
    private LoggerInterface&MockObject $logger;
    private UserRepository $subject;

    public function testCountByCatalogFilterGroupCountsTheAssignedUsers(): void
    {
        $this->connection->expects(static::once())
            ->method('fetchOne')
            ->with('SELECT COUNT(1) AS `count` FROM `user` WHERE `catalog_filter_group` = ?', [4])
            ->willReturn('3');

        self::assertSame(3, $this->subject->countByCatalogFilterGroup(4));
    }

    public function testCreateLeavesOmittedColumnsOutOfTheStatement(): void
    {
        // an absent optional column has to stay out of the INSERT so the schema default applies to it
        $this->connection->expects(static::once())
            ->method('query')
            ->with(
                'INSERT INTO `user` (`username`, `disabled`, `access`) VALUES(?, ?, ?)',
                ['some-user', 0, 25]
            );

        $this->connection->expects(static::once())
            ->method('getLastInsertedId')
            ->willReturn(666);

        self::assertSame(
            666,
            $this->subject->create(['username' => 'some-user', 'disabled' => 0, 'access' => 25])
        );
    }

    public function testCreateReturnsZeroWhenTheInsertFailed(): void
    {
        $this->connection->expects(static::once())
            ->method('query')
            ->willThrowException(new QueryFailedException('some-error'));

        self::assertSame(0, $this->subject->create(['username' => 'some-user']));
    }

    public function testDeleteAlsoDropsAccessRulesAndSessions(): void
    {
        $this->connection->expects(static::exactly(3))
            ->method('query')
            ->with(
                ...self::withConsecutive(
                    ['DELETE FROM `user` WHERE `id` = ?', [666]],
                    ['DELETE FROM `access_list` WHERE `user` = ?', [666]],
                    ['DELETE FROM `session` WHERE `username` = ?', ['some-user']],
                )
            );

        $this->subject->delete(666, 'some-user');
    }

    public function testFindActiveSessionIpMatchesAPerpetualApiSessionOnItsType(): void
    {
        // a perpetual api session has expire = 0, so an expiry comparison alone would never find it
        $this->connection->expects(static::once())
            ->method('fetchOne')
            ->with(
                "SELECT `ip` FROM `session` WHERE `username` = ? AND ((`expire` = 0 AND `type` = 'api') OR `expire` > ?);",
                ['some-user', 123456]
            )
            ->willReturn('10.0.0.1');

        self::assertSame('10.0.0.1', $this->subject->findActiveSessionIp('some-user', 123456, true));
    }

    public function testFindActiveSessionIpReturnsNullWhenNotLoggedIn(): void
    {
        $this->connection->method('fetchOne')->willReturn(false);

        self::assertNull($this->subject->findActiveSessionIp('some-user', 123456, false));
    }

    public function testFindByApiKeyFallsBackToTheSessionAndThenTheHashedKeys(): void
    {
        $result = $this->createMock(PDOStatement::class);

        // no plain key, no api session, and no user whose hashed key matches
        $this->connection->expects(static::exactly(2))
            ->method('fetchOne')
            ->willReturn(false);

        $this->connection->expects(static::once())
            ->method('query')
            ->with("SELECT `id`, `apikey`, `username` FROM `user` WHERE `apikey` IS NOT NULL AND `apikey` != '' AND `username` != ''")
            ->willReturn($result);

        $result->expects(static::exactly(2))
            ->method('fetch')
            ->with(PDO::FETCH_ASSOC)
            ->willReturn(['id' => '1', 'apikey' => 'some-key', 'username' => 'some-user'], false);

        self::assertNull($this->subject->findByApiKey('some-api-key'));
    }

    public function testFindByApiKeyResolvesTheCallerOncePerRequest(): void
    {
        // the row itself is cached too, so the second lookup never reaches the database
        User::add_to_cache('user', 42, $this->userRow(42, 'some-user'));

        $this->connection->expects(static::once())
            ->method('fetchOne')
            ->with('SELECT `id` FROM `user` WHERE `apikey` = ?', ['some-key'])
            ->willReturn('42');

        self::assertSame(42, $this->subject->findByApiKey('some-key')?->getId());
        self::assertSame(42, $this->subject->findByApiKey('some-key')?->getId());

        User::clear_cache();
    }

    public function testFindByApiSessionTokenPinsTheSessionType(): void
    {
        // `Session::exists()` answers for `stream` rows as well, so this lookup is what refuses them
        $this->connection->expects(static::once())
            ->method('fetchOne')
            ->with(
                "SELECT `username` FROM `session` WHERE `id` = ? AND `expire` > ? AND `type` = 'api'",
                static::callback(static fn(array $params): bool => $params[0] === 'some-token')
            )
            ->willReturn(false);

        self::assertNull($this->subject->findByApiSessionToken('some-token'));
    }

    public function testFindByUsernameResolvesTheNameOncePerRequest(): void
    {
        User::add_to_cache('user', 42, $this->userRow(42, 'some-user'));

        $this->connection->expects(static::once())
            ->method('fetchOne')
            ->with('SELECT `id` FROM `user` WHERE `username` = ?', ['some-user'])
            ->willReturn('42');

        self::assertSame(42, $this->subject->findByUsername('some-user')?->getId());
        self::assertSame(42, $this->subject->findByUsername('some-user')?->getId());

        User::clear_cache();
    }

    public function testGetPreferenceRowsNeverOffersTheInternalBookkeeping(): void
    {
        // `lastfm_challenge`, `autoupdate_*` and friends are written by their own owner and must never
        // reach a settings screen, for any subject: an admin editing someone else included
        $queries = [];
        $this->connection->method('query')->willReturnCallback(
            function (string $sql) use (&$queries): PDOStatement {
                $queries[] = $sql;

                $result = $this->createMock(PDOStatement::class);
                $result->method('fetch')->willReturn(false);

                return $result;
            }
        );

        $this->subject->getPreferenceRows(42, null, true);
        $this->subject->getPreferenceRows(User::INTERNAL_SYSTEM_USER_ID, null, false);
        $this->subject->getPreferenceRows(42, 'interface', true);

        self::assertCount(3, $queries);
        foreach ($queries as $sql) {
            self::assertStringContainsString("`preference`.`category` != 'internal'", $sql, $sql);
        }
    }

    public function testGetRowsByIdsCastsTheIdsIntoTheStatement(): void
    {
        $result = $this->createMock(PDOStatement::class);

        $this->connection->expects(static::once())
            ->method('query')
            ->with(self::stringContains('WHERE `id` IN (1,0,3)'))
            ->willReturn($result);

        $result->expects(static::exactly(2))
            ->method('fetch')
            ->with(PDO::FETCH_ASSOC)
            ->willReturn(['id' => '1', 'username' => 'foo'], false);

        self::assertSame(
            [['id' => '1', 'username' => 'foo']],
            $this->subject->getRowsByIds([1, 'x', 3])
        );
    }

    public function testGetRowsByIdsReturnsNothingForAnEmptyList(): void
    {
        $this->connection->expects(static::never())
            ->method('query');

        self::assertSame([], $this->subject->getRowsByIds([]));
    }

    public function testGetValidationByUsernameReturnsNullForAClearedValidation(): void
    {
        $this->connection->expects(static::once())
            ->method('fetchOne')
            ->with(
                'SELECT `validation` FROM `user` WHERE `username` = ?',
                ['some-user']
            )
            ->willReturn(null);

        self::assertNull($this->subject->getValidationByUsername('some-user'));
    }

    public function testHasOtherAdminIgnoresDisabledAccountsWhenAsked(): void
    {
        $this->connection->expects(static::once())
            ->method('fetchOne')
            ->with(
                "SELECT `id` FROM `user` WHERE `disabled` = '0' AND `access` = ? AND `id` != ? ",
                [AccessLevelEnum::ADMIN->value, 666]
            )
            ->willReturn('42');

        self::assertTrue($this->subject->hasOtherAdmin(666, true));
    }

    public function testHasOtherAdminReturnsFalseWhenThisIsTheLastOne(): void
    {
        $this->connection->method('fetchOne')->willReturn(false);

        self::assertFalse($this->subject->hasOtherAdmin(666, false));
    }

    public function testIdByUsernameReturnsZeroForAnUnknownName(): void
    {
        $this->connection->expects(static::once())
            ->method('fetchOne')
            ->with(
                'SELECT `id` FROM `user` WHERE `username` = ?',
                ['some-user']
            )
            ->willReturn(false);

        self::assertSame(0, $this->subject->idByUsername('some-user'));
    }

    public function testResetCatalogFilterGroupPutsThemBackOnDefault(): void
    {
        $this->connection->expects(static::once())
            ->method('query')
            ->with('UPDATE `user` SET `catalog_filter_group` = 0 WHERE `catalog_filter_group` = ?', [4]);

        $this->subject->resetCatalogFilterGroup(4);
    }

    public function testResetMissingCatalogFilterGroupsSweepsEveryDanglingUser(): void
    {
        $this->connection->expects(static::once())
            ->method('query')
            ->with('UPDATE `user` SET `catalog_filter_group` = 0 WHERE `catalog_filter_group` NOT IN (SELECT `id` FROM `catalog_filter_group`);');

        $this->subject->resetMissingCatalogFilterGroups();
    }

    public function testRetrievePasswordFromUserReturnsAnEmptyStringForAnUnknownUser(): void
    {
        $this->connection->expects(static::once())
            ->method('fetchOne')
            ->with(
                'SELECT `password` FROM `user` WHERE `id` = ?',
                [666]
            )
            ->willReturn(false);

        self::assertSame('', $this->subject->retrievePasswordFromUser(666));
    }

    public function testSetFieldClearsATokenWithNull(): void
    {
        $this->connection->expects(static::once())
            ->method('query')
            ->with('UPDATE `user` SET `apikey` = ? WHERE `id` = ?', [null, 666]);

        self::assertTrue($this->subject->setField(666, UserFieldEnum::APIKEY, null));
    }

    public function testSetFieldWritesTheColumnFromTheEnum(): void
    {
        $this->connection->expects(static::once())
            ->method('query')
            ->with('UPDATE `user` SET `email` = ? WHERE `id` = ?', ['some@example.org', 666]);

        self::assertTrue($this->subject->setField(666, UserFieldEnum::EMAIL, 'some@example.org'));
    }

    public function testSetUserDataForAllWritesEveryUserInOneStatement(): void
    {
        // a server-wide counter holds the same value for everyone, so it must not run one statement per user
        $this->connection->expects(static::once())
            ->method('query')
            ->with(
                'REPLACE INTO `user_data` (`user`, `key`, `value`) SELECT `id`, ?, ? FROM `user`;',
                ['song', 42]
            );

        $this->subject->setUserDataForAll('song', 42);
    }

    public function testSetUserDataReplacesTheStoredCounter(): void
    {
        $this->connection->expects(static::once())
            ->method('query')
            ->with('REPLACE INTO `user_data` SET `user` = ?, `key` = ?, `value` = ?;', [666, 'song', 42]);

        $this->subject->setUserData(666, 'song', 42);
    }

    public function testSetValidationAlsoDisablesTheAccount(): void
    {
        $this->connection->expects(static::once())
            ->method('query')
            ->with("UPDATE `user` SET `validation` = ?, `disabled`='1' WHERE `id` = ?", ['some-key', 666]);

        self::assertTrue($this->subject->setValidation(666, 'some-key'));
    }

    public function testUpdateLastSeenSkipsTheAnonymousUser(): void
    {
        // the anonymous user is a php object with no row behind it, so the statement would match nothing
        $this->connection->expects(static::never())
            ->method('query');

        $this->subject->updateLastSeen(-1);
        $this->subject->updateLastSeen(0);
    }

    public function testUpdateLastSeenStampsTheRow(): void
    {
        $this->connection->expects(static::once())
            ->method('query')
            ->with(
                'UPDATE `user` SET `last_seen` = ? WHERE `id` = ?',
                self::callback(static fn(array $params): bool => is_int($params[0]) && $params[1] === 666)
            );

        $this->subject->updateLastSeen(666);
    }

    protected function setUp(): void
    {
        $this->connection = $this->createMock(DatabaseConnectionInterface::class);
        $this->logger     = $this->createMock(LoggerInterface::class);

        $this->subject = new UserRepository(
            $this->connection,
            $this->logger,
        );
    }

    /**
     * A whole user row, the way the constructor expects it out of the cache
     *
     * @return array<string, mixed>
     */
    private function userRow(int $id, string $username): array
    {
        return [
            'id' => $id,
            'username' => $username,
            'fullname' => $username,
            'email' => '',
            'website' => '',
            'apikey' => '',
            'access' => 25,
            'disabled' => 0,
            'last_seen' => 0,
            'create_date' => 0,
            'validation' => '',
            'state' => '',
            'city' => '',
            'fullname_public' => 0,
            'rsstoken' => '',
            'streamtoken' => '',
            'subsonic_secret' => '',
            'catalog_filter_group' => 0,
        ];
    }
}