diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..fe03f52 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,38 @@ +name: CI + +on: + push: + branches: + - main + - master + pull_request: + +permissions: + contents: read + +jobs: + test: + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + php-version: ['8.1', '8.2', '8.3', '8.4'] + + steps: + - name: Check out repository + uses: actions/checkout@v7.0.1 + + - name: Set up PHP + uses: shivammathur/setup-php@2.37.2 + with: + php-version: ${{ matrix.php-version }} + coverage: none + + - name: Validate composer configuration + run: composer validate --strict + + - name: Install dependencies + run: composer install --no-interaction --prefer-dist + + - name: Run test suite + run: composer test diff --git a/.gitignore b/.gitignore index 914bf13..ae6eb3e 100644 --- a/.gitignore +++ b/.gitignore @@ -1,5 +1,7 @@ -vendor +vendor/ composer.phar composer.lock .DS_Store -build +build/ +.phpunit.cache/ +.phpunit.result.cache diff --git a/.travis.yml b/.travis.yml deleted file mode 100644 index b9326f2..0000000 --- a/.travis.yml +++ /dev/null @@ -1,19 +0,0 @@ -language: php - -php: - - hhvm - - 5.6 - - 5.5 - - 5.4 - - 5.3 - -before_script: - - composer self-update - - composer install - -script: - - phpunit --coverage-text - -matrix: - allow_failures: - - php: hhvm diff --git a/README.md b/README.md index 309003d..c17645a 100644 --- a/README.md +++ b/README.md @@ -1,32 +1,31 @@ -[![Build Status](https://travis-ci.org/zackkitzmiller/tiny-php.png?branch=master)](https://travis-ci.org/zackkitzmiller/tiny-php) -[![Coverage Status](https://coveralls.io/repos/zackkitzmiller/tiny-php/badge.png?branch=master)](https://coveralls.io/r/zackkitzmiller/tiny-php?branch=master) +[![CI](https://github.com/zackkitzmiller/tiny-php/actions/workflows/ci.yml/badge.svg)](https://github.com/zackkitzmiller/tiny-php/actions/workflows/ci.yml) # Tiny -A reversible base62 ID obfuscater +Tiny is a small reversible ID obfuscator for PHP. It encodes integers into a custom alphabet and decodes them back again. -## Authors +## What's modernized -Originally by Jacob DeHart, with Ruby and Python ports by Kyle Bragger - -Now maintained by [Zack Kitzmiller](https://github.com/zackkitzmiller). +- PHP 8.1+ baseline +- PSR-4 autoloading +- PHPUnit 10/11 test suite +- GitHub Actions CI +- Safer alphabet validation and decoding errors ## Installation -Install via Composer - -```json -{ - "require": { - "zackkitzmiller/tiny": "1.2.0" - }, -} +```bash +composer require zackkitzmiller/tiny ``` ## Usage ```php -$tiny = new \ZackKitzmiller\Tiny('5SX0TEjkR1mLOw8Gvq2VyJxIFhgCAYidrclDWaM3so9bfzZpuUenKtP74QNH6B'); +to(5); // E @@ -39,47 +38,59 @@ echo $tiny->to(126); echo $tiny->from('XX'); // 126 +``` -echo $tiny->to(999); -// vk +## Character set rules -echo $tiny->from('vk'); -// 999 +Your alphabet must: -``` +- contain at least 2 characters +- only contain unique characters +- stay fixed forever once you start issuing encoded IDs -## Configuration +Tiny throws an exception when the alphabet is invalid or when you try to decode characters that are not in the configured alphabet. -You must instanciate a new instance of Tiny with a random alpha-numeric set where each character must only be used exactly once. Do **NOT** change this once you start using Tiny, as you won't be able to reverse. +## Generate an alphabet -You can generate a random set from the commandline with `$ ./bin/genset` +After installing dependencies, generate a fresh 62-character alphabet with: -## Using laravel? +```bash +./bin/genset +``` -If you're using laravel and want to use a more laravel-like and cleaner syntax you only have to follow these steps. +You can also generate one in code: -First open your ``app/config/app.php`` file and scroll down to your providers and add -```php -'providers' => array( - ... - 'ZackKitzmiller\TinyServiceProvider', -) -``` -and then this to aliases ```php -'aliases' => array( - ... - 'Tiny' => 'ZackKitzmiller\Facades\Tiny', -) +$set = \ZackKitzmiller\Tiny::generateSet(); ``` -Lastly you run ``php artisan config:publish zackkitzmiller/tiny`` to publish the configuration file and then run ``php artisan tiny:generate`` to create a valid key. +The legacy `Tiny::generate_set()` helper is still available for backwards compatibility. -### Usage in Laravel -```php -echo Tiny::to(999); -// echos vk +This release does introduce a package-level modernization break: Composer autoloading now uses PSR-4 for `ZackKitzmiller\\` classes, so consumers relying on older PSR-0-era assumptions should verify their integration when upgrading. + +The Laravel integration classes continue to autoload from the `ZackKitzmiller\\Laravel5\\` namespace under `src/ZackKitzmiller/Laravel5/`. + +## Development -echo Tiny::from('E'); -// echos 5 +```bash +composer install +composer test +composer check ``` + +## Legacy Laravel integration + +The repository still includes the original Laravel integration classes for older applications, but the package is now centered on the framework-agnostic core library. + +For Laravel integration: + +- Laravel 5+: + - register `ZackKitzmiller\Laravel5\TinyServiceProvider` + - optionally register the `ZackKitzmiller\Facades\Tiny` facade alias + - publish the config with `php artisan vendor:publish --tag=tiny-config` + - set `TINY_KEY` in your environment or run `php artisan tiny:generate` +- older Laravel integrations: + - register `ZackKitzmiller\TinyServiceProvider` + - optionally register the `ZackKitzmiller\Facades\Tiny` facade alias + - publish the package configuration with the framework command your app version expects + - run `php artisan tiny:generate` to persist the generated key diff --git a/bin/genset b/bin/genset index 92541dd..a8c8caa 100755 --- a/bin/genset +++ b/bin/genset @@ -1,14 +1,11 @@ #!/usr/bin/env php +=5.3.0" + "php": "^8.1" }, "require-dev": { - "phpunit/phpunit": "~3.7.0", - "league/phpunit-coverage-listener": "~1.1" + "phpunit/phpunit": "^10.5 || ^11.5" }, "autoload": { - "psr-0": {"ZackKitzmiller": "src/"} + "psr-4": { + "ZackKitzmiller\\Laravel5\\": "src/ZackKitzmiller/Laravel5/", + "ZackKitzmiller\\": "src/ZackKitzmiller/" + } + }, + "autoload-dev": { + "classmap": [ + "tests/" + ] + }, + "bin": [ + "bin/genset" + ], + "scripts": { + "test": "phpunit", + "test:coverage": "phpunit --coverage-text", + "check": [ + "@composer validate --strict", + "@test" + ] + }, + "config": { + "sort-packages": true } } diff --git a/phpunit.xml b/phpunit.xml index 3a1d951..e7bc2f7 100644 --- a/phpunit.xml +++ b/phpunit.xml @@ -1,43 +1,8 @@ - - + tests - - - - - - src/ZackKitzmiller/Tiny.php - - - - - - - - - - - - - - ZackKitzmiller - - - 4GggSuZVGvVaxBi8E9IZTqG31EXFvjMe7 - - - https://coveralls.io/api/v1/jobs - - - build/ - - - - - diff --git a/src/ZackKitzmiller/EnvironmentKeyUpdater.php b/src/ZackKitzmiller/EnvironmentKeyUpdater.php new file mode 100644 index 0000000..9ab824a --- /dev/null +++ b/src/ZackKitzmiller/EnvironmentKeyUpdater.php @@ -0,0 +1,51 @@ + self::PRIMARY_KEY . '=' . $key, + $contents, + 1 + ); + + if ($updated !== null) { + return $updated; + } + } + + if (preg_match($legacyPattern, $contents) === 1) { + $updated = preg_replace_callback( + $legacyPattern, + static fn (): string => self::PRIMARY_KEY . '=' . $key, + $contents, + 1 + ); + + if ($updated !== null) { + return $updated; + } + } + + if ($trimmed === '') { + return self::PRIMARY_KEY . '=' . $key . PHP_EOL; + } + + return $trimmed . PHP_EOL . self::PRIMARY_KEY . '=' . $key . PHP_EOL; + } +} diff --git a/src/ZackKitzmiller/Facades/Tiny.php b/src/ZackKitzmiller/Facades/Tiny.php index d1cd555..2da7d84 100644 --- a/src/ZackKitzmiller/Facades/Tiny.php +++ b/src/ZackKitzmiller/Facades/Tiny.php @@ -1,12 +1,15 @@ -environmentFilePath(); + + $content = is_file($path) ? $this->readEnvironmentFile($path) : ''; + $content = EnvironmentKeyUpdater::updateContents($content, $key); - $path = base_path('.env'); + $this->writeEnvironmentFile($path, $content); + $this->laravel['config']->set('tiny.key', $key); + $this->laravel['config']->set('zackkitzmiller/tiny::key', $key); - if (file_exists($path) and getenv('LEAGUE_TINY_KEY') !== false) { - $originalContent = file_get_contents($path); - $content = str_replace('LEAGUE_TINY_KEY='.getenv('LEAGUE_TINY_KEY'), 'LEAGUE_TINY_KEY='.$key, $originalContent); + $this->info("Tiny key [{$key}] has been set."); - file_put_contents($path, $content); - } else { - $fp = fopen($path, 'a'); - fwrite($fp, "\nLEAGUE_TINY_KEY=$key\n"); - fclose($fp); + return self::SUCCESS; + } + + public function fire(): int + { + return $this->handle(); + } + + private function readEnvironmentFile(string $path): string + { + $content = $this->files->get($path); + + if (! is_string($content)) { + throw new RuntimeException(sprintf('Unable to read environment file at %s.', $path)); } - $this->info("Tiny key [$key] has been set."); + return $content; } + private function writeEnvironmentFile(string $path, string $content): void + { + if ($this->files->put($path, $content) === false) { + throw new RuntimeException(sprintf('Unable to write environment file at %s.', $path)); + } + } + + private function environmentFilePath(): string + { + $environment = $this->environmentOption(); + + if ($environment !== null) { + if (method_exists($this->laravel, 'environmentPath')) { + return rtrim($this->laravel->environmentPath(), '/\\') . '/.env.' . $environment; + } + + if (method_exists($this->laravel, 'basePath')) { + return $this->laravel->basePath('.env.' . $environment); + } + + return $this->laravel['path.base'] . '/.env.' . $environment; + } + + if (method_exists($this->laravel, 'environmentFilePath')) { + return $this->laravel->environmentFilePath(); + } + + if (method_exists($this->laravel, 'basePath')) { + return $this->laravel->basePath('.env'); + } + + return $this->laravel['path.base'] . '/.env'; + } + + private function environmentOption(): ?string + { + $environment = $this->option('env'); + + if (! is_string($environment) || $environment === '') { + return null; + } + + if (preg_match('/\A[A-Za-z0-9_-]+\z/', $environment) !== 1) { + throw new InvalidArgumentException('The --env option may only contain letters, numbers, dashes, and underscores.'); + } + + return $environment; + } } diff --git a/src/ZackKitzmiller/Laravel5/TinyServiceProvider.php b/src/ZackKitzmiller/Laravel5/TinyServiceProvider.php index 852bdcd..e29b9eb 100644 --- a/src/ZackKitzmiller/Laravel5/TinyServiceProvider.php +++ b/src/ZackKitzmiller/Laravel5/TinyServiceProvider.php @@ -1,30 +1,59 @@ -app->singleton('tiny.generate', function () { - return new TinyGenerateCommand(); - }); + if ($this->app->runningInConsole() && function_exists('config_path')) { + $this->publishes([ + self::configPath() => config_path('tiny.php'), + ], 'tiny-config'); - $this->commands('tiny.generate'); + $this->commands(['tiny.generate']); + } } - public function register() + public function register(): void { - $this->app->singleton('tiny', function () { - $key = getenv('LEAGUE_TINY_KEY'); + $this->mergeConfigFrom(self::configPath(), 'tiny'); + + $this->app->singleton('tiny.generate', static function ($app) { + return new TinyGenerateCommand($app['files']); + }); + + $this->app->singleton('tiny', static function ($app) { + $key = $app['config']->get('tiny.key'); + + if ($key === null || $key === false) { + $primaryKey = getenv(EnvironmentKeyUpdater::PRIMARY_KEY); + $legacyKey = getenv(EnvironmentKeyUpdater::LEGACY_KEY); + $key = $primaryKey !== false ? $primaryKey : $legacyKey; + } + + if (! is_string($key) || $key === '') { + throw new RuntimeException('A Tiny character set must be configured before resolving the Tiny service.'); + } return new Tiny($key); }); } - public function provides() + public function provides(): array + { + return ['tiny']; + } + + private static function configPath(): string { - return array('tiny'); + return dirname(__DIR__, 2) . '/config/config.php'; } } diff --git a/src/ZackKitzmiller/Tiny.php b/src/ZackKitzmiller/Tiny.php index 6ee5ea7..bd39251 100644 --- a/src/ZackKitzmiller/Tiny.php +++ b/src/ZackKitzmiller/Tiny.php @@ -1,56 +1,129 @@ -set = $set; } - public function to($id) + public function to(int|string $id): string + { + $encoded = ''; + $value = $this->normalizeId($id); + $radix = strlen($this->set); + + do { + $remainder = $value % $radix; + $encoded = $this->set[$remainder] . $encoded; + $value = intdiv($value - $remainder, $radix); + } while ($value > 0); + + return $encoded; + } + + public function from(string $value): int { - $set = $this->set; - - $hexn = ''; - $id = floor(abs(intval($id))); - $radix = strlen($set); - while (true) { - $r = $id % $radix; - $hexn = $set[$r] . $hexn; - $id = ($id - $r) / $radix; - if ($id == 0) { - break; + if ($value === '') { + return 0; + } + + $radix = strlen($this->set); + $decoded = 0; + + foreach (str_split($value) as $character) { + $position = strpos($this->set, $character); + + if ($position === false) { + throw new InvalidArgumentException(sprintf('Character "%s" is not in the Tiny character set.', $character)); } + + if ($decoded > intdiv(PHP_INT_MAX - $position, $radix)) { + throw new InvalidArgumentException('Decoded value exceeds PHP\'s native integer range.'); + } + + $decoded = ($decoded * $radix) + $position; } - return $hexn; + + return $decoded; } - public function from($str) + public static function generateSet(): string { - $set = $this->set; + $characters = [ + ...range('A', 'Z'), + ...range('a', 'z'), + ...range('0', '9'), + ]; - $radix = strlen($set); - $strlen = strlen($str); - $n = 0; - for ($i = 0; $i < $strlen; $i++) { - $n += strpos($set, $str[$i]) * pow($radix, ($strlen - $i - 1)); + for ($i = count($characters) - 1; $i > 0; $i--) { + $swapIndex = random_int(0, $i); + [$characters[$i], $characters[$swapIndex]] = [$characters[$swapIndex], $characters[$i]]; } - return $n; + + return implode('', $characters); } - public static function generate_set() + public static function generate_set(): string { - $arr = array(); + return self::generateSet(); + } - for ($i = 65; $i <= 122; $i++) { - if ($i < 91 || $i > 96) $arr[] = chr($i); + private static function assertValidSet(string $set): void + { + if (strlen($set) < 2) { + throw new InvalidCharacterSet('Tiny requires at least two unique characters in the set.'); } - $arr = array_merge($arr, range(0, 9)); - shuffle($arr); - - return join('', $arr); + if (count(array_unique(str_split($set))) !== strlen($set)) { + throw new InvalidCharacterSet('Tiny character sets must only contain unique characters.'); + } } + private function normalizeId(int|string $id): int + { + if (is_int($id)) { + if ($id === PHP_INT_MIN) { + throw new InvalidArgumentException('Tiny cannot encode values smaller than PHP_INT_MIN + 1.'); + } + + return abs($id); + } + + if (! preg_match('/^[+-]?\d+$/', $id)) { + throw new InvalidArgumentException('Tiny can only encode whole-number integers.'); + } + + $isNegative = str_starts_with($id, '-'); + $normalized = ltrim($id, '+-'); + $normalized = ltrim($normalized, '0'); + $normalized = $normalized === '' ? '0' : $normalized; + $limit = $isNegative ? ltrim((string) PHP_INT_MIN, '-') : (string) PHP_INT_MAX; + + if ( + strlen($normalized) > strlen($limit) + || ( + strlen($normalized) === strlen($limit) + && strcmp($normalized, $limit) > 0 + ) + ) { + throw new InvalidArgumentException('Tiny can only encode integers within PHP\'s native integer range.'); + } + + if ($isNegative && $normalized === ltrim((string) PHP_INT_MIN, '-')) { + throw new InvalidArgumentException('Tiny cannot encode values smaller than PHP_INT_MIN + 1.'); + } + + return (int) $normalized; + } } diff --git a/src/ZackKitzmiller/TinyGenerateCommand.php b/src/ZackKitzmiller/TinyGenerateCommand.php index 8147784..5209aa5 100644 --- a/src/ZackKitzmiller/TinyGenerateCommand.php +++ b/src/ZackKitzmiller/TinyGenerateCommand.php @@ -1,43 +1,137 @@ -environmentFilePath(); + $contents = is_file($environmentPath) ? $this->readEnvironmentFile($environmentPath) : ''; + $contents = EnvironmentKeyUpdater::updateContents($contents, $key); + + $this->writeEnvironmentFile($environmentPath, $contents); + $this->updateLegacyConfigFile($key); + $this->laravel['config']->set('tiny.key', $key); + $this->laravel['config']->set('zackkitzmiller/tiny::key', $key); + + $this->info("Tiny key [{$key}] has been set."); + + return self::SUCCESS; + } + + public function fire(): int + { + return $this->handle(); + } + + private function readEnvironmentFile(string $path): string + { + $content = $this->files->get($path); + + if (! is_string($content)) { + throw new \RuntimeException(sprintf('Unable to read environment file at %s.', $path)); + } - $this->files = $files; + return $content; } - public function fire() + private function writeEnvironmentFile(string $path, string $content): void { - list($path, $contents) = $this->getKeyFile(); + if ($this->files->put($path, $content) === false) { + throw new \RuntimeException(sprintf('Unable to write environment file at %s.', $path)); + } + } - $key = Tiny::generate_set(); + private function environmentFilePath(): string + { + $environment = $this->environmentOption(); - $contents = str_replace($this->laravel['config']['zackkitzmiller/tiny::key'], $key, $contents); + if ($environment !== null) { + if (method_exists($this->laravel, 'environmentPath')) { + return rtrim($this->laravel->environmentPath(), '/\\') . '/.env.' . $environment; + } - $this->files->put($path, $contents); + if (method_exists($this->laravel, 'basePath')) { + return $this->laravel->basePath('.env.' . $environment); + } - $this->laravel['config']['zackkitzmiller/tiny::key'] = $key; + return $this->laravel['path.base'] . '/.env.' . $environment; + } - $this->info("Tiny key [$key] has been set."); + if (method_exists($this->laravel, 'environmentFilePath')) { + return $this->laravel->environmentFilePath(); + } + + if (method_exists($this->laravel, 'basePath')) { + return $this->laravel->basePath('.env'); + } + + return $this->laravel['path.base'] . '/.env'; } - protected function getKeyFile() + private function environmentOption(): ?string { - $env = $this->option('env') ? $this->option('env').'/' : ''; + $environment = $this->option('env'); + + if (! is_string($environment) || $environment === '') { + return null; + } - $contents = $this->files->get($path = $this->laravel['path']."/config/packages/zackkitzmiller/tiny/{$env}config.php"); + if (preg_match('/\A[A-Za-z0-9_-]+\z/', $environment) !== 1) { + throw new \InvalidArgumentException('The --env option may only contain letters, numbers, dashes, and underscores.'); + } - return array($path, $contents); + return $environment; } + private function updateLegacyConfigFile(string $key): void + { + $path = $this->legacyConfigFilePath(); + + if ($path === null || ! is_file($path)) { + return; + } + + $contents = $this->readEnvironmentFile($path); + $escapedKey = addcslashes($key, "\\'"); + $updated = preg_replace_callback( + "/^([ \\t]*'key'\\s*=>\\s*)([^,]+)(,?[ \\t]*$)/m", + static fn (array $matches): string => $matches[1] . "'" . $escapedKey . "'" . $matches[3], + $contents, + 1, + $count + ); + + if ($updated !== null && $count > 0) { + $this->writeEnvironmentFile($path, $updated); + } + } + + private function legacyConfigFilePath(): ?string + { + if (! isset($this->laravel['path'])) { + return null; + } + + $environment = $this->environmentOption(); + $environment = $environment !== null ? $environment . '/' : ''; + + return $this->laravel['path'] . "/config/packages/zackkitzmiller/tiny/{$environment}config.php"; + } } diff --git a/src/ZackKitzmiller/TinyServiceProvider.php b/src/ZackKitzmiller/TinyServiceProvider.php index cb3ea58..2664206 100644 --- a/src/ZackKitzmiller/TinyServiceProvider.php +++ b/src/ZackKitzmiller/TinyServiceProvider.php @@ -1,34 +1,52 @@ -app->runningInConsole() && function_exists('config_path')) { + $this->publishes([ + __DIR__ . '/../config/config.php' => config_path('tiny.php'), + ], 'tiny-config'); + + $this->commands(['tiny.generate']); + } + } - public function boot() + public function register(): void { - $this->package('zackkitzmiller/tiny', 'zackkitzmiller/tiny', __DIR__.'/../'); + $this->mergeConfigFrom(__DIR__ . '/../config/config.php', 'tiny'); - $this->app['tiny.generate'] = $this->app->share(function($app) - { + $this->app->singleton('tiny.generate', function ($app) { return new TinyGenerateCommand($app['files']); }); - $this->commands('tiny.generate'); - } + $this->app->singleton('tiny', function ($app) { + $key = $app['config']->get('tiny.key'); - public function register() - { - $this->app['tiny'] = $this->app->share(function($app) - { - $key = $app['config']['zackkitzmiller/tiny::key']; + if ($key === null || $key === false) { + $primaryKey = getenv(EnvironmentKeyUpdater::PRIMARY_KEY); + $legacyKey = getenv(EnvironmentKeyUpdater::LEGACY_KEY); + $key = $primaryKey !== false ? $primaryKey : $legacyKey; + } + + if (! is_string($key) || $key === '') { + throw new RuntimeException('A Tiny character set must be configured before resolving the Tiny service.'); + } return new Tiny($key); }); } - public function provides() + public function provides(): array { - return array('tiny'); + return ['tiny']; } - } \ No newline at end of file diff --git a/src/config/config.php b/src/config/config.php index 481756e..8b8a3b5 100644 --- a/src/config/config.php +++ b/src/config/config.php @@ -1,7 +1,8 @@ '', - -); \ No newline at end of file + 'key' => env('TINY_KEY', env('LEAGUE_TINY_KEY', '')), +]; \ No newline at end of file diff --git a/tests/TinyGenerateCommandEnvironmentPathTest.php b/tests/TinyGenerateCommandEnvironmentPathTest.php new file mode 100644 index 0000000..b26f223 --- /dev/null +++ b/tests/TinyGenerateCommandEnvironmentPathTest.php @@ -0,0 +1,154 @@ +options[$key] ?? null; + } + + public function setOptions(array $options): void + { + $this->options = $options; + } +} +PHP); +} + +if (! class_exists(\Illuminate\Filesystem\Filesystem::class)) { + eval(<<<'PHP' +namespace Illuminate\Filesystem; + +class Filesystem +{ +} +PHP); +} + +require_once __DIR__ . '/../src/ZackKitzmiller/TinyGenerateCommand.php'; +require_once __DIR__ . '/../src/ZackKitzmiller/Laravel5/TinyGenerateCommand.php'; + +final class TinyGenerateCommandEnvironmentPathTest extends TestCase +{ + /** + * @return array + */ + public static function commandClasses(): array + { + return [ + 'current command' => [\ZackKitzmiller\TinyGenerateCommand::class, '/var/www/current/.env'], + 'laravel5 command' => [\ZackKitzmiller\Laravel5\TinyGenerateCommand::class, '/var/www/legacy/.env'], + ]; + } + + /** + * @dataProvider commandClasses + */ + public function testUsesDefaultEnvironmentFilePathWhenEnvOptionIsMissing(string $commandClass, string $defaultPath): void + { + $command = $this->makeCommand($commandClass, new FakeLaravelApplication(dirname($defaultPath), $defaultPath)); + + self::assertSame($defaultPath, $this->environmentFilePath($command)); + } + + /** + * @dataProvider commandClasses + */ + public function testUsesEnvironmentSpecificFileWhenEnvOptionIsProvided(string $commandClass, string $defaultPath): void + { + $command = $this->makeCommand($commandClass, new FakeLaravelApplication(dirname($defaultPath), $defaultPath), 'staging'); + + self::assertSame(dirname($defaultPath) . '/.env.staging', $this->environmentFilePath($command)); + } + + /** + * @dataProvider commandClasses + */ + public function testRejectsUnsafeEnvironmentNames(string $commandClass, string $defaultPath): void + { + $command = $this->makeCommand($commandClass, new FakeLaravelApplication(dirname($defaultPath), $defaultPath), '../staging'); + + $this->expectException(InvalidArgumentException::class); + + $this->environmentFilePath($command); + } + + /** + * @param class-string $commandClass + */ + private function makeCommand(string $commandClass, FakeLaravelApplication $laravel, ?string $environment = null): object + { + $command = new $commandClass(new \Illuminate\Filesystem\Filesystem()); + $command->laravel = $laravel; + $command->setOptions(['env' => $environment]); + + return $command; + } + + private function environmentFilePath(object $command): string + { + $method = new ReflectionMethod($command, 'environmentFilePath'); + $method->setAccessible(true); + + /** @var string */ + return $method->invoke($command); + } +} + +final class FakeLaravelApplication implements ArrayAccess +{ + public function __construct( + private string $basePath, + private string $environmentFilePath, + ) { + } + + public function environmentFilePath(): string + { + return $this->environmentFilePath; + } + + public function environmentPath(): string + { + return $this->basePath; + } + + public function basePath(string $path = ''): string + { + return $path === '' ? $this->basePath : $this->basePath . '/' . ltrim($path, '/'); + } + + public function offsetExists(mixed $offset): bool + { + return $offset === 'path.base'; + } + + public function offsetGet(mixed $offset): mixed + { + return $offset === 'path.base' ? $this->basePath : null; + } + + public function offsetSet(mixed $offset, mixed $value): void + { + } + + public function offsetUnset(mixed $offset): void + { + } +} diff --git a/tests/TinyTest.php b/tests/TinyTest.php index e65699f..c7e0237 100644 --- a/tests/TinyTest.php +++ b/tests/TinyTest.php @@ -1,45 +1,180 @@ tiny = new Tiny('5SX0TEjkR1mLOw8Gvq2VyJxIFhgCAYidrclDWaM3so9bfzZpuUenKtP74QNH6B'); } - public function testToTiny() { - $converted = $this->tiny->to(5); - $this->assertEquals('E', $converted); + public function testToTiny(): void + { + self::assertSame('E', $this->tiny->to(5)); } - public function testFromTiny() { - $reversed = $this->tiny->from('E'); - $this->assertEquals(5, $reversed); + public function testFromTiny(): void + { + self::assertSame(5, $this->tiny->from('E')); } - public function testReversingRandomInt() { - for ($i = 0; $i <= 100; $i++) { - $this->assertEquals($this->tiny->from($this->tiny->to($i)), $i); + public function testReversingRandomInt(): void + { + for ($i = 0; $i <= 1000; $i++) { + self::assertSame($i, $this->tiny->from($this->tiny->to($i))); } } - public function testGenerateRandomSetsWork() { - for ($i = 0; $i <= 1000; $i++) { - $tiny = new Tiny(Tiny::generate_set()); - $this->assertEquals($tiny->from($tiny->to($i)), $i); - } + public function testNegativeValuesAreNormalized(): void + { + self::assertSame($this->tiny->to(25), $this->tiny->to(-25)); + } + + public function testNumericStringsAreSupported(): void + { + self::assertSame($this->tiny->to(25), $this->tiny->to('0025')); + } + + public function testNegativeNumericStringsAreNormalized(): void + { + self::assertSame($this->tiny->to(25), $this->tiny->to('-25')); } - public function testGenerateSetUnique() { - $set = Tiny::generate_set(); - $set_parts = str_split($set); - $used = array(); - foreach ($set_parts as $char) { - $this->assertArrayNotHasKey($char, $used); - $used[$char] = $char; + public function testGenerateRandomSetsWork(): void + { + for ($i = 0; $i <= 100; $i++) { + $tiny = new Tiny(Tiny::generateSet()); + self::assertSame($i, $tiny->from($tiny->to($i))); } } + + public function testGenerateSetAliasRemainsAvailable(): void + { + self::assertSame(62, strlen(Tiny::generate_set())); + } + + public function testGenerateSetIsUnique(): void + { + $set = Tiny::generateSet(); + self::assertCount(62, array_unique(str_split($set))); + } + + public function testConstructorRejectsDuplicateCharacters(): void + { + $this->expectException(InvalidCharacterSet::class); + + new Tiny('aa'); + } + + public function testConstructorRejectsSmallCharacterSets(): void + { + $this->expectException(InvalidCharacterSet::class); + + new Tiny('a'); + } + + public function testFromRejectsUnknownCharacters(): void + { + $this->expectException(\InvalidArgumentException::class); + + $this->tiny->from('!'); + } + + public function testFromRejectsValuesThatOverflowNativeIntegers(): void + { + $tiny = new Tiny('ab'); + + $this->expectException(\InvalidArgumentException::class); + + $tiny->from(str_repeat('b', 128)); + } + + public function testToRejectsNonIntegerStrings(): void + { + $this->expectException(\InvalidArgumentException::class); + + $this->tiny->to('12abc'); + } + + public function testToRejectsOutOfRangeNumericStrings(): void + { + $this->expectException(\InvalidArgumentException::class); + + $this->tiny->to((string) PHP_INT_MAX . '0'); + } + + public function testToRejectsMinimumIntegerString(): void + { + $this->expectException(\InvalidArgumentException::class); + + $this->tiny->to((string) PHP_INT_MIN); + } + + public function testEnvironmentKeyUpdaterReplacesExistingKeys(): void + { + $contents = "APP_ENV=testing\nLEAGUE_TINY_KEY=old-key\n"; + + self::assertSame( + "APP_ENV=testing\nTINY_KEY=new-key\n", + EnvironmentKeyUpdater::updateContents($contents, 'new-key') + ); + } + + public function testEnvironmentKeyUpdaterAppendsMissingKeys(): void + { + $contents = "APP_ENV=testing\n"; + + self::assertSame( + "APP_ENV=testing\nTINY_KEY=new-key\n", + EnvironmentKeyUpdater::updateContents($contents, 'new-key') + ); + } + + public function testEnvironmentKeyUpdaterPreservesLiteralSpecialCharacters(): void + { + $contents = "TINY_KEY=old-key\n"; + + self::assertSame( + 'TINY_KEY=value$1\path' . PHP_EOL, + EnvironmentKeyUpdater::updateContents($contents, 'value$1\\path') + ); + } + + public function testEnvironmentKeyUpdaterPrefersTinyKeyWhenBothKeysExist(): void + { + $contents = "TINY_KEY=old-primary\nLEAGUE_TINY_KEY=old-legacy\n"; + + self::assertSame( + "TINY_KEY=new-key\nLEAGUE_TINY_KEY=old-legacy\n", + EnvironmentKeyUpdater::updateContents($contents, 'new-key') + ); + } + + public function testEnvironmentKeyUpdaterPrefersTinyKeyRegardlessOfOrder(): void + { + $contents = "LEAGUE_TINY_KEY=old-legacy\nTINY_KEY=old-primary\n"; + + self::assertSame( + "LEAGUE_TINY_KEY=old-legacy\nTINY_KEY=new-key\n", + EnvironmentKeyUpdater::updateContents($contents, 'new-key') + ); + } + + public function testEnvironmentKeyUpdaterHandlesIndentedKeys(): void + { + $contents = " TINY_KEY=old-primary\n"; + + self::assertSame( + "TINY_KEY=new-key\n", + EnvironmentKeyUpdater::updateContents($contents, 'new-key') + ); + } }