diff --git a/.github/workflows/pest.yml b/.github/workflows/pest.yml new file mode 100644 index 0000000..6e87473 --- /dev/null +++ b/.github/workflows/pest.yml @@ -0,0 +1,33 @@ +name: Pest Tests + +on: + pull_request: + branches: + - main + - master + +jobs: + test: + runs-on: ubuntu-latest + + strategy: + fail-fast: false + matrix: + php: [8.3] + + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Setup PHP + uses: shivammathur/setup-php@v2 + with: + php-version: ${{ matrix.php }} + extensions: dom, curl, libxml, mbstring, zip, pdo, sqlite, pdo_sqlite, bcmath, intl, gd, exif, iconv, imagick, fileinfo + coverage: none + + - name: Install dependencies + run: composer install --prefer-dist --no-interaction --no-progress + + - name: Run Pest tests + run: vendor/bin/pest diff --git a/README.md b/README.md index 8b64f4a..4c9fd80 100644 --- a/README.md +++ b/README.md @@ -83,6 +83,46 @@ On this page you can manage the query strings you wish to strip, simply : You'll see your query string in the list. +### Database Redirects + +By default, the addon stores redirects and query strings as YAML files in `content/alt-redirect/`. For containerised deployments or larger sites, you may wish to store these in your database instead. + +#### 1. Publish Configuration +If you haven't already, publish the configuration file to your site: + +```bash +php artisan vendor:publish --tag=alt-redirect-config +``` + +#### 2. Run Migrations +While migrations are loaded automatically, you can publish them if you wish to customize them: + +```bash +php artisan vendor:publish --tag=alt-redirect-migrations +``` + +Run the migrations to create the necessary tables: + +```bash +php artisan migrate +``` + +#### 3. Switch Driver +In your `config/alt-redirect.php`, change the `driver` from `file` to `database`: + +```php +'driver' => 'database', +``` + +#### 4. Migrate Existing Data (Optional) +If you already have file-based redirects and want to move them to your database, you can use the following Artisan command: + +```bash +php artisan alt-redirect:migrate-file-redirects +``` + +The command will check if the required tables exist and then migrate all your redirects and query strings. + #### Artisan Command We have provided an Artisan command to force the creation of defaults. diff --git a/composer.json b/composer.json index 947ba0b..4baa9eb 100644 --- a/composer.json +++ b/composer.json @@ -7,6 +7,11 @@ "AltDesign\\AltRedirect\\": "src" } }, + "autoload-dev": { + "psr-4": { + "AltDesign\\AltRedirect\\Tests\\": "tests" + } + }, "require": { "php": "^8.1", "statamic/cms": "^6.0" @@ -24,7 +29,14 @@ }, "config": { "allow-plugins": { - "pixelfear/composer-dist-plugin": true + "pixelfear/composer-dist-plugin": true, + "pestphp/pest-plugin": true } + }, + "require-dev": { + "laravel/pint": "^1.29", + "pestphp/pest": "^4.4", + "pestphp/pest-plugin-laravel": "^4.1", + "orchestra/testbench": "^11.1" } } diff --git a/config/alt-redirect.php b/config/alt-redirect.php index 6136d21..fb13ed6 100644 --- a/config/alt-redirect.php +++ b/config/alt-redirect.php @@ -16,6 +16,19 @@ | */ - 'headers' => [] + 'headers' => [], + /* + |-------------------------------------------------------------------------- + | Driver + |-------------------------------------------------------------------------- + | + | Here you may specify the driver to use for storing redirects. + | + | Supported: "file", "database" + | + */ + + 'driver' => 'file', + // 'driver' => 'database', ]; diff --git a/phpunit.xml b/phpunit.xml new file mode 100644 index 0000000..2652880 --- /dev/null +++ b/phpunit.xml @@ -0,0 +1,20 @@ + + + + + ./tests/Feature + + + ./tests/Unit + + + + + + + + diff --git a/resources/blueprints/redirects.yaml b/resources/blueprints/redirects.yaml index 89d8fd7..dcc47ca 100644 --- a/resources/blueprints/redirects.yaml +++ b/resources/blueprints/redirects.yaml @@ -60,6 +60,19 @@ tabs: hide_display: false width: 50 default: '301' + - + handle: is_regex + field: + type: toggle + display: 'Is Regex?' + instructions: 'Automatically detected, but can be overridden.' + listable: hidden + instructions_position: above + visibility: visible + replicator_preview: true + hide_display: false + width: 50 + default: false - handle: sites field: diff --git a/routes/cp.php b/routes/cp.php index 1133017..f594bcd 100644 --- a/routes/cp.php +++ b/routes/cp.php @@ -1,7 +1,8 @@ ['statamic.cp.authenticated'], 'namespace' => 'AltDesign\AltRedirect\Http\Controllers'], function() { +Route::group(['middleware' => ['statamic.cp.authenticated'], 'namespace' => 'AltDesign\AltRedirect\Http\Controllers'], function () { // Settings Route::get('/alt-design/alt-redirect/', 'AltRedirectController@index')->name('alt-redirect.index'); Route::post('/alt-design/alt-redirect/', 'AltRedirectController@create')->name('alt-redirect.create'); diff --git a/src/Console/Commands/DefaultQueryStringsCommand.php b/src/Console/Commands/DefaultQueryStringsCommand.php index 06e43e6..a718921 100644 --- a/src/Console/Commands/DefaultQueryStringsCommand.php +++ b/src/Console/Commands/DefaultQueryStringsCommand.php @@ -26,7 +26,7 @@ class DefaultQueryStringsCommand extends Command */ public function handle() { - if(!$this->confirm('Do you wish to (re)create the list of default query strings?')) { + if (! $this->confirm('Do you wish to (re)create the list of default query strings?')) { $this->error('User aborted Command'); } diff --git a/src/Console/Commands/MigrateFileRedirectsCommand.php b/src/Console/Commands/MigrateFileRedirectsCommand.php new file mode 100644 index 0000000..3e7c8db --- /dev/null +++ b/src/Console/Commands/MigrateFileRedirectsCommand.php @@ -0,0 +1,75 @@ +error('The required database tables do not exist.'); + $this->warn('Please run the following commands to set up the database:'); + $this->line('1. php artisan vendor:publish --tag=alt-redirect-migrations'); + $this->line('2. php artisan migrate'); + + return 1; + } + + if (! $this->confirm('This will migrate all file-based redirects and query strings to the database. Do you wish to continue?')) { + $this->error('User aborted command.'); + + return 1; + } + + $fileRepository = new FileRepository; + $databaseRepository = new DatabaseRepository; + + $this->info('Migrating redirects...'); + $redirects = $fileRepository->all('redirects'); + if (count($redirects) > 0) { + foreach ($redirects as $redirect) { + $databaseRepository->save('redirects', $redirect); + } + $this->info(count($redirects).' redirects migrated.'); + } else { + $this->info('No redirects found to migrate.'); + } + + $this->info('Migrating query strings...'); + $queryStrings = $fileRepository->all('query-strings'); + if (count($queryStrings) > 0) { + foreach ($queryStrings as $queryString) { + $databaseRepository->save('query-strings', $queryString); + } + $this->info(count($queryStrings).' query strings migrated.'); + } else { + $this->info('No query strings found to migrate.'); + } + + $this->info('Migration completed successfully!'); + + return 0; + } +} diff --git a/src/Console/Commands/ReScanRegexCommand.php b/src/Console/Commands/ReScanRegexCommand.php new file mode 100644 index 0000000..09af0e0 --- /dev/null +++ b/src/Console/Commands/ReScanRegexCommand.php @@ -0,0 +1,44 @@ +all('redirects'); + + $count = 0; + foreach ($redirects as $redirect) { + if (! isset($redirect['from'])) { + $this->error('Redirect missing "from" key: ' . ($redirect['id'] ?? 'unknown ID')); + continue; + } + + $isRegexBefore = (bool) ($redirect['is_regex'] ?? false); + $isRegexAfter = URISupport::isRegex($redirect['from']); + + if ($isRegexBefore !== $isRegexAfter) { + $redirect['is_regex'] = $isRegexAfter; + $repository->save('redirects', $redirect); + $count++; + } + } + + $this->info("Successfully updated $count redirects."); + } + + protected function isRegex(string $str): bool + { + return URISupport::isRegex($str); + } +} diff --git a/src/Contracts/RepositoryInterface.php b/src/Contracts/RepositoryInterface.php new file mode 100644 index 0000000..849fa40 --- /dev/null +++ b/src/Contracts/RepositoryInterface.php @@ -0,0 +1,18 @@ +string('id')->primary(); + $table->string('from')->index(); + $table->string('to'); + $table->integer('redirect_type')->default(301); + $table->boolean('is_regex')->default(false); + $table->json('sites')->nullable(); + $table->timestamps(); + }); + + Schema::create('alt_query_strings', function (Blueprint $table) { + $table->string('id')->primary(); + $table->string('query_string')->index(); + $table->boolean('strip')->default(false); + $table->json('sites')->nullable(); + $table->timestamps(); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('alt_redirects'); + Schema::dropIfExists('alt_query_strings'); + } +}; diff --git a/src/Database/Migrations/2024_04_09_000001_seed_default_query_strings.php b/src/Database/Migrations/2024_04_09_000001_seed_default_query_strings.php new file mode 100644 index 0000000..0e7ca1f --- /dev/null +++ b/src/Database/Migrations/2024_04_09_000001_seed_default_query_strings.php @@ -0,0 +1,23 @@ +makeDefaultQueryStrings(); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + // No need to remove them on down, they are part of the table data + } +}; diff --git a/src/Helpers/Data.php b/src/Helpers/Data.php deleted file mode 100644 index 531937b..0000000 --- a/src/Helpers/Data.php +++ /dev/null @@ -1,129 +0,0 @@ - [ - 'content/alt-redirect', - 'content/alt-redirect/alt-regex' - ], - 'query-strings' => [ - 'content/alt-redirect/query-strings' - ], - ]; - - public function __construct($type, $onlyRegex = false) - { - $this->type = $type; - - // New up Stat File Manager - $this->manager = new Manager(); - - // Check redirect folder exists - $this->checkOrMakeDirectories(); - - if(!$onlyRegex) { - $allRedirects = []; - foreach($this->types[$this->type] as $path) { - $filePath = base_path($path); - $allRedirects = array_merge($allRedirects, File::files($filePath)); - } - - $allRedirects = collect($allRedirects)->sortByDesc(function ($file) { - return $file->getCTime(); - }); - foreach ($allRedirects as $redirect) { - $data = Yaml::parse(File::get($redirect)); - $this->data[] = $data; - } - } - - $allRegexRedirects = File::allFiles(base_path('/content/alt-redirect/alt-regex')); - $allRegexRedirects = collect($allRegexRedirects)->sortBy(function ($file) { - return $file->getCTime(); - }); - foreach ($allRegexRedirects as $redirect) { - $data = Yaml::parse(File::get($redirect)); - $this->regexData[] = $data; - } - } - - public function checkOrMakeDirectories() - { - foreach($this->types as $type) { - foreach($type as $directory) { - if (!$this->manager->disk()->exists($directory)) { - $this->manager->disk()->makeDirectory($directory); - } - } - } - } - - public function getByKey($key, $value) : array | null - { - $data = collect($this->data); - $result = $data->firstWhere($key, $value); - if ($result) { - return $result; - } - return null; - } - - public function get($key) - { - if (!isset($this->data[$key])) { - return null; - } - return $this->data[$key]; - } - - public function set($key, $value) - { - $this->data[$key] = $value; - - Yaml::dump($this->data, $this->currentFile); - } - - public function all() - { - return $this->data; - } - - public function setAll($data) - { - $this->data = $data; - - switch ($this->type) { - case 'redirects': - if (strpos($data['from'], '(.*)') === false) { - $this->manager->disk()->put('content/alt-redirect/' . hash('sha512', (base64_encode($data['from']))) . '.yaml', Yaml::dump($this->data)); - return; - } - $this->manager->disk()->put('content/alt-redirect/alt-regex/' . hash('sha512', base64_encode($data['id'])) . '.yaml', Yaml::dump($this->data)); - break; - case 'query-strings': - $this->manager->disk()->put('content/alt-redirect/query-strings/' . hash('sha512', (base64_encode($data['query_string']))) . '.yaml', Yaml::dump($this->data)); - break; - } - } - - public function saveAll($data) - { - foreach ($data as $redirect) { - $this->setAll($redirect); - } - } - -} diff --git a/src/Helpers/DefaultQueryStrings.php b/src/Helpers/DefaultQueryStrings.php index f21e4dd..8395fd1 100644 --- a/src/Helpers/DefaultQueryStrings.php +++ b/src/Helpers/DefaultQueryStrings.php @@ -1,39 +1,42 @@ -setDirectory(__DIR__.'/../../resources/blueprints')->find('query-strings'); // Add the values to the array - foreach($this->defaultQueryStrings as $query) { + foreach ($this->defaultQueryStrings as $query) { $fields = $blueprint->fields(); $arr = [ - 'id' => uniqid(), + 'id' => md5($query), 'sites' => ['default'], 'query_string' => $query, + 'strip' => true, ]; $fields = $fields->addValues($arr); $fields->validate(); - $data->setAll($fields->process()->values()->toArray()); + $repository->save('query-strings', $fields->process()->values()->toArray()); } - (new Manager())->disk()->makeDirectory('content/alt-redirect/.installed'); } } diff --git a/src/Helpers/URISupport.php b/src/Helpers/URISupport.php index 33c27d3..30eba81 100644 --- a/src/Helpers/URISupport.php +++ b/src/Helpers/URISupport.php @@ -4,21 +4,20 @@ namespace AltDesign\AltRedirect\Helpers; +use AltDesign\AltRedirect\Contracts\RepositoryInterface; use Illuminate\Support\Str; class URISupport { /** - * Returns the current URI without any query strings for redirect matching. + * Returns the current path for simple redirect matching. * - * @return string $uri + * @return string $path */ - public static function uriWithFilteredQueryStrings() : string + public static function path(): string { $request = request(); - // Return just the path without any query parameters - // Query parameters will be handled separately in redirectWithPreservedParams return Str::replace( $request->root(), '', @@ -26,4 +25,92 @@ public static function uriWithFilteredQueryStrings() : string ); } + /** + * Returns the current URI with filtered query strings for regex redirect matching. + * + * @param string|null $path Optional path to use instead of current path. + * @return string $uri + */ + public static function uriWithFilteredQueryStrings(?string $path = null): string + { + $request = request(); + $path = $path ?? self::path(); + $queryString = $request->getQueryString(); + + if (! $queryString) { + return $path; + } + + $stripKeys = []; + try { + $repository = app(RepositoryInterface::class); + foreach ($repository->all('query-strings') as $item) { + if ($item['strip'] ?? false) { + $stripKeys[] = strtolower($item['query_string']); + } + } + } catch (\Exception $e) { + // Fallback if repository is not available + } + + parse_str($queryString, $params); + $filteredParams = []; + foreach ($params as $key => $value) { + if (! in_array(strtolower((string) $key), $stripKeys)) { + $filteredParams[$key] = $value; + } + } + + if (empty($filteredParams)) { + return $path; + } + + return $path.'?'.http_build_query($filteredParams); + } + + /** + * Returns true if the given string is a regex redirect. + * + * @param string $str + * @return bool + */ + public static function isRegex(string $str): bool + { + if (empty($str)) { + return false; + } + + // 1. Check if it's a valid delimited PHP regex. + if (@preg_match($str, '') !== false) { + // BUT, if the delimiter is '/', it might just be a simple path. + // Many URLs start and end with '/'. + if (str_starts_with($str, '/') && str_ends_with($str, '/')) { + // High confidence indicators + if (preg_match('/[\*\^\$\|{}\\\\[\]]/', $str)) { + return true; + } + // Grouping with wildcards/quantifiers (e.g. (.+)) + if (preg_match('/\([^\)]*[\.\+\?\*][^\)]*\)/', $str)) { + return true; + } + + return false; + } + + // If it uses other delimiters (like # or ~), assume the user knows what they're doing. + return true; + } + + // 2. Check for naked regex indicators. + // High confidence indicators + if (preg_match('/[\*\^\$\|{}\\\\[\]]/', $str)) { + return @preg_match('#'.$str.'#', '') !== false; + } + // Grouping with wildcards/quantifiers (e.g. (.+)) + if (preg_match('/\([^\)]*[\.\+\?\*][^\)]*\)/', $str)) { + return @preg_match('#'.$str.'#', '') !== false; + } + + return false; + } } diff --git a/src/Http/Controllers/AltRedirectController.php b/src/Http/Controllers/AltRedirectController.php index 5e7f568..862c8ac 100644 --- a/src/Http/Controllers/AltRedirectController.php +++ b/src/Http/Controllers/AltRedirectController.php @@ -2,23 +2,24 @@ namespace AltDesign\AltRedirect\Http\Controllers; -use AltDesign\AltRedirect\Helpers\Data; +use AltDesign\AltRedirect\Contracts\RepositoryInterface; use Illuminate\Http\Request; use Inertia\Inertia; use Statamic\Fields\Blueprint; use Statamic\Fields\BlueprintRepository; -use Statamic\Filesystem\Manager; class AltRedirectController { private string $type = 'redirects'; + private array $actions = [ 'redirects' => 'alt-redirect.create', - 'query-strings' => 'alt-redirect.query-strings.create' + 'query-strings' => 'alt-redirect.query-strings.create', ]; + private array $titles = [ 'redirects' => 'Alt Redirect', - 'query-strings' => 'Alt Redirect - Query Strings' + 'query-strings' => 'Alt Redirect - Query Strings', ]; private array $instructions = [ @@ -29,7 +30,7 @@ class AltRedirectController // Work out what page we're handling public function __construct() { - if(collect([request()->path(), request()->input('type')])->filter(fn($value) => !empty($value) && str_contains($value, 'query-strings'))->isNotEmpty()) { + if (collect([request()->path(), request()->input('type')])->filter(fn ($value) => ! empty($value) && str_contains($value, 'query-strings'))->isNotEmpty()) { $this->type = 'query-strings'; } } @@ -39,10 +40,9 @@ public function index() // Grab the old directory just in case $oldDirectory = Blueprint::directory(); - //Publish form + // Publish form // Get an array of values - $data = new Data($this->type); - $values = $data->all(); + $values = app(RepositoryInterface::class)->all($this->type); // Get a blueprint.So $blueprint = with(new BlueprintRepository)->setDirectory(__DIR__.'/../../../resources/blueprints')->find($this->type); @@ -72,7 +72,7 @@ public function index() public function create(Request $request) { - $data = new Data($this->type); + $repository = app(RepositoryInterface::class); // Get a blueprint. $blueprint = with(new BlueprintRepository)->setDirectory(__DIR__.'/../../../resources/blueprints')->find($this->type); @@ -82,7 +82,9 @@ public function create(Request $request) // Add the values to the array $arr = $request->all(); - $arr['id'] = uniqid(); + if (empty($arr['id'])) { + $arr['id'] = uniqid(); + } // Avoid looping redirects (caught by validation, but give a more helpful error) if (($this->type == 'redirects') && ($arr['to'] === $arr['from'])) { @@ -100,51 +102,36 @@ public function create(Request $request) $fields = $fields->addValues($arr); $fields->validate(); - $data->setAll($fields->process()->values()->toArray()); - - $data = new Data($this->type); + $repository->save($this->type, $fields->process()->values()->toArray()); return redirect()->back()->with([ - 'items' => $data->all(), + 'items' => $repository->all($this->type), ]); } public function delete(Request $request) { - $disk = (new Manager())->disk(); - switch($this->type) { - case "redirects" : - $disk->delete('content/alt-redirect/' . hash('sha512', base64_encode($request->from)) . '.yaml'); - $disk->delete('content/alt-redirect/' . base64_encode($request->from) . '.yaml'); - $disk->delete('content/alt-redirect/alt-regex/' . hash('sha512', base64_encode($request->id)) . '.yaml'); - $disk->delete('content/alt-redirect/alt-regex/'. base64_encode($request->id) . '.yaml'); - break; - case 'query-strings': - $disk->delete('content/alt-redirect/query-strings/' . hash('sha512', base64_encode($request->query_string)) . '.yaml'); - break; - } - - $data = new Data($this->type); - $values = $data->all(); + $repository = app(RepositoryInterface::class); + $repository->delete($this->type, $request->all()); return redirect()->back()->with([ - 'items' => $values, + 'items' => $repository->all($this->type), ]); } // Import and Export can stay hardcoded to redirects since I/O for Query Strings aren't supported atm public function export(Request $request) { - $data = new Data('redirects'); + $redirects = app(RepositoryInterface::class)->all('redirects'); - $callback = function () use ($data) { + $callback = function () use ($redirects) { $df = fopen('php://output', 'w'); fputcsv($df, ['from', 'to', 'redirect_type', 'sites', 'id']); // Use the data from the request instead of fetching from the database - foreach ($data->data as $row) { - fputcsv($df, [$row['from'], $row['to'], $row['redirect_type'], is_array($row['sites']) ? implode(',', $row['sites']) : $row['sites'], $row['id']]); // Adjust as per your data structure + foreach ($redirects as $row) { + fputcsv($df, [$row['from'], $row['to'], $row['redirect_type'], is_array($row['sites'] ?? null) ? implode(',', $row['sites']) : ($row['sites'] ?? ''), $row['id']]); // Adjust as per your data structure } fclose($df); @@ -158,8 +145,8 @@ public function export(Request $request) public function import(Request $request) { - $data = new Data($this->type); - $currentData = $data->all(); + $repository = app(RepositoryInterface::class); + $currentData = $repository->all('redirects'); $file = $request->file('file'); $handle = fopen($file->path(), 'r'); @@ -170,7 +157,7 @@ public function import(Request $request) 'from' => $row[0], 'to' => $row[1], 'redirect_type' => $row[2], - 'sites' => !empty($row[3] ?? false) ? explode(',', $row[3]) : ['default'], + 'sites' => ! empty($row[3] ?? false) ? explode(',', $row[3]) : ['default'], 'id' => ! empty($row[4] ?? false) ? $row[4] : uniqid(), ]; // Skip the redirect if it'll create an infinite loop (handles empty redirects too) @@ -190,42 +177,39 @@ public function import(Request $request) // Close the file handle fclose($handle); } - $data = new Data('redirects'); - $data->saveAll($currentData); + $repository->saveAll('redirects', $currentData); return redirect()->back()->with([ - 'items' => $data->all(), + 'items' => $repository->all('redirects'), ]); } // Toggle a key in a certain item and return the data afterwards public function toggle(Request $request) { - $toggleKey = $request->get('toggleKey'); - $index = $request->get('index'); - $data = new Data($this->type); + $toggleKey = $request->get('toggleKey'); + $index = $request->get('index'); + $repository = app(RepositoryInterface::class); switch ($this->type) { case 'query-strings': - $item = $data->getByKey('query_string', $index); + $item = $repository->find($this->type, 'query_string', $index); if ($item === null) { return response('Error finding item', 500); } - if (!isset($item[$toggleKey])) { + if (! isset($item[$toggleKey])) { $item[$toggleKey] = false; } - $item[$toggleKey] = !$item[$toggleKey]; - $data->setAll($item); + $item[$toggleKey] = ! $item[$toggleKey]; + $repository->save($this->type, $item); break; default: return response('Method not implemented', 500); } - $data = new Data($this->type); - $values = $data->all(); return redirect()->back()->with([ - 'items' => $values, + 'items' => $repository->all($this->type), ]); } } diff --git a/src/Http/Middleware/CheckForRedirects.php b/src/Http/Middleware/CheckForRedirects.php index 9598e9c..57c1f75 100644 --- a/src/Http/Middleware/CheckForRedirects.php +++ b/src/Http/Middleware/CheckForRedirects.php @@ -1,72 +1,84 @@ -redirectWithPreservedParams($to , $redirect['redirect_type'] ?? 301); - } + $path = URISupport::uriWithFilteredQueryStrings($pathOnly); + $permuPath = URISupport::uriWithFilteredQueryStrings($permuPathOnly); + + // Check simple redirects + $redirect = $repository->find('redirects', 'from', $path) ?? + $repository->find('redirects', 'from', $permuPath) ?? + $repository->find('redirects', 'from', $pathOnly) ?? + $repository->find('redirects', 'from', $permuPathOnly); + + if ($redirect) { + $to = $redirect['to'] ?? '/'; + // There's no need to redirect. + if ($to === $path || $to === $permuPath || $to === $pathOnly || $to === $permuPathOnly) { + return $next($request); + } + if (! ($redirect['sites'] ?? false) || (in_array(Site::current(), $redirect['sites']))) { + return $this->redirectWithPreservedParams($to, $redirect['redirect_type'] ?? 301); } } - //Regex checks - $data = new Data('redirect', true); - foreach ($data->regexData as $redirect) { - if (preg_match('#' . $redirect['from'] . '#', $uri)) { - $redirectTo = preg_replace('#' . $redirect['from'] . '#', $redirect['to'], $uri); - if (!($redirect['sites'] ?? false) || (in_array(Site::current(), $redirect['sites']))) { + // Regex checks + $uri = URISupport::uriWithFilteredQueryStrings(); + foreach ($repository->getRegex('redirects') as $redirect) { + $from = $redirect['from']; + + // Determine if the pattern is already delimited + $isDelimited = @preg_match($from, '') !== false; + $pattern = $isDelimited ? $from : '#' . $from . '#'; + + // Handle the ? hack for non-delimited patterns + if (!$isDelimited && ! preg_match($pattern, $uri) && strpos($from, '?') !== false && strpos($from, '\?') === false) { + $pattern = '#' . str_replace('?', '\?', $from) . '#'; + } + + if (preg_match($pattern, $uri)) { + $redirectTo = preg_replace($pattern, $redirect['to'], $uri); + if (! ($redirect['sites'] ?? false) || (in_array(Site::current(), $redirect['sites']))) { return $this->redirectWithPreservedParams($redirectTo ?? '/', $redirect['redirect_type'] ?? 301); } } } - //No redirect + + // No redirect return $next($request); } private function redirectWithPreservedParams($to, $status) { $stripKeys = []; - foreach ((new Data('query-strings'))->all() as $item) { + $repository = app(RepositoryInterface::class); + foreach ($repository->all('query-strings') as $item) { if ($item['strip'] ?? false) { $stripKeys[] = strtolower($item['query_string']); } @@ -90,12 +102,12 @@ private function redirectWithPreservedParams($to, $status) parse_str($decodedQueryString, $parsedParams); - foreach($parsedParams as $key => $value) { + foreach ($parsedParams as $key => $value) { $normalizedKey = strtolower($key); // Strip only parameters marked with strip:true, preserve all others - if (!in_array($normalizedKey, $stripKeys) && !isset($seenKeys[$normalizedKey])) { + if (! in_array($normalizedKey, $stripKeys) && ! isset($seenKeys[$normalizedKey])) { $seenKeys[$normalizedKey] = true; - $filteredStrings[] = sprintf("%s=%s", urlencode($key), urlencode($value)); + $filteredStrings[] = sprintf('%s=%s', urlencode($key), urlencode($value)); } } } @@ -104,7 +116,7 @@ private function redirectWithPreservedParams($to, $status) $to .= str_contains($to, '?') ? '&' : '?'; $to .= implode('&', $filteredStrings); } - return redirect($to , $status, config('alt-redirect.headers', [])); + + return redirect($to, $status, config('alt-redirect.headers', [])); } } - diff --git a/src/Models/QueryString.php b/src/Models/QueryString.php new file mode 100644 index 0000000..558be4f --- /dev/null +++ b/src/Models/QueryString.php @@ -0,0 +1,26 @@ + 'boolean', + 'sites' => 'array', + ]; + + public $incrementing = false; + + protected $keyType = 'string'; +} diff --git a/src/Models/Redirect.php b/src/Models/Redirect.php new file mode 100644 index 0000000..bf32c84 --- /dev/null +++ b/src/Models/Redirect.php @@ -0,0 +1,28 @@ + 'array', + 'is_regex' => 'boolean', + ]; + + public $incrementing = false; + + protected $keyType = 'string'; +} diff --git a/src/Repositories/DatabaseRepository.php b/src/Repositories/DatabaseRepository.php new file mode 100644 index 0000000..1d16540 --- /dev/null +++ b/src/Repositories/DatabaseRepository.php @@ -0,0 +1,96 @@ +toArray(); + } elseif ($type === 'query-strings') { + return QueryString::all()->toArray(); + } + + return []; + } + + public function getRegex(string $type): array + { + if ($type !== 'redirects') { + return []; + } + + return Redirect::where('is_regex', true)->get()->toArray(); + } + + public function find(string $type, string $key, $value): ?array + { + if ($type === 'redirects') { + $model = Redirect::where($key, $value)->first(); + + return $model ? $model->toArray() : null; + } elseif ($type === 'query-strings') { + $model = QueryString::where($key, $value)->first(); + + return $model ? $model->toArray() : null; + } + + return null; + } + + public function save(string $type, array $data): void + { + if ($type === 'redirects') { + if (! isset($data['from'])) { + return; + } + $data['is_regex'] = URISupport::isRegex($data['from']); + if (empty($data['id'])) { + $existing = Redirect::where('from', $data['from'])->first(); + $data['id'] = $existing ? $existing->id : (string) uniqid(); + } + Redirect::updateOrCreate(['id' => $data['id']], $data); + } elseif ($type === 'query-strings') { + if (empty($data['id'])) { + $existing = QueryString::where('query_string', $data['query_string'])->first(); + $data['id'] = $existing ? $existing->id : (string) uniqid(); + } + QueryString::updateOrCreate(['id' => $data['id']], $data); + } + } + + protected function isRegex(string $str): bool + { + return URISupport::isRegex($str); + } + + public function saveAll(string $type, array $data): void + { + foreach ($data as $item) { + $this->save($type, $item); + } + } + + public function delete(string $type, array $data): void + { + if ($type === 'redirects') { + if (isset($data['id'])) { + Redirect::where('id', $data['id'])->delete(); + } elseif (isset($data['from'])) { + Redirect::where('from', $data['from'])->delete(); + } + } elseif ($type === 'query-strings') { + if (isset($data['id'])) { + QueryString::where('id', $data['id'])->delete(); + } elseif (isset($data['query_string'])) { + QueryString::where('query_string', $data['query_string'])->delete(); + } + } + } +} diff --git a/src/Repositories/FileRepository.php b/src/Repositories/FileRepository.php new file mode 100644 index 0000000..ba43a19 --- /dev/null +++ b/src/Repositories/FileRepository.php @@ -0,0 +1,171 @@ + [ + 'content/alt-redirect', + 'content/alt-redirect/alt-regex', + ], + 'query-strings' => [ + 'content/alt-redirect/query-strings', + ], + ]; + + public function __construct() + { + $this->manager = new Manager; + $this->checkOrMakeDirectories(); + } + + public function all(string $type): array + { + if (! isset($this->paths[$type])) { + return []; + } + + $allData = []; + $disk = $this->manager->disk(); + foreach ($this->paths[$type] as $path) { + if (! $disk->exists($path)) { + continue; + } + $allData = array_merge($allData, $disk->getFiles($path)->filter(fn ($f) => str_ends_with($f, '.yaml'))->all()); + } + + $allData = collect($allData)->sortByDesc(function ($file) use ($disk) { + return $disk->lastModified($file); + }); + + $results = []; + foreach ($allData as $file) { + $results[] = YAML::parse($disk->get($file)); + } + + return $results; + } + + public function getRegex(string $type): array + { + if ($type !== 'redirects' && $type !== 'redirect') { + return []; + } + + $disk = $this->manager->disk(); + if (! $disk->exists('content/alt-redirect/alt-regex')) { + return []; + } + + $allRegexRedirects = $disk->getFilesRecursively('content/alt-redirect/alt-regex')->all(); + $allRegexRedirects = collect($allRegexRedirects)->sortBy(function ($file) use ($disk) { + return $disk->lastModified($file); + }); + + $results = []; + foreach ($allRegexRedirects as $file) { + $results[] = YAML::parse($disk->get($file)); + } + + return $results; + } + + public function find(string $type, string $key, $value): ?array + { + if ($type === 'redirects' && $key === 'from') { + $b64 = base64_encode($value); + $possibleFiles = [ + 'content/alt-redirect/'.$b64.'.yaml', + 'content/alt-redirect/'.hash('sha512', $b64).'.yaml', + ]; + + foreach ($possibleFiles as $file) { + if ($this->manager->disk()->exists($file)) { + return YAML::parse($this->manager->disk()->get($file)); + } + } + } + + $data = collect($this->all($type)); + + return $data->firstWhere($key, $value); + } + + public function save(string $type, array $data): void + { + switch ($type) { + case 'redirects': + if (! isset($data['from'])) { + return; + } + if (! URISupport::isRegex($data['from'])) { + $this->manager->disk()->put('content/alt-redirect/'.hash('sha512', (base64_encode($data['from']))).'.yaml', YAML::dump($data)); + + return; + } + $this->manager->disk()->put('content/alt-redirect/alt-regex/'.hash('sha512', base64_encode($data['id'])).'.yaml', YAML::dump($data)); + break; + case 'query-strings': + $this->manager->disk()->put('content/alt-redirect/query-strings/'.hash('sha512', (base64_encode($data['query_string']))).'.yaml', YAML::dump($data)); + break; + } + } + + protected function isRegex(string $str): bool + { + return URISupport::isRegex($str); + } + + public function saveAll(string $type, array $data): void + { + foreach ($data as $item) { + $this->save($type, $item); + } + } + + public function delete(string $type, array $data): void + { + $disk = $this->manager->disk(); + switch ($type) { + case 'redirects': + if (isset($data['from'])) { + $disk->delete('content/alt-redirect/'.hash('sha512', base64_encode($data['from'])).'.yaml'); + $disk->delete('content/alt-redirect/'.base64_encode($data['from']).'.yaml'); + } + if (isset($data['id'])) { + $disk->delete('content/alt-redirect/alt-regex/'.hash('sha512', base64_encode($data['id'])).'.yaml'); + $disk->delete('content/alt-redirect/alt-regex/'.base64_encode($data['id']).'.yaml'); + } + break; + case 'query-strings': + if (isset($data['query_string'])) { + $disk->delete('content/alt-redirect/query-strings/'.hash('sha512', base64_encode($data['query_string'])).'.yaml'); + } elseif (isset($data['id'])) { + $item = $this->find($type, 'id', $data['id']); + if ($item && isset($item['query_string'])) { + $disk->delete('content/alt-redirect/query-strings/'.hash('sha512', base64_encode($item['query_string'])).'.yaml'); + } + } + break; + } + } + + protected function checkOrMakeDirectories(): void + { + foreach ($this->paths as $type) { + foreach ($type as $directory) { + if (! $this->manager->disk()->exists($directory)) { + $this->manager->disk()->makeDirectory($directory); + } + } + } + } +} diff --git a/src/Repositories/RepositoryManager.php b/src/Repositories/RepositoryManager.php new file mode 100644 index 0000000..5a7d3fe --- /dev/null +++ b/src/Repositories/RepositoryManager.php @@ -0,0 +1,24 @@ +config->get('alt-redirect.driver', 'file'); + } + + public function createFileDriver(): RepositoryInterface + { + return new FileRepository; + } + + public function createDatabaseDriver(): RepositoryInterface + { + return new DatabaseRepository; + } +} diff --git a/src/ServiceProvider.php b/src/ServiceProvider.php index 6d4ca13..30edbf0 100644 --- a/src/ServiceProvider.php +++ b/src/ServiceProvider.php @@ -1,15 +1,20 @@ - [ 'resources/js/alt-redirect-addon.js', - 'resources/css/alt-redirect-addon.css' + 'resources/css/alt-redirect-addon.css', ], 'publicDirectory' => 'resources/dist', ]; protected $middlewareGroups = [ 'web' => [ - \AltDesign\AltRedirect\Http\Middleware\CheckForRedirects::class, - ] + CheckForRedirects::class, + ], ]; /** * Register our addon and child menus in the nav - * - * @return self */ - public function addToNav() : self + public function addToNav(): self { Nav::extend(function ($nav) { $nav->content('Alt Redirect') @@ -54,73 +57,71 @@ public function addToNav() : self /** * Register our permissions, so we can control who can see the settings. - * - * @return self */ - public function registerPermissions() : self + public function registerPermissions(): self { Permission::register('view alt-redirect') - ->label('View Alt Redirect Settings'); + ->label('View Alt Redirect Settings'); return $this; } /** * Register our artisan commands - * - * @return self */ - public function registerCommands() : self + public function registerCommands(): self { $this->commands([ DefaultQueryStringsCommand::class, + MigrateFileRedirectsCommand::class, + ReScanRegexCommand::class, ]); + return $this; } - /** - * Install the default query strings - * - * @return self - */ - public function installDefaultQueryStrings() : self + public function installDefaultQueryStrings(): self { + if (config('alt-redirect.driver') !== 'file') { + return $this; + } + // create the standard - if ($this->app->runningInConsole()) { - $disk = (new Manager())->disk(); - if (!$disk->exists('content/alt-redirect/.installed')) { - (new DefaultQueryStrings)->makeDefaultQueryStrings(); - } + $disk = (new Manager)->disk(); + if (! $disk->exists('content/alt-redirect/.installed')) { + (new DefaultQueryStrings)->makeDefaultQueryStrings(); + $disk->put('content/alt-redirect/.installed', ''); } + return $this; } - public function configureSSG() : self + public function configureSSG(): self { - if (!class_exists(SSG::class)) { + if (! class_exists(SSG::class)) { return $this; } SSG::after(function () { $dest = config('statamic.ssg.destination'); $base = rtrim(config('statamic.ssg.base_url'), '/'); // remove trailing slash - $disk = (new Manager())->disk(); + $disk = (new Manager)->disk(); - $redirects = (new Data('redirects'))->all(); - print("Found " . count($redirects) . " redirects\n"); + $redirects = app(RepositoryInterface::class)->all('redirects'); + echo 'Found '.count($redirects)." redirects\n"; $generated = $directories = 0; - foreach( $redirects as $redirect ) { - $fromDir = $dest . $redirect['from']; + foreach ($redirects as $redirect) { + $fromDir = $dest.$redirect['from']; $from = sprintf('%s%sindex.html', $fromDir, (Str::endsWith($fromDir, '/') ? '' : '/') ); - $to = $base . $redirect['to']; + $to = $base.$redirect['to']; - if (!$disk->exists($from)) { + if (! $disk->exists($from)) { $contents = view('alt-redirect::ssg', ['to' => $to]); - if (!$disk->isDirectory($fromDir)) { + if (! $disk->isDirectory($fromDir)) { mkdir($fromDir, 0777, true); $directories++; } @@ -130,17 +131,45 @@ public function configureSSG() : self } } - print("Generated $generated redirect files in $directories new directories\n"); + echo "Generated $generated redirect files in $directories new directories\n"; }); + return $this; } + public function register() + { + parent::register(); + + $this->mergeConfigFrom(__DIR__.'/../config/alt-redirect.php', 'alt-redirect'); + + $this->app->singleton(RepositoryManager::class, function ($app) { + return new RepositoryManager($app); + }); + + $this->app->bind(RepositoryInterface::class, function ($app) { + return $app->make(RepositoryManager::class)->driver(); + }); + } + public function bootAddon() { + if ($this->app->runningInConsole()) { + $this->loadMigrationsFrom(__DIR__.'/Database/Migrations'); + + $this->publishes([ + __DIR__.'/../config/alt-redirect.php' => config_path('alt-redirect.php'), + ], 'alt-redirect-config'); + + $this->publishes([ + __DIR__.'/Database/Migrations' => database_path('migrations'), + ], 'alt-redirect-migrations'); + } + $this->addToNav() ->registerPermissions() ->registerCommands() - ->configureSSG(); + ->configureSSG() + ->installDefaultQueryStrings(); } } - diff --git a/tests/Feature/ControllerDuplicateTest.php b/tests/Feature/ControllerDuplicateTest.php new file mode 100644 index 0000000..70d63fe --- /dev/null +++ b/tests/Feature/ControllerDuplicateTest.php @@ -0,0 +1,69 @@ + 'database']); + // Authenticate as a user to bypass middleware if necessary + $user = User::make()->makeSuper()->save(); + $this->actingAs($user); +}); + +it('does not create duplicate redirects when updating', function () { + // 1. Create an initial redirect in the database + $redirect = Redirect::create([ + 'id' => 'existing-id', + 'from' => '/old-path', + 'to' => '/initial-new-path', + 'redirect_type' => 301, + 'sites' => ['default'], + ]); + + // 2. Post to the create endpoint with the SAME ID but different 'to' path + $response = $this->post(cp_route('alt-redirect.create'), [ + 'id' => 'existing-id', + 'from' => '/old-path', + 'to' => '/updated-new-path', + 'redirect_type' => 301, + 'sites' => ['default'], + ]); + + $response->assertStatus(302); // Should redirect back + + // 3. Verify that we still only have ONE record and it was UPDATED + $redirects = Redirect::where('from', '/old-path')->get(); + + expect($redirects->count())->toBe(1); + expect($redirects->first()->id)->toBe('existing-id'); + expect($redirects->first()->to)->toBe('/updated-new-path'); +}); + +it('does not create duplicate query strings when updating', function () { + // 1. Create an initial query string + $qs = QueryString::create([ + 'id' => 'existing-qs-id', + 'query_string' => 'gclid', + 'strip' => true, + 'sites' => ['default'], + ]); + + // 2. Post to the create endpoint with the SAME ID but different 'strip' value + $response = $this->post(cp_route('alt-redirect.query-strings.create'), [ + 'type' => 'query-strings', + 'id' => 'existing-qs-id', + 'query_string' => 'gclid', + 'strip' => false, + 'sites' => ['default'], + ]); + + $response->assertStatus(302); + + // 3. Verify that we still only have ONE record and it was UPDATED + $queryStrings = QueryString::where('query_string', 'gclid')->get(); + + expect($queryStrings->count())->toBe(1); + expect($queryStrings->first()->id)->toBe('existing-qs-id'); + expect($queryStrings->first()->strip)->toBe(false); +}); diff --git a/tests/Feature/DatabaseControlPanelTest.php b/tests/Feature/DatabaseControlPanelTest.php new file mode 100644 index 0000000..a74753d --- /dev/null +++ b/tests/Feature/DatabaseControlPanelTest.php @@ -0,0 +1,22 @@ + 'database']); + $this->user = User::make()->makeSuper()->save(); +}); + +it('can access the redirects index page', function () { + $this->actingAs($this->user) + ->get(cp_route('alt-redirect.index')) + ->assertStatus(200) + ->assertSee('Alt Redirect'); +}); + +it('can access the query strings index page', function () { + $this->actingAs($this->user) + ->get(cp_route('alt-redirect.query-strings.index')) + ->assertStatus(200) + ->assertSee('Alt Redirect - Query Strings'); +}); diff --git a/tests/Feature/DatabaseCsvTest.php b/tests/Feature/DatabaseCsvTest.php new file mode 100644 index 0000000..683b63a --- /dev/null +++ b/tests/Feature/DatabaseCsvTest.php @@ -0,0 +1,56 @@ + 'database']); + $this->user = User::make()->makeSuper()->save(); +}); + +it('can export redirects as csv', function () { + $repository = app(RepositoryInterface::class); + $repository->save('redirects', [ + 'id' => 'export-test-1', + 'from' => '/export-old-1', + 'to' => '/export-new-1', + 'redirect_type' => 301, + 'sites' => ['default'], + ]); + + $response = $this->actingAs($this->user) + ->get(cp_route('alt-redirect.export')); + + $response->assertStatus(200); + $response->assertHeader('Content-Type', 'text/csv; charset=UTF-8'); + + $content = $response->streamedContent(); + $rows = explode("\n", trim($content)); + + expect($rows)->toHaveCount(2); + expect($rows[0])->toBe('from,to,redirect_type,sites,id'); + expect($rows[1])->toContain('/export-old-1,/export-new-1,301,default,export-test-1'); +}); + +it('can import redirects from csv', function () { + $csvContent = "from,to,redirect_type,sites,id\n"; + $csvContent .= "/import-old-1,/import-new-1,301,default,import-test-1\n"; + $csvContent .= '/import-old-2,/import-new-2,302,"default,other",import-test-2'; + + $file = UploadedFile::fake()->createWithContent('redirects.csv', $csvContent); + + $response = $this->actingAs($this->user) + ->post(cp_route('alt-redirect.import'), [ + 'file' => $file, + ]); + + $response->assertRedirect(); + + $repository = app(RepositoryInterface::class); + $redirects = $repository->all('redirects'); + + expect($redirects)->toHaveCount(2); + expect($redirects[0]['from'])->toBe('/import-old-1'); + expect($redirects[1]['sites'])->toBe(['default', 'other']); +}); diff --git a/tests/Feature/DatabaseRedirectTest.php b/tests/Feature/DatabaseRedirectTest.php new file mode 100644 index 0000000..d4ba8f6 --- /dev/null +++ b/tests/Feature/DatabaseRedirectTest.php @@ -0,0 +1,154 @@ + 'database']); +}); + +it('can redirect a simple path', function () { + $repository = app(RepositoryInterface::class); + $repository->save('redirects', [ + 'id' => 'simple-test-database', + 'from' => '/old-path-database', + 'to' => '/new-path-database', + 'redirect_type' => 301, + 'sites' => ['default'], + ]); + + $this->get('/old-path-database') + ->assertRedirect('/new-path-database') + ->assertStatus(301); +}); + +it('can redirect with regex', function () { + $repository = app(RepositoryInterface::class); + $repository->save('redirects', [ + 'id' => 'regex-test-database', + 'from' => '/old-database/(.*)', + 'to' => '/new-database/$1', + 'redirect_type' => 302, + 'sites' => ['default'], + ]); + + $this->get('/old-database/something') + ->assertRedirect('/new-database/something') + ->assertStatus(302); +}); + +it('can redirect with query strings in regex', function () { + $repository = app(RepositoryInterface::class); + $repository->save('redirects', [ + 'id' => 'query-regex-test-database', + 'from' => '/test-database\?source=(.*)', + 'to' => '/beans-database/$1', + 'redirect_type' => 301, + 'sites' => ['default'], + ]); + + // It should match and redirect, but since 'source' is not stripped, it's added back to the target + $this->get('/test-database?source=mandem') + ->assertRedirect('/beans-database/mandem?source=mandem') + ->assertStatus(301); +}); + +it('can strip query strings', function () { + $repository = app(RepositoryInterface::class); + $repository->save('query-strings', [ + 'id' => 'strip-gclid-database', + 'query_string' => 'gclid', + 'strip' => true, + 'sites' => ['default'], + ]); + + $repository->save('redirects', [ + 'id' => 'simple-strip-test-database', + 'from' => '/old-strip-database', + 'to' => '/new-strip-database', + 'redirect_type' => 301, + 'sites' => ['default'], + ]); + + $this->get('/old-strip-database?gclid=123&other=456') + ->assertRedirect('/new-strip-database?other=456') + ->assertStatus(301); +}); + +it('can handle site specific redirects', function () { + $repository = app(RepositoryInterface::class); + $repository->save('redirects', [ + 'id' => 'site-test-database', + 'from' => '/only-default-database', + 'to' => '/matched-database', + 'redirect_type' => 301, + 'sites' => ['default'], + ]); + + Site::setCurrent('default'); + $this->get('/only-default-database')->assertRedirect('/matched-database'); + + Site::setCurrent('other'); + // If not default site, it should not match and return 404 + $this->get('/only-default-database')->assertNotFound(); +}); + +it('forgives unescaped question marks in regex', function () { + $repository = app(RepositoryInterface::class); + $repository->save('redirects', [ + 'id' => 'unescaped-test-database', + 'from' => '/test-unescaped-database?source=(.*)', // Unescaped ? + 'to' => '/new-test-database/$1', + 'redirect_type' => 301, + 'sites' => ['default'], + ]); + + $this->get('/test-unescaped-database?source=mandem') + ->assertRedirect('/new-test-database/mandem?source=mandem'); +}); + +it('can redirect with non-standard regex', function () { + $repository = app(RepositoryInterface::class); + $repository->save('redirects', [ + 'id' => 'non-standard-regex-database', + 'from' => '^/products/(.+)$', + 'to' => '/shop/$1', + 'redirect_type' => 301, + 'sites' => ['default'], + ]); + + $this->get('/products/shoes') + ->assertRedirect('/shop/shoes') + ->assertStatus(301); +}); + +it('can delete a redirect', function () { + $repository = app(RepositoryInterface::class); + $repository->save('redirects', [ + 'id' => 'delete-test-database', + 'from' => '/old-delete', + 'to' => '/new-delete', + 'redirect_type' => 301, + 'sites' => ['default'], + ]); + + $repository->delete('redirects', ['id' => 'delete-test-database', 'from' => '/old-delete']); + + $this->get('/old-delete')->assertNotFound(); +}); + +it('can delete a query string', function () { + $repository = app(RepositoryInterface::class); + $repository->save('query-strings', [ + 'id' => 'delete-qs-database', + 'query_string' => 'gclid-delete', + 'strip' => true, + 'sites' => ['default'], + ]); + + // This is what the frontend does: sends only query_string + $repository->delete('query-strings', ['query_string' => 'gclid-delete']); + + // Check it's gone + expect($repository->find('query-strings', 'query_string', 'gclid-delete'))->toBeNull(); +}); diff --git a/tests/Feature/DatabaseRepositoryTest.php b/tests/Feature/DatabaseRepositoryTest.php new file mode 100644 index 0000000..490d0ad --- /dev/null +++ b/tests/Feature/DatabaseRepositoryTest.php @@ -0,0 +1,111 @@ + '/old-url', + 'to' => '/new-url', + 'redirect_type' => '301', + 'sites' => ['default'], + ]; + + $repository->save('redirects', $data); + + $redirect = Redirect::where('from', '/old-url')->first(); + expect($redirect)->not->toBeNull(); + expect($redirect->id)->not->toBeEmpty(); + expect($redirect->to)->toBe('/new-url'); +}); + +it('saves query string without id by generating one', function () { + $repository = new DatabaseRepository(); + $data = [ + 'query_string' => 'foo=bar', + 'strip' => true, + 'sites' => ['default'], + ]; + + $repository->save('query-strings', $data); + + $qs = QueryString::where('query_string', 'foo=bar')->first(); + expect($qs)->not->toBeNull(); + expect($qs->id)->not->toBeEmpty(); +}); + +it('is idempotent when saving without id', function () { + $repository = new DatabaseRepository(); + $data = [ + 'from' => '/stable-url', + 'to' => '/target-1', + 'redirect_type' => '301', + 'sites' => ['default'], + ]; + + $repository->save('redirects', $data); + $redirect1 = Redirect::where('from', '/stable-url')->first(); + $id1 = $redirect1->id; + + // Save again with same from but different to + $data['to'] = '/target-2'; + $repository->save('redirects', $data); + $redirect2 = Redirect::where('from', '/stable-url')->first(); + + expect($redirect2->id)->toBe($id1); + expect($redirect2->to)->toBe('/target-2'); + expect(Redirect::where('from', '/stable-url')->count())->toBe(1); +}); + +it('is idempotent for query strings when saving without id', function () { + $repository = new DatabaseRepository(); + $data = [ + 'query_string' => 'stable-qs', + 'strip' => true, + 'sites' => ['default'], + ]; + + $repository->save('query-strings', $data); + $qs1 = QueryString::where('query_string', 'stable-qs')->first(); + $id1 = $qs1->id; + + // Save again with same query_string but different strip value + $data['strip'] = false; + $repository->save('query-strings', $data); + $qs2 = QueryString::where('query_string', 'stable-qs')->first(); + + expect($qs2->id)->toBe($id1); + expect($qs2->strip)->toBe(false); + expect(QueryString::where('query_string', 'stable-qs')->count())->toBe(1); +}); + +it('can migrate redirects without id from files to database', function () { + // 1. Setup file-based redirects without ID + $fileRepo = new FileRepository(); + $from = '/legacy-url'; + $fileRepo->save('redirects', [ + 'from' => $from, + 'to' => '/new-url', + 'redirect_type' => 301, + 'sites' => ['default'], + ]); + + // 2. Verify it's in the FileRepository and has no ID + $legacy = $fileRepo->find('redirects', 'from', $from); + expect($legacy)->not->toBeNull(); + expect($legacy)->not->toHaveKey('id'); + + // 3. Run migration command + $this->artisan('alt-redirect:migrate-file-redirects') + ->expectsConfirmation('This will migrate all file-based redirects and query strings to the database. Do you wish to continue?', 'yes') + ->assertExitCode(0); + + // 4. Verify it's in the database with a generated ID + $redirect = Redirect::where('from', $from)->first(); + expect($redirect)->not->toBeNull(); + expect($redirect->id)->not->toBeEmpty(); + expect($redirect->to)->toBe('/new-url'); +}); diff --git a/tests/Feature/DefaultQueryStringInstallationTest.php b/tests/Feature/DefaultQueryStringInstallationTest.php new file mode 100644 index 0000000..7a3457c --- /dev/null +++ b/tests/Feature/DefaultQueryStringInstallationTest.php @@ -0,0 +1,36 @@ + 'database']); + + $disk = (new Manager)->disk(); + $markerPath = 'content/alt-redirect/.installed'; + + // 1. Ensure marker is gone + if ($disk->exists($markerPath)) { + $disk->delete($markerPath); + } + + // 2. Call the installation logic (normally called during boot) + app(\AltDesign\AltRedirect\ServiceProvider::class, ['app' => app()])->installDefaultQueryStrings(); + + // 3. Verify marker DOES NOT EXIST + expect($disk->exists($markerPath))->toBeFalse(); +}); + +it('seeds the database via migration', function () { + config(['alt-redirect.driver' => 'database']); + + // We need to re-run the migration with the database driver active + // Since we are in a test, the migrations might have run with 'file' driver initially + (new \AltDesign\AltRedirect\Helpers\DefaultQueryStrings)->makeDefaultQueryStrings(); + + $repository = app(\AltDesign\AltRedirect\Repositories\RepositoryManager::class)->driver('database'); + $queryStrings = $repository->all('query-strings'); + + expect($queryStrings)->not->toBeEmpty(); + expect($queryStrings)->toHaveCount(9); +}); diff --git a/tests/Feature/FileControlPanelTest.php b/tests/Feature/FileControlPanelTest.php new file mode 100644 index 0000000..c7c85ff --- /dev/null +++ b/tests/Feature/FileControlPanelTest.php @@ -0,0 +1,22 @@ + 'file']); + $this->user = User::make()->makeSuper()->save(); +}); + +it('can access the redirects index page', function () { + $this->actingAs($this->user) + ->get(cp_route('alt-redirect.index')) + ->assertStatus(200) + ->assertSee('Alt Redirect'); +}); + +it('can access the query strings index page', function () { + $this->actingAs($this->user) + ->get(cp_route('alt-redirect.query-strings.index')) + ->assertStatus(200) + ->assertSee('Alt Redirect - Query Strings'); +}); diff --git a/tests/Feature/FileCsvTest.php b/tests/Feature/FileCsvTest.php new file mode 100644 index 0000000..452af8f --- /dev/null +++ b/tests/Feature/FileCsvTest.php @@ -0,0 +1,56 @@ + 'file']); + $this->user = User::make()->makeSuper()->save(); +}); + +it('can export redirects as csv', function () { + $repository = app(RepositoryInterface::class); + $repository->save('redirects', [ + 'id' => 'export-test-1', + 'from' => '/export-old-1', + 'to' => '/export-new-1', + 'redirect_type' => 301, + 'sites' => ['default'], + ]); + + $response = $this->actingAs($this->user) + ->get(cp_route('alt-redirect.export')); + + $response->assertStatus(200); + $response->assertHeader('Content-Type', 'text/csv; charset=UTF-8'); + + $content = $response->streamedContent(); + $rows = explode("\n", trim($content)); + + expect($rows)->toHaveCount(2); + expect($rows[0])->toBe('from,to,redirect_type,sites,id'); + expect($rows[1])->toContain('/export-old-1,/export-new-1,301,default,export-test-1'); +}); + +it('can import redirects from csv', function () { + $csvContent = "from,to,redirect_type,sites,id\n"; + $csvContent .= "/import-old-1,/import-new-1,301,default,import-test-1\n"; + $csvContent .= '/import-old-2,/import-new-2,302,"default,other",import-test-2'; + + $file = UploadedFile::fake()->createWithContent('redirects.csv', $csvContent); + + $response = $this->actingAs($this->user) + ->post(cp_route('alt-redirect.import'), [ + 'file' => $file, + ]); + + $response->assertRedirect(); + + $repository = app(RepositoryInterface::class); + $redirects = $repository->all('redirects'); + + expect($redirects)->toHaveCount(2); + expect($redirects[0]['from'])->toBe('/import-old-1'); + expect($redirects[1]['sites'])->toBe(['default', 'other']); +}); diff --git a/tests/Feature/FileRedirectTest.php b/tests/Feature/FileRedirectTest.php new file mode 100644 index 0000000..7e5d6d5 --- /dev/null +++ b/tests/Feature/FileRedirectTest.php @@ -0,0 +1,154 @@ + 'file']); +}); + +it('can redirect a simple path', function () { + $repository = app(RepositoryInterface::class); + $repository->save('redirects', [ + 'id' => 'simple-test-file', + 'from' => '/old-path-file', + 'to' => '/new-path-file', + 'redirect_type' => 301, + 'sites' => ['default'], + ]); + + $this->get('/old-path-file') + ->assertRedirect('/new-path-file') + ->assertStatus(301); +}); + +it('can redirect with regex', function () { + $repository = app(RepositoryInterface::class); + $repository->save('redirects', [ + 'id' => 'regex-test-file', + 'from' => '/old-file/(.*)', + 'to' => '/new-file/$1', + 'redirect_type' => 302, + 'sites' => ['default'], + ]); + + $this->get('/old-file/something') + ->assertRedirect('/new-file/something') + ->assertStatus(302); +}); + +it('can redirect with query strings in regex', function () { + $repository = app(RepositoryInterface::class); + $repository->save('redirects', [ + 'id' => 'query-regex-test-file', + 'from' => '/test-file\?source=(.*)', + 'to' => '/beans-file/$1', + 'redirect_type' => 301, + 'sites' => ['default'], + ]); + + // It should match and redirect, but since 'source' is not stripped, it's added back to the target + $this->get('/test-file?source=mandem') + ->assertRedirect('/beans-file/mandem?source=mandem') + ->assertStatus(301); +}); + +it('can strip query strings', function () { + $repository = app(RepositoryInterface::class); + $repository->save('query-strings', [ + 'id' => 'strip-gclid-file', + 'query_string' => 'gclid', + 'strip' => true, + 'sites' => ['default'], + ]); + + $repository->save('redirects', [ + 'id' => 'simple-strip-test-file', + 'from' => '/old-strip-file', + 'to' => '/new-strip-file', + 'redirect_type' => 301, + 'sites' => ['default'], + ]); + + $this->get('/old-strip-file?gclid=123&other=456') + ->assertRedirect('/new-strip-file?other=456') + ->assertStatus(301); +}); + +it('can handle site specific redirects', function () { + $repository = app(RepositoryInterface::class); + $repository->save('redirects', [ + 'id' => 'site-test-file', + 'from' => '/only-default-file', + 'to' => '/matched-file', + 'redirect_type' => 301, + 'sites' => ['default'], + ]); + + Site::setCurrent('default'); + $this->get('/only-default-file')->assertRedirect('/matched-file'); + + Site::setCurrent('other'); + // If not default site, it should not match and return 404 + $this->get('/only-default-file')->assertNotFound(); +}); + +it('forgives unescaped question marks in regex', function () { + $repository = app(RepositoryInterface::class); + $repository->save('redirects', [ + 'id' => 'unescaped-test-file', + 'from' => '/test-unescaped-file?source=(.*)', // Unescaped ? + 'to' => '/new-test-file/$1', + 'redirect_type' => 301, + 'sites' => ['default'], + ]); + + $this->get('/test-unescaped-file?source=mandem') + ->assertRedirect('/new-test-file/mandem?source=mandem'); +}); + +it('can redirect with non-standard regex', function () { + $repository = app(RepositoryInterface::class); + $repository->save('redirects', [ + 'id' => 'non-standard-regex-file', + 'from' => '^/products/(.+)$', + 'to' => '/shop/$1', + 'redirect_type' => 301, + 'sites' => ['default'], + ]); + + $this->get('/products/shoes') + ->assertRedirect('/shop/shoes') + ->assertStatus(301); +}); + +it('can delete a redirect', function () { + $repository = app(RepositoryInterface::class); + $repository->save('redirects', [ + 'id' => 'delete-test-file', + 'from' => '/old-delete', + 'to' => '/new-delete', + 'redirect_type' => 301, + 'sites' => ['default'], + ]); + + $repository->delete('redirects', ['id' => 'delete-test-file', 'from' => '/old-delete']); + + $this->get('/old-delete')->assertNotFound(); +}); + +it('can delete a query string', function () { + $repository = app(RepositoryInterface::class); + $repository->save('query-strings', [ + 'id' => 'delete-qs-file', + 'query_string' => 'gclid-delete', + 'strip' => true, + 'sites' => ['default'], + ]); + + // This is what the frontend does: sends only query_string + $repository->delete('query-strings', ['query_string' => 'gclid-delete']); + + // Check it's gone + expect($repository->find('query-strings', 'query_string', 'gclid-delete'))->toBeNull(); +}); diff --git a/tests/Feature/InstallationTest.php b/tests/Feature/InstallationTest.php new file mode 100644 index 0000000..8654b29 --- /dev/null +++ b/tests/Feature/InstallationTest.php @@ -0,0 +1,17 @@ +all('query-strings'); + + // It should now install them automatically + expect($queryStrings)->not->toBeEmpty(); + expect($queryStrings)->toHaveCount(9); + + $handles = collect($queryStrings)->pluck('query_string')->toArray(); + expect($handles)->toContain('utm_source'); + expect($handles)->toContain('utm_medium'); + expect($handles)->toContain('gclid'); +}); diff --git a/tests/Feature/RegexDetectionTest.php b/tests/Feature/RegexDetectionTest.php new file mode 100644 index 0000000..9e92c13 --- /dev/null +++ b/tests/Feature/RegexDetectionTest.php @@ -0,0 +1,147 @@ +artisan('migrate'); +}); + +it('does not flag simple URLs with query strings as regex', function ($url) { + $repository = app(RepositoryInterface::class); + $repository->save('redirects', [ + 'id' => 'test-id-'.md5($url), + 'from' => $url, + 'to' => '/target', + 'redirect_type' => 301, + 'sites' => ['default'], + ]); + + $redirect = $repository->find('redirects', 'from', $url); + + expect($redirect['is_regex'] ?? false)->toBeFalse(); +})->with([ + '/fire-equipment-and-first-aid-storage-cabinets/dangerous-and-flammable-substance-coshh-storage-cabinet/?combination=286_1295', + '/fire-equipment-and-first-aid-storage-cabinets/dangerous-and-flammable-substance-coshh-storage-cabinet/?combination=286_1297', + '/sti-theft-stoppers/emergency-lighting-covers/?sort_by=price&sort_order=asc', + '/industrial-spill-control/chemical-spill-control/?items_per_page=32', + '/disabled-equipment/c-tec-quantec-addressable-nurse-call/page-3/?sort_by=position&sort_order=asc', + '/search?q=foo', + '/test.html?a=b', + '/search/results+for+query', + '/product-categories/spill-care?selectedTaxonomies[product_categories][spill-care]=spill-care', + '/products/test.php', + '/search?q=(foo)', + '/credit-application/', + '/profiles-add/', + '/about/', + '/delivery/', + '/engineers-stock-and-spares/', + '/special-offers/', + '/services/', + '/fire-risk-assessment-en/', + '/online-training-courses/', + '/personal-safety/', + '/industrial-spill-control/', + '/disabled-equipment/', + '/fire-safety-stick/', + '/sti-theft-stoppers/', + '/fire-brigade-equipment/', + '/fire-hose-reels/', + '/intumescent-products/', + '/fire-door-furniture/', + '/fire-safety-signs/', + '/site-fire-alarms/', + '/emergency-lighting/', + '/fire-alarms/', + '/fire-equipment-and-storage-cabinets/', + '/stands-and-trolleys/', + '/fire-safety-packs/', + '/spares/', + '/shop/........../', + '/welding-drapes/', + '/fire-blankets/', + '/fire-extinguishers/', +]); + +it('still flags actual regex as regex', function ($url) { + $repository = app(RepositoryInterface::class); + $repository->save('redirects', [ + 'id' => 'regex-id-'.md5($url), + 'from' => $url, + 'to' => '/target/$1', + 'redirect_type' => 301, + 'sites' => ['default'], + ]); + + $redirect = $repository->find('redirects', 'from', $url); + + expect($redirect['is_regex'] ?? false)->toBeTrue(); +})->with([ + '/products/(.*)', + '^/old-path/(.+)$', + '/blog/[0-9]{4}/[0-9]{2}/.*', + '/test/(foo|bar)', + '/test/\d+', + '#/products/(.*)#', + '/^foo$/i', +]); + +it('handles file driver regex detection correctly', function () { + Config::set('alt-redirect.driver', 'file'); + $repository = app(RepositoryInterface::class); + + // For file repository, we can't check 'is_regex' column but we can check if it's placed in the regex folder + // but the file repository implementation also uses isRegex to decide on filename + // Actually, FileRepository doesn't store is_regex in the YAML, but uses it for saving. + + $url = '/test?a=b'; + $repository->save('redirects', [ + 'id' => 'file-test', + 'from' => $url, + 'to' => '/target', + 'redirect_type' => 301, + 'sites' => ['default'], + ]); + + // If it's NOT regex, it should be saved with a hashed filename + $expectedPath = 'content/alt-redirect/'.hash('sha512', base64_encode($url)).'.yaml'; + expect(File::disk()->exists($expectedPath))->toBeTrue(); +}); + +it('can re-scan redirects and fix regex flags', function () { + Config::set('alt-redirect.driver', 'database'); + $repository = app(RepositoryInterface::class); + + // Use model directly to bypass repository auto-calculation for setup + Redirect::create([ + 'id' => 'wrong-flag-1', + 'from' => '/simple-path?a=b', + 'to' => '/target', + 'redirect_type' => 301, + 'sites' => ['default'], + 'is_regex' => true, // Wrong! + ]); + + Redirect::create([ + 'id' => 'wrong-flag-2', + 'from' => '/products/(.*)', + 'to' => '/shop/$1', + 'redirect_type' => 301, + 'sites' => ['default'], + 'is_regex' => false, // Wrong! + ]); + + $this->artisan('alt-redirect:re-scan-regex') + ->expectsOutput('Successfully updated 2 redirects.') + ->assertExitCode(0); + + $redirect1 = $repository->find('redirects', 'id', 'wrong-flag-1'); + $redirect2 = $repository->find('redirects', 'id', 'wrong-flag-2'); + + expect($redirect1['is_regex'] ?? true)->toBeFalse(); + expect($redirect2['is_regex'] ?? false)->toBeTrue(); +}); diff --git a/tests/Feature/SimpleRedirectWithQueryStringTest.php b/tests/Feature/SimpleRedirectWithQueryStringTest.php new file mode 100644 index 0000000..00667ac --- /dev/null +++ b/tests/Feature/SimpleRedirectWithQueryStringTest.php @@ -0,0 +1,89 @@ + 'database']); +}); + +it('can redirect a simple path with a query string', function () { + $repository = app(RepositoryInterface::class); + $repository->save('redirects', [ + 'id' => 'simple-qs-test', + 'from' => '/old-qs?foo=bar', + 'to' => '/new-qs', + 'redirect_type' => 301, + 'sites' => ['default'], + ]); + + $this->get('/old-qs?foo=bar') + ->assertRedirect('/new-qs?foo=bar') + ->assertStatus(301); +}); + +it('can redirect a simple path with a query string when other query strings are present and stripped', function () { + $repository = app(RepositoryInterface::class); + $repository->save('query-strings', [ + 'id' => 'strip-gclid', + 'query_string' => 'gclid', + 'strip' => true, + 'sites' => ['default'], + ]); + + $repository->save('redirects', [ + 'id' => 'simple-qs-strip-test', + 'from' => '/old-qs-strip?foo=bar', + 'to' => '/new-qs-strip', + 'redirect_type' => 301, + 'sites' => ['default'], + ]); + + $this->get('/old-qs-strip?foo=bar&gclid=123') + ->assertRedirect('/new-qs-strip?foo=bar') + ->assertStatus(301); +}); + +it('can redirect a simple path without a query string when the request has a query string', function () { + $repository = app(RepositoryInterface::class); + $repository->save('redirects', [ + 'id' => 'simple-no-qs-test', + 'from' => '/old-no-qs', + 'to' => '/new-no-qs', + 'redirect_type' => 301, + 'sites' => ['default'], + ]); + + $this->get('/old-no-qs?foo=bar') + ->assertRedirect('/new-no-qs?foo=bar') + ->assertStatus(301); +}); + +it('can redirect a simple path with a query string even with trailing slash differences', function () { + $repository = app(RepositoryInterface::class); + // 1. Redirect has trailing slash, request doesn't + $repository->save('redirects', [ + 'id' => 'simple-qs-trailing-test', + 'from' => '/old-qs-trailing/?foo=bar', + 'to' => '/new-qs-trailing', + 'redirect_type' => 301, + 'sites' => ['default'], + ]); + + $this->get('/old-qs-trailing?foo=bar') + ->assertRedirect('/new-qs-trailing?foo=bar') + ->assertStatus(301); + + // 2. Redirect doesn't have trailing slash, request does + $repository->save('redirects', [ + 'id' => 'simple-qs-no-trailing-test', + 'from' => '/old-qs-no-trailing?foo=bar', + 'to' => '/new-qs-no-trailing', + 'redirect_type' => 301, + 'sites' => ['default'], + ]); + + $this->get('/old-qs-no-trailing/?foo=bar') + ->assertRedirect('/new-qs-no-trailing?foo=bar') + ->assertStatus(301); +}); diff --git a/tests/Pest.php b/tests/Pest.php new file mode 100644 index 0000000..ccd03f1 --- /dev/null +++ b/tests/Pest.php @@ -0,0 +1,6 @@ +in(__DIR__); diff --git a/tests/TestCase.php b/tests/TestCase.php new file mode 100644 index 0000000..bd078cd --- /dev/null +++ b/tests/TestCase.php @@ -0,0 +1,92 @@ +set('statamic.edits.file', true); + $app['config']->set('statamic.users.repository', 'file'); + $app['config']->set('statamic.system.multisite', true); + $app['config']->set('statamic.editions.pro', true); + + // Configure sites + $app['config']->set('statamic.sites', [ + 'default' => [ + 'name' => 'English', + 'locale' => 'en_US', + 'url' => '/', + ], + 'other' => [ + 'name' => 'French', + 'locale' => 'fr_FR', + 'url' => '/fr/', + ], + ]); + + // Use a temporary database for testing + $app['config']->set('database.default', 'testbench'); + $app['config']->set('database.connections.testbench', [ + 'driver' => 'sqlite', + 'database' => ':memory:', + 'prefix' => '', + ]); + + // Mock the disk for file driver + $app['config']->set('filesystems.disks.local.root', __DIR__.'/__fixtures__/storage'); + $app->bind('filesystems.paths.standard', fn () => __DIR__.'/__fixtures__/storage'); + } + + protected function defineDatabaseMigrations() + { + $this->loadMigrationsFrom(__DIR__.'/../src/Database/Migrations'); + } + + protected function setUp(): void + { + parent::setUp(); + + Site::setSites(config('statamic.sites')); + } + + protected function tearDown(): void + { + if (file_exists(__DIR__.'/__fixtures__/storage/content/alt-redirect')) { + $this->deleteDirectory(__DIR__.'/__fixtures__/storage/content/alt-redirect'); + } + + parent::tearDown(); + } + + private function deleteDirectory($dir) + { + if (! file_exists($dir)) { + return true; + } + + if (! is_dir($dir)) { + return unlink($dir); + } + + foreach (scandir($dir) as $item) { + if ($item == '.' || $item == '..') { + continue; + } + + if (! $this->deleteDirectory($dir.DIRECTORY_SEPARATOR.$item)) { + return false; + } + } + + return rmdir($dir); + } +} diff --git a/tests/Unit/.gitkeep b/tests/Unit/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/tests/__fixtures__/content/assets/.gitkeep b/tests/__fixtures__/content/assets/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/tests/__fixtures__/content/collections/.gitkeep b/tests/__fixtures__/content/collections/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/tests/__fixtures__/content/globals/.gitkeep b/tests/__fixtures__/content/globals/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/tests/__fixtures__/content/navigation/.gitkeep b/tests/__fixtures__/content/navigation/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/tests/__fixtures__/content/structures/collections/.gitkeep b/tests/__fixtures__/content/structures/collections/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/tests/__fixtures__/content/structures/navigation/.gitkeep b/tests/__fixtures__/content/structures/navigation/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/tests/__fixtures__/content/submissions/.gitkeep b/tests/__fixtures__/content/submissions/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/tests/__fixtures__/content/taxonomies/.gitkeep b/tests/__fixtures__/content/taxonomies/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/tests/__fixtures__/dev-null/.gitkeep b/tests/__fixtures__/dev-null/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/tests/__fixtures__/storage/content/.gitkeep b/tests/__fixtures__/storage/content/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/tests/__fixtures__/users/.yaml b/tests/__fixtures__/users/.yaml new file mode 100644 index 0000000..6ba8e4f --- /dev/null +++ b/tests/__fixtures__/users/.yaml @@ -0,0 +1,2 @@ +super: true +id: 9e62c86f-6e50-4f9a-a1e8-435bc5c9076f