diff --git a/config/vufind/config.ini b/config/vufind/config.ini index 10f01c0ccca4264d990a5236cff06d680ec99268..8a793c197b7aaa0d7f0e7de2963caf264a83c802 100644 --- a/config/vufind/config.ini +++ b/config/vufind/config.ini @@ -601,6 +601,12 @@ sms = enabled ; store its path in the database (default "none"). url_shortener = none +; Which method to use for generating the short link key. Options: +; - base62: Base62-encode the database row ID (insecure, but makes very short URLs) +; - md5: Create a salted MD5 hash of the URL (DEFAULT: more private, but also longer) +; ...or any hash algorithm supported by PHP's hash() function. +url_shortener_key_type = md5 + ; Which redirect mechanism to use when shortlinks are resolved. ; threshold:1000 (default) : If the URL is shorter than the given size, HTTP is used, else HTML. ; html : HTML meta redirect after 3 seconds with infobox. diff --git a/module/VuFind/sql/migrations/pgsql/7.0/001-modify-shortlinks.sql b/module/VuFind/sql/migrations/pgsql/7.0/001-modify-shortlinks.sql new file mode 100644 index 0000000000000000000000000000000000000000..79b6b187f0a90040255872a4a9cd2af0f8badfdb --- /dev/null +++ b/module/VuFind/sql/migrations/pgsql/7.0/001-modify-shortlinks.sql @@ -0,0 +1,3 @@ +ALTER TABLE "shortlinks" + ADD COLUMN hash varchar(32); +CREATE UNIQUE INDEX shortlinks_hash_idx ON shortlinks (hash); \ No newline at end of file diff --git a/module/VuFind/sql/mysql.sql b/module/VuFind/sql/mysql.sql index d4e1340ae7be69e65246a1f6b1cc25e4ab99b1c3..5f3ff8cb8c0780ba379cc3306bf4584a929cafab 100644 --- a/module/VuFind/sql/mysql.sql +++ b/module/VuFind/sql/mysql.sql @@ -177,8 +177,10 @@ CREATE TABLE `external_session` ( CREATE TABLE `shortlinks` ( `id` int(11) NOT NULL AUTO_INCREMENT, `path` mediumtext NOT NULL, + `hash` varchar(32), `created` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP, - PRIMARY KEY (`id`) + PRIMARY KEY (`id`), + UNIQUE KEY `shortlinks_hash_IDX` USING HASH (`hash`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8 COLLATE utf8_bin; /*!40101 SET character_set_client = @saved_cs_client */; diff --git a/module/VuFind/sql/pgsql.sql b/module/VuFind/sql/pgsql.sql index 39abd1cbc298764d4e2d35c13e6a6ecd528f37b7..e2c222781419537929642663808830f916dabad8 100644 --- a/module/VuFind/sql/pgsql.sql +++ b/module/VuFind/sql/pgsql.sql @@ -100,10 +100,11 @@ DROP TABLE IF EXISTS "shortlinks"; CREATE TABLE shortlinks ( id SERIAL, path text, +hash varchar(32), created timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (id) ); - +CREATE UNIQUE INDEX shortlinks_hash_idx ON shortlinks (hash); -- -------------------------------------------------------- diff --git a/module/VuFind/src/VuFind/Controller/UpgradeController.php b/module/VuFind/src/VuFind/Controller/UpgradeController.php index 4faa73de038bb2201caaf8047b6ce37c3beb01d1..22b47e5843380f6b6707d1b76f917698ac67a294 100644 --- a/module/VuFind/src/VuFind/Controller/UpgradeController.php +++ b/module/VuFind/src/VuFind/Controller/UpgradeController.php @@ -38,6 +38,7 @@ use VuFind\Config\Version; use VuFind\Config\Writer; use VuFind\Cookie\Container as CookieContainer; use VuFind\Cookie\CookieManager; +use VuFind\Crypt\Base62; use VuFind\Date\Converter; use VuFind\Db\AdapterFactory; use VuFind\Exception\RecordMissing as RecordMissingException; @@ -551,6 +552,9 @@ class UpgradeController extends AbstractBase return $this->redirect()->toRoute('upgrade-fixduplicatetags'); } + // fix shortlinks + $this->fixshortlinks(); + // Clean up the "VuFind" source, if necessary. $this->fixVuFindSourceInDatabase(); } catch (Exception $e) { @@ -924,4 +928,39 @@ class UpgradeController extends AbstractBase = new ArrayObject([], ArrayObject::ARRAY_AS_PROPS); return $this->forwardTo('Upgrade', 'Home'); } + + /** + * Generate base62 encoding to migrate old shortlinks + * + * @throws Exception + * + * @return void + */ + protected function fixshortlinks() + { + $shortlinksTable = $this->getTable('shortlinks'); + $base62 = new Base62(); + + try { + $results = $shortlinksTable->select(['hash' => null]); + + foreach ($results as $result) { + $id = $result['id']; + $shortlinksTable->update( + ['hash' => $base62->encode($id)], + ['id' => $id] + ); + } + + $this->session->warnings->append( + 'Added hash value(s) to ' . count($results) . ' short links.' + ); + } catch (Exception $e) { + $this->session->warnings->append( + 'Could not fix hashes in table shortlinks - maybe column ' . + 'hash is missing? Exception thrown with ' . + 'message: ' . $e->getMessage() + ); + } + } } diff --git a/module/VuFind/src/VuFind/Crypt/Base62.php b/module/VuFind/src/VuFind/Crypt/Base62.php new file mode 100644 index 0000000000000000000000000000000000000000..bdf3ce04904036c50945a7aab72c90252d3973b4 --- /dev/null +++ b/module/VuFind/src/VuFind/Crypt/Base62.php @@ -0,0 +1,103 @@ +<?php +/** + * Base62 generator + * + * Class to encode and decode numbers using base62 + * + * PHP version 7 + * + * Copyright (C) Villanova University 2020. + * + * This program is free software; you can redistribute it and/or modify + * it under the terms of the GNU General Public License version 2, + * as published by the Free Software Foundation. + * + * 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 General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program; if not, write to the Free Software + * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA + * + * @category VuFind + * @package VuFind\Crypt + * @author Cornelius Amzar <cornelius.amzar@bsz-bw.de> + * @license http://opensource.org/licenses/gpl-2.0.php GNU General Public License + * @link https://vufind.org/wiki/development Wiki + */ +namespace VuFind\Crypt; + +use Exception; + +/** + * Base62 generator + * + * Class to encode and decode numbers using base62 + * + * @category VuFind + * @package Crypt + * @author Cornelius Amzar <cornelius.amzar@bsz-bw.de> + * @license http://opensource.org/licenses/gpl-2.0.php GNU General Public License + * @link https://vufind.org/wiki/development Wiki + */ +class Base62 +{ + const BASE62_ALPHABET + = '0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz'; + const BASE62_BASE = 62; + + /** + * Common base62 encoding function. + * Implemented here so we don't need additional PHP modules like bcmath. + * + * @param string $base10Number Number to encode + * + * @return string + * + * @throws Exception + */ + public function encode($base10Number) + { + $binaryNumber = intval($base10Number); + if ($binaryNumber === 0) { + throw new Exception('not a base10 number: "' . $base10Number . '"'); + } + + $base62Number = ''; + while ($binaryNumber != 0) { + $base62Number = self::BASE62_ALPHABET[$binaryNumber % self::BASE62_BASE] + . $base62Number; + $binaryNumber = intdiv($binaryNumber, self::BASE62_BASE); + } + + return ($base62Number == '') ? '0' : $base62Number; + } + + /** + * Common base62 decoding function. + * Implemented here so we don't need additional PHP modules like bcmath. + * + * @param string $base62Number Number to decode + * + * @return int + * + * @throws Exception + */ + public function decode($base62Number) + { + $binaryNumber = 0; + for ($i = 0; $i < strlen($base62Number); ++$i) { + $digit = $base62Number[$i]; + $strpos = strpos(self::BASE62_ALPHABET, $digit); + if ($strpos === false) { + throw new Exception('not a base62 digit: "' . $digit . '"'); + } + + $binaryNumber *= self::BASE62_BASE; + $binaryNumber += $strpos; + } + return $binaryNumber; + } +} diff --git a/module/VuFind/src/VuFind/UrlShortener/Database.php b/module/VuFind/src/VuFind/UrlShortener/Database.php index 81c09a93eadcbd86a9b57e00c57b3fde422a98e7..388d9b487204ee96379435e37f2c810d3b3eebd0 100644 --- a/module/VuFind/src/VuFind/UrlShortener/Database.php +++ b/module/VuFind/src/VuFind/UrlShortener/Database.php @@ -27,6 +27,7 @@ */ namespace VuFind\UrlShortener; +use Exception; use VuFind\Db\Table\Shortlinks as ShortlinksTable; /** @@ -35,14 +36,18 @@ use VuFind\Db\Table\Shortlinks as ShortlinksTable; * @category VuFind * @package UrlShortener * @author Demian Katz <demian.katz@villanova.edu> + * @author Cornelius Amzar <cornelius.amzar@bsz-bw.de> * @license http://opensource.org/licenses/gpl-2.0.php GNU General Public License * @link https://vufind.org/wiki/development Wiki */ class Database implements UrlShortenerInterface { - const BASE62_ALPHABET - = '0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz'; - const BASE62_BASE = 62; + /** + * Hash algorithm to use + * + * @var string + */ + protected $hashAlgorithm; /** * Base URL of current VuFind site @@ -58,69 +63,149 @@ class Database implements UrlShortenerInterface */ protected $table; + /** + * HMacKey from config + * + * @var string + */ + protected $salt; + + /** + * When using a hash algorithm other than base62, the preferred number of + * characters to use from the hash in the URL (more may be used for + * disambiguation when necessary). + * + * @var int + */ + protected $preferredHashLength = 9; + + /** + * The maximum allowed hash length (tied to the width of the database hash + * column); if we can't generate a unique hash under this length, something + * has gone very wrong. + * + * @var int + */ + protected $maxHashLength = 32; + /** * Constructor * - * @param string $baseUrl Base URL of current VuFind site - * @param ShortlinksTable $table Shortlinks database table + * @param string $baseUrl Base URL of current VuFind site + * @param ShortlinksTable $table Shortlinks database table + * @param string $salt HMacKey from config + * @param string $hashAlgorithm Hash algorithm to use */ - public function __construct(string $baseUrl, ShortlinksTable $table) - { + public function __construct( + string $baseUrl, + ShortlinksTable $table, + string $salt, + string $hashAlgorithm = 'md5' + ) { $this->baseUrl = $baseUrl; $this->table = $table; + $this->salt = $salt; + $this->hashAlgorithm = $hashAlgorithm; } /** - * Common base62 encoding function. - * Implemented here so we don't need additional PHP modules like bcmath. + * Generate a short hash using the base62 algorithm (and write a row to the + * database). * - * @param string $base10Number Number to encode + * @param string $path Path to store in database * * @return string + */ + protected function getBase62Hash(string $path): string + { + $this->table->insert(['path' => $path]); + $id = $this->table->getLastInsertValue(); + $row = $this->table->select(['id' => $id])->current(); + $b62 = new \VuFind\Crypt\Base62(); + $row->hash = $b62->encode($id); + $row->save(); + return $row->hash; + } + + /** + * Support method for getGenericHash(): do the work of picking a short version + * of the hash and writing to the database as needed. + * + * @param string $path Path to store in database + * @param string $hash Hash of $path (generated in getGenericHash) + * @param int $length Minimum number of characters from hash to use for + * lookups (may be increased to enforce uniqueness) * - * @throws \Exception + * @return string */ - protected function base62Encode($base10Number) + protected function saveAndShortenHash($path, $hash, $length) { - $binaryNumber = intval($base10Number); - if ($binaryNumber === 0) { - throw new \Exception('not a base10 number: "' . $base10Number . '"'); + // Validate hash length: + if ($length > $this->maxHashLength) { + throw new \Exception( + 'Could not generate unique hash under ' . $this->maxHashLength + . ' characters in length.' + ); } + $shorthash = str_pad(substr($hash, 0, $length), $length, '_'); + $results = $this->table->select(['hash' => $shorthash]); - $base62Number = ''; - while ($binaryNumber != 0) { - $base62Number = self::BASE62_ALPHABET[$binaryNumber % self::BASE62_BASE] - . $base62Number; - $binaryNumber = intdiv($binaryNumber, self::BASE62_BASE); + // Brand new hash? Create row and return: + if ($results->count() == 0) { + $this->table->insert(['path' => $path, 'hash' => $shorthash]); + return $shorthash; } - return ($base62Number == '') ? '0' : $base62Number; + // If we got this far, the hash already exists; let's check if it matches + // the path... + if ($results->current()['path'] === $path) { + return $shorthash; + } + + // If we got here, we have encountered an unexpected hash collision. Let's + // disambiguate by making it one character longer: + return $this->saveAndShortenHash($path, $hash, $length + 1); } /** - * Common base62 decoding function. - * Implemented here so we don't need additional PHP modules like bcmath. + * Generate a short hash using the configured algorithm (and write a row to the + * database if the link is new). * - * @param string $base62Number Number to decode + * @param string $path Path to store in database * - * @return int + * @return string + */ + protected function getGenericHash(string $path): string + { + $hash = hash($this->hashAlgorithm, $path . $this->salt); + // Generate short hash within a transaction to avoid odd timing-related + // problems: + $connection = $this->table->getAdapter()->getDriver()->getConnection(); + $connection->beginTransaction(); + $shortHash = $this + ->saveAndShortenHash($path, $hash, $this->preferredHashLength); + $connection->commit(); + return $shortHash; + } + + /** + * Given a URL, create a database entry (if necessary) and return the hash + * value for inclusion in the short URL. * - * @throws \Exception + * @param string $url URL + * + * @return string */ - protected function base62Decode($base62Number) + protected function getShortHash(string $url): string { - $binaryNumber = 0; - for ($i = 0; $i < strlen($base62Number); ++$i) { - $digit = $base62Number[$i]; - $strpos = strpos(self::BASE62_ALPHABET, $digit); - if ($strpos === false) { - throw new \Exception('not a base62 digit: "' . $digit . '"'); - } - - $binaryNumber *= self::BASE62_BASE; - $binaryNumber += $strpos; - } - return $binaryNumber; + $path = str_replace($this->baseUrl, '', $url); + + // We need to handle things differently depending on whether we're + // using the legacy base62 algorithm, or a different hash mechanism. + $shorthash = $this->hashAlgorithm === 'base62' + ? $this->getBase62Hash($path) : $this->getGenericHash($path); + + return $shorthash; } /** @@ -132,26 +217,23 @@ class Database implements UrlShortenerInterface */ public function shorten($url) { - $path = str_replace($this->baseUrl, '', $url); - $this->table->insert(['path' => $path]); - $id = $this->table->getLastInsertValue(); - - $shortUrl = $this->baseUrl . '/short/' . $this->base62Encode($id); - return $shortUrl; + return $this->baseUrl . '/short/' . $this->getShortHash($url); } /** * Resolve URL from Database via id. * - * @param string $id ID to resolve + * @param string $input hash * * @return string + * + * @throws Exception */ - public function resolve($id) + public function resolve($input) { - $results = $this->table->select(['id' => $this->base62Decode($id)]); + $results = $this->table->select(['hash' => $input]); if ($results->count() !== 1) { - throw new \Exception('Shortlink could not be resolved: ' . $id); + throw new Exception('Shortlink could not be resolved: ' . $input); } return $this->baseUrl . $results->current()['path']; diff --git a/module/VuFind/src/VuFind/UrlShortener/DatabaseFactory.php b/module/VuFind/src/VuFind/UrlShortener/DatabaseFactory.php index 2790b129e76c066f02dd65549eac02a2c4b63ac3..817d525ca44c0e602c2b122fa2883ae5b62d2391 100644 --- a/module/VuFind/src/VuFind/UrlShortener/DatabaseFactory.php +++ b/module/VuFind/src/VuFind/UrlShortener/DatabaseFactory.php @@ -27,6 +27,7 @@ */ namespace VuFind\UrlShortener; +use Exception; use Interop\Container\ContainerInterface; /** @@ -53,13 +54,20 @@ class DatabaseFactory array $options = null ) { if (!empty($options)) { - throw new \Exception('Unexpected options passed to factory.'); + throw new Exception('Unexpected options passed to factory.'); } $router = $container->get('HttpRouter'); $baseUrl = $container->get('ViewRenderer')->plugin('serverurl') ->__invoke($router->assemble([], ['name' => 'home'])); $table = $container->get(\VuFind\Db\Table\PluginManager::class) ->get('shortlinks'); - return new $requestedName(rtrim($baseUrl, '/'), $table); + $config = $container->get(\VuFind\Config\PluginManager::class) + ->get('config'); + $salt = $config->Security->HMACkey ?? ''; + if (empty($salt)) { + throw new Exception('HMACkey missing from configuration.'); + } + $hashType = $config->Mail->url_shortener_key_type ?? 'md5'; + return new $requestedName(rtrim($baseUrl, '/'), $table, $salt, $hashType); } } diff --git a/module/VuFind/tests/unit-tests/src/VuFindTest/Crypt/Base62Test.php b/module/VuFind/tests/unit-tests/src/VuFindTest/Crypt/Base62Test.php new file mode 100644 index 0000000000000000000000000000000000000000..a5bc57540eb355c4c85c86ffd29f25f723bf0d26 --- /dev/null +++ b/module/VuFind/tests/unit-tests/src/VuFindTest/Crypt/Base62Test.php @@ -0,0 +1,89 @@ +<?php +/** + * Base62 Test Class + * + * PHP version 7 + * + * Copyright (C) Villanova University 2020. + * + * This program is free software; you can redistribute it and/or modify + * it under the terms of the GNU General Public License version 2, + * as published by the Free Software Foundation. + * + * 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 General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program; if not, write to the Free Software + * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA + * + * @category VuFind + * @package Tests + * @author Demian Katz <demian.katz@villanova.edu> + * @license http://opensource.org/licenses/gpl-2.0.php GNU General Public License + * @link https://vufind.org/wiki/development:testing:unit_tests Wiki + */ +namespace VuFindTest\Crypt; + +use VuFind\Crypt\Base62; + +/** + * Base62 Test Class + * + * @category VuFind + * @package Tests + * @author Demian Katz <demian.katz@villanova.edu> + * @license http://opensource.org/licenses/gpl-2.0.php GNU General Public License + * @link https://vufind.org/wiki/development:testing:unit_tests Wiki + */ +class Base62Test extends \VuFindTest\Unit\TestCase +{ + /** + * Test encoding. + * + * @param string $input Input + * @param string $expected Expected output + * + * @dataProvider exampleProvider + * + * @return void + */ + public function testEncode($input, $expected) + { + $base62 = new Base62(); + $this->assertEquals($expected, $base62->encode($input)); + } + + /** + * Test decoding. + * + * @param string $expected Expected output + * @param string $input Input + * + * @dataProvider exampleProvider + * + * @return void + */ + public function testDecode($expected, $input) + { + $base62 = new Base62(); + $this->assertEquals($expected, $base62->decode($input)); + } + + /** + * Data provider for tests. + * + * @return array + */ + public function exampleProvider() + { + // format: base 10 number, base 62 number + return [ + ['2', '2'], + ['6234', '1cY'], + ['1437846', '6234'], + ]; + } +} diff --git a/module/VuFind/tests/unit-tests/src/VuFindTest/UrlShortener/DatabaseTest.php b/module/VuFind/tests/unit-tests/src/VuFindTest/UrlShortener/DatabaseTest.php index 144b5089bbb111d1539b14c1b02a6b500766fe80..48079e59320be94a0dd8b221cc38ec3f7353053c 100644 --- a/module/VuFind/tests/unit-tests/src/VuFindTest/UrlShortener/DatabaseTest.php +++ b/module/VuFind/tests/unit-tests/src/VuFindTest/UrlShortener/DatabaseTest.php @@ -27,7 +27,14 @@ */ namespace VuFindTest\UrlShortener; +use Exception; +use PHPUnit\Framework\TestCase; +use VuFind\Db\Table\Shortlinks; use VuFind\UrlShortener\Database; +use Zend\Db\Adapter\Adapter; +use Zend\Db\Adapter\Driver\ConnectionInterface; +use Zend\Db\Adapter\Driver\DriverInterface; +use Zend\Db\ResultSet; /** * "Database" URL shortener test. @@ -35,33 +42,34 @@ use VuFind\UrlShortener\Database; * @category VuFind * @package Tests * @author Demian Katz <demian.katz@villanova.edu> + * @author Cornelius Amzar <cornelius.amzar@bsz-bw.de> * @license http://opensource.org/licenses/gpl-2.0.php GNU General Public License * @link https://vufind.org/wiki/development:testing:unit_tests Wiki */ -class DatabaseTest extends \PHPUnit\Framework\TestCase +class DatabaseTest extends TestCase { /** * Get the object to test. * - * @param object $table Database table object/mock + * @param object $table Database table object/mock * * @return Database */ public function getShortener($table) { - return new Database('http://foo', $table); + return new Database('http://foo', $table, 'RAnD0mVuFindSa!t'); } /** * Get the mock table object. * - * @param array $methods Methods to mock. + * @param array $methods Methods to mock. * * @return object */ public function getMockTable($methods) { - return $this->getMockBuilder(\VuFind\Db\Table\Shortlinks::class) + return $this->getMockBuilder(Shortlinks::class) ->disableOriginalConstructor() ->setMethods($methods) ->getMock(); @@ -71,62 +79,129 @@ class DatabaseTest extends \PHPUnit\Framework\TestCase * Test that the shortener works correctly under "happy path." * * @return void + * + * @throws Exception */ public function testShortener() { - $table = $this->getMockTable(['insert', 'getLastInsertValue']); + $connection = $this->getMockBuilder(ConnectionInterface::class) + ->setMethods( + [ + 'beginTransaction', 'commit', 'connect', 'getResource', + 'isConnected', 'getCurrentSchema', 'disconnect', 'rollback', + 'execute', 'getLastGeneratedValue' + ] + )->disableOriginalConstructor() + ->getMock(); + $connection->expects($this->once())->method('beginTransaction'); + $connection->expects($this->once())->method('commit'); + $driver = $this->getMockBuilder(DriverInterface::class) + ->setMethods( + [ + 'getConnection', 'getDatabasePlatformName', 'checkEnvironment', + 'createStatement', 'createResult', 'getPrepareType', + 'formatParameterName', 'getLastGeneratedValue' + ] + )->disableOriginalConstructor() + ->getMock(); + $driver->expects($this->once())->method('getConnection') + ->will($this->returnValue($connection)); + $adapter = $this->getMockBuilder(Adapter::class) + ->setMethods(['getDriver']) + ->disableOriginalConstructor() + ->getMock(); + $adapter->expects($this->once())->method('getDriver') + ->will($this->returnValue($driver)); + $table = $this->getMockTable(['insert', 'select', 'getAdapter']); $table->expects($this->once())->method('insert') - ->with($this->equalTo(['path' => '/bar'])); - $table->expects($this->once())->method('getLastInsertValue') - ->will($this->returnValue('10')); + ->with($this->equalTo(['path' => '/bar', 'hash' => 'a1e7812e2'])); + $table->expects($this->once())->method('getAdapter') + ->will($this->returnValue($adapter)); + $mockResults = $this->getMockBuilder(ResultSet::class) + ->setMethods(['count', 'current']) + ->disableOriginalConstructor() + ->getMock(); + $mockResults->expects($this->once())->method('count') + ->will($this->returnValue(0)); + $table->expects($this->once())->method('select') + ->with($this->equalTo(['hash' => 'a1e7812e2'])) + ->will($this->returnValue($mockResults)); $db = $this->getShortener($table); - $this->assertEquals('http://foo/short/A', $db->shorten('http://foo/bar')); + $this->assertEquals('http://foo/short/a1e7812e2', $db->shorten('http://foo/bar')); } /** * Test that resolve is supported. * * @return void + * + * @throws Exception */ public function testResolution() { $table = $this->getMockTable(['select']); - $mockResults = $this->getMockBuilder(\Zend\Db\ResultSet::class) + $mockResults = $this->getMockBuilder(ResultSet::class) ->setMethods(['count', 'current']) ->disableOriginalConstructor() ->getMock(); $mockResults->expects($this->once())->method('count') ->will($this->returnValue(1)); $mockResults->expects($this->once())->method('current') - ->will($this->returnValue(['path' => '/bar'])); + ->will($this->returnValue(['path' => '/bar', 'hash' => '8ef580184'])); $table->expects($this->once())->method('select') - ->with($this->equalTo(['id' => 10])) + ->with($this->equalTo(['hash' => '8ef580184'])) ->will($this->returnValue($mockResults)); $db = $this->getShortener($table); - $this->assertEquals('http://foo/bar', $db->resolve('A')); + $this->assertEquals('http://foo/bar', $db->resolve('8ef580184')); } /** * Test that resolve errors correctly when given bad input * * @return void + * + * @throws Exception */ public function testResolutionOfBadInput() { - $this->expectException(\Exception::class); - $this->expectExceptionMessage('Shortlink could not be resolved: B'); + $this->expectExceptionMessage('Shortlink could not be resolved: abcd12?'); $table = $this->getMockTable(['select']); - $mockResults = $this->getMockBuilder(\Zend\Db\ResultSet::class) + $mockResults = $this->getMockBuilder(ResultSet::class) ->setMethods(['count']) ->disableOriginalConstructor() ->getMock(); $mockResults->expects($this->once())->method('count') ->will($this->returnValue(0)); $table->expects($this->once())->method('select') - ->with($this->equalTo(['id' => 11])) + ->with($this->equalTo(['hash' => 'abcd12?'])) ->will($this->returnValue($mockResults)); $db = $this->getShortener($table); - $db->resolve('B'); + $db->resolve('abcd12?'); + } + + /** + * Test that resolve errors correctly when given bad input + * + * @return void + * + * @throws Exception + */ + public function testResolutionOfOldIds() + { + $table = $this->getMockTable(['select']); + $mockResults = $this->getMockBuilder(ResultSet::class) + ->setMethods(['count', 'current']) + ->disableOriginalConstructor() + ->getMock(); + $mockResults->expects($this->once())->method('count') + ->will($this->returnValue(1)); + $mockResults->expects($this->once())->method('current') + ->will($this->returnValue(['path' => '/bar', 'hash' => 'A'])); + $table->expects($this->once())->method('select') + ->with($this->equalTo(['hash' => 'A'])) + ->will($this->returnValue($mockResults)); + $db = $this->getShortener($table); + $this->assertEquals('http://foo/bar', $db->resolve('A')); } }