From 29597a30385b31715b46b394885fdfbbd25750db Mon Sep 17 00:00:00 2001 From: Imani Manyara Date: Tue, 6 Oct 2026 07:27:37 -0400 Subject: [PATCH] Move the tidy command out to laranail/artisan-ui The owner moved the maintenance command to the package that owns command execution: it ships there as laranail::artisan-ui.tidy with the same actions, options and safety rules, and its tests went with it. There is no forwarder here, so this is breaking and opens the 0.3 line (branch alias 0.3.x-dev); consumers on ^0.2 keep the old command. Also removes routes/web.php and routes/api.php. No provider ever loaded them; web.php held unauthenticated GET closures that rebuilt caches, and artisan-ui's authenticated quick actions cover each of them. --- CHANGELOG.md | 11 + composer.json | 2 +- docs/architecture.md | 12 +- docs/commands.md | 84 +-- docs/exceptions.md | 4 +- routes/api.php | 3 - routes/web.php | 57 -- src/Commands/Tidy.php | 539 ------------------ src/Providers/ToolkitServiceProvider.php | 3 +- tests/Fixtures/Legacy/removed-symbols.json | 2 +- .../Console/CommandConsoleFeaturesTest.php | 111 ---- tests/Unit/Console/TidyBranchesTest.php | 263 --------- tests/Unit/Console/TidyCommandTest.php | 317 ---------- tests/Unit/Console/TidyUserFileGuardTest.php | 223 -------- 14 files changed, 27 insertions(+), 1604 deletions(-) delete mode 100755 routes/api.php delete mode 100755 routes/web.php delete mode 100644 src/Commands/Tidy.php delete mode 100644 tests/Unit/Console/CommandConsoleFeaturesTest.php delete mode 100644 tests/Unit/Console/TidyBranchesTest.php delete mode 100644 tests/Unit/Console/TidyCommandTest.php delete mode 100644 tests/Unit/Console/TidyUserFileGuardTest.php diff --git a/CHANGELOG.md b/CHANGELOG.md index c62ff21..dc68061 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,17 @@ All notable changes to `laranail/toolkit` are documented in this file. The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/) and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [Unreleased] + +### Removed + +- **Breaking: the `laranail::toolkit.tidy` command** (`Simtabi\Laranail\Toolkit\Commands\Tidy`) moved to `laranail/artisan-ui` as `laranail::artisan-ui.tidy`, with the same actions, options and safety rules. Install that package and call the new name; there is no forwarder here. +- **`routes/web.php` and `routes/api.php`.** No provider ever loaded them. `web.php` held seven unauthenticated `GET` closures that rebuilt and cleared caches (`/tidy/*`), and `api.php` was empty. Their actions are in `laranail/artisan-ui`'s authenticated Caches and Tidy quick-action groups. + +### Changed + +- The branch alias is `0.3.x-dev`. Consumers on `^0.2` stay on 0.2.x and keep `laranail::toolkit.tidy`; require `^0.3` to move. + ## [0.2.3] - 2026-10-05 ### Added diff --git a/composer.json b/composer.json index 0e63050..bcf4f54 100644 --- a/composer.json +++ b/composer.json @@ -112,7 +112,7 @@ } }, "branch-alias": { - "dev-main": "0.2.x-dev" + "dev-main": "0.3.x-dev" } }, "suggest": { diff --git a/docs/architecture.md b/docs/architecture.md index 448136b..0471b75 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -43,17 +43,13 @@ src/ > The command base (`Command` + `SupportsNamespacedNames`) is **not** local — it > comes from [`laranail/console`](https://opensource.simtabi.com/console/) `^1.0`, -> the org-canonical command base. All three toolkit commands — `MakeCrud`, -> `IdeHelperMacros`, `Tidy` — extend it and use its **full +> the org-canonical command base. Both toolkit commands — `MakeCrud` and +> `IdeHelperMacros` — extend it and use its **full > feature set**: the fluent `$this->consoleWriter()` (context statuses > success/error/warning/info/note, styling, emoji) and the `$this->services` > lifecycle (`performance`, `signals`, `interaction`, `logger`, `error`, -> `metadata`, `display`). The heavy `Tidy` command makes its destructive sweep -> **signal-safe** (`signals()->shouldKeepRunning()`), confirms through -> `interaction()->confirmAction()` (safe default in non-interactive mode), and -> captures failures via the **auto-redacting** `error()->logError()` — so -> credentials never reach a log channel. The existing security hardening -> (FilePathGuard storage confinement, `db` action gating) is untouched. +> `metadata`, `display`). The maintenance command `Tidy` moved to `laranail/artisan-ui` +> (`laranail::artisan-ui.tidy`) in 0.3.0. ## Adding a feature / tool / module diff --git a/docs/commands.md b/docs/commands.md index 1fd6dc7..9987776 100644 --- a/docs/commands.md +++ b/docs/commands.md @@ -10,7 +10,6 @@ alias), and injects its collaborators — no facades in the core logic. |---|---|---|---| | CRUD generator | `laranail::toolkit.make-crud` | `laranail::toolkit.make-crud` | [make-crud](make-crud.md) | | IDE-helper macros | `laranail::toolkit.ide-helper-macros` | `laranail::toolkit.ide-helper-macros` | [macros](macros.md) | -| Tidy | `laranail::toolkit.tidy` | `tidy` | below | --- @@ -62,90 +61,21 @@ non-zero. The non-interactive flag is read from the input, so `--no-interaction` the stub size via `display()->formatBytes(strlen(...))`. Accepts `--path=` to override the output location (default `ide-helper/_ide_helper_macros.php` under the base path). -- **`tidy`** (heavy) — `consoleWriter()` throughout; the file sweep is - **signal-safe** (`shouldKeepRunning()` is polled per root and per file); - destructive prompts route through `interaction()->confirmAction()` (the existing - `db` gating is preserved); each run is timed with `performance()` and logged via - `logger()->logCompletion()` with files-processed + space-freed `metadata()`. --- -## `laranail::toolkit.tidy` — maintenance / cleanup +## Tidy moved to `laranail/artisan-ui` -Unified, path-confined cleanup: `cache`, `logs`, `temp`, `storage`, `db`, `all`. +The maintenance command `laranail::toolkit.tidy` was removed in 0.3.0. It now lives in +[`laranail/artisan-ui`](https://opensource.simtabi.com/documentation/laranail/artisan-ui/) as +`laranail::artisan-ui.tidy`, with the same actions, options and safety rules (storage-confined +deletion, scoped `storage` sweeps, `db` gated and kept out of `all`). Install that package and call +the new name: ```bash -php artisan laranail::toolkit.tidy [action] [options] +php artisan laranail::artisan-ui.tidy logs --days=30 --force ``` -| Option | Description | -|---|---| -| `action` (argument) | `cache`, `logs`, `temp`, `storage`, `db`, or `all` (default). | -| `--days=` | Only delete files older than this many days. | -| `--size=` | Only delete files larger than this many MB. | -| `--seed` | (db) also run `db:seed` after `migrate:fresh`. | -| `--optimize` | (cache) also run `optimize:clear`. | -| `--dry-run` | Show what would be removed without deleting anything. | -| `--unfiltered` | (`storage`) sweep user files with no age/size filter. Refused in production. | -| `--force` | Skip confirmation prompts (required for the `db` action). | - -### Actions - -- **`cache`** — flushes the application cache; with `--optimize` also runs - `optimize:clear`. `--dry-run` previews; the freed space is reported. -- **`logs` / `temp` / `storage`** — delete files under the relevant - `storage_path()` roots, filtered by `--days` (age) and/or `--size`. - `.gitignore` files are always preserved. -- **`db`** — runs `migrate:fresh` (optionally `--seed`). **Excluded from - `all`.** Requires `--force` AND passes the production-safety - `confirmToProceed()` guard; it is a no-op under `--dry-run`. -- **`all`** — tidies cache + `logs` / `temp`, and `storage` **only when scoped** - (see below). It **never** runs the destructive `db` action. - -### The `storage` action needs a scope - -`storage` sweeps `storage/app/public`, `storage/app/uploads` and -`storage/app/exports`. The first is the disk behind `storage:link` — user -uploads. So unlike `logs` and `temp`, which hold data the application -regenerates, an unfiltered sweep here is not housekeeping. - -| Invocation | Result | -|---|---| -| `tidy storage --days=30 --force` | Deletes uploads older than 30 days. | -| `tidy storage --size=100 --force` | Deletes uploads over 100 MB. | -| `tidy storage --force` | **Refused.** Every file would match. | -| `tidy storage --unfiltered --force` | Deletes everything — outside production only. | -| `tidy storage --unfiltered --force` *(production)* | **Refused**, with no override. | -| `tidy all --force` | Sweeps cache + logs + temp; **skips** `storage` and says so. | -| `tidy all --days=30 --force` | Sweeps everything, `storage` included. | - -> Through v0.1.0, `tidy storage --force` and `tidy all --force` deleted every -> file in those roots. The containment guard did not catch it and never would -> have: it answers "can this delete something outside `storage_path()`", and -> those roots are inside it. - -### Safety notes - -- **Every deletion is confined to `storage_path()`.** The swept roots are - storage-relative; each is realpath-resolved and proven to sit inside - `realpath(storage_path())` before any delete. Each candidate file is - realpath-resolved and re-checked for containment, so a `..` path or a symlink - pointing **outside** storage is skipped, never followed. Paths are additionally - screened by the `FilePathGuard` (`..` / null-byte rejection). -- **`--dry-run` deletes nothing** — it previews each candidate and reports the - space that *would* be freed. -- **`db` is hard-gated** behind `--force` + `confirmToProceed()` and excluded - from `all`, so a bulk tidy can never drop your tables. -- **`--force` is not the gate for user files.** It appears in every CI - invocation, so it is typed by habit; scoping the `storage` sweep with - `--days`/`--size`, or acknowledging it with `--unfiltered`, is. -- **Signal-safe sweep.** The deletion loop polls - `signals()->shouldKeepRunning()` per root and per file, so a `SIGTERM` / - `SIGINT` stops the sweep cleanly mid-directory rather than leaving it half-run. - Without ext-pcntl (e.g. Windows) the check defaults `true`, so a normal run is - unaffected. Every run is timed and a `logger()->logCompletion()` summary records - files-processed and space-freed. - --- ## See also diff --git a/docs/exceptions.md b/docs/exceptions.md index cf6bd08..1f22d86 100644 --- a/docs/exceptions.md +++ b/docs/exceptions.md @@ -131,8 +131,8 @@ throw InvalidPathException::directoryTraversal($path); // \RuntimeException `InvalidPathException` adds reason-specific factories on top of `create()`: `directoryTraversal()`, `nullByteDetected()`, `outsideAllowedDirectory()`, and -`invalidCharacters()` — used by the path-confinement guards across the file, -database, and tidy code paths. +`invalidCharacters()` — used by the path-confinement guards across the file +and database code paths. `FileTooLargeException::create()` formats both the actual and maximum size as human-readable strings (e.g. `1.50 MB`) in the message. diff --git a/routes/api.php b/routes/api.php deleted file mode 100755 index 174d7fd..0000000 --- a/routes/api.php +++ /dev/null @@ -1,3 +0,0 @@ - 'tidy', 'as' => 'tidy.', 'middleware' => ['web']], function () { - - Route::get('/', ['name' => 'all', 'uses' => function () { - Artisan::call('optimize'); - Artisan::call('route:cache'); - Artisan::call('cache:clear'); - Artisan::call('view:clear'); - Artisan::call('config:cache'); - Artisan::call('clear-compiled'); - - return 'All Cache, Route, Config, and View files have been purged successfully!'; - }]); - - Route::get('/optimize', ['name' => 'optimize', 'uses' => function () { - Artisan::call('optimize'); - - return 'Application optimized successfully!'; - }]); - - Route::get('/route', ['name' => 'route', 'uses' => function () { - Artisan::call('route:cache'); - - return 'Application route cache cleared successfully!'; - }]); - - Route::get('/cache', ['name' => 'cache', 'uses' => function () { - Artisan::call('cache:clear'); - - return 'Application cache cleared successfully!'; - }]); - - Route::get('/view', ['name' => 'view', 'uses' => function () { - Artisan::call('view:clear'); - - return 'Application views cleared successfully!'; - }]); - - Route::get('/config', ['name' => 'config', 'uses' => function () { - Artisan::call('config:cache'); - - return 'Application config cleared successfully!'; - }]); - - Route::get('/clear-compiled', ['name' => 'clear-compiled', 'uses' => function () { - Artisan::call('clear-compiled'); - - return 'Compiled files cleared successfully!'; - }]); - -}); diff --git a/src/Commands/Tidy.php b/src/Commands/Tidy.php deleted file mode 100644 index decdb1e..0000000 --- a/src/Commands/Tidy.php +++ /dev/null @@ -1,539 +0,0 @@ - ['logs'], - 'temp' => ['app/temp', 'app/tmp', 'framework/cache/data'], - 'storage' => ['app/public', 'app/uploads', 'app/exports'], - ]; - - /** - * Actions whose roots hold data the application cannot regenerate. - * - * `logs` and `temp` sweep log files and `framework/cache/data` — losing - * those costs nothing, which is the whole point of the command. `storage` - * sweeps `app/public` (the disk behind `storage:link`), `app/uploads` and - * `app/exports`: user uploads. With no `--days`/`--size` every file matched, - * and `--force` skipped the prompt, so `tidy storage --force` — and - * `tidy all --force`, which used to sweep the same roots — deleted every - * file a user had ever uploaded, in one non-interactive command with a name - * that reads like housekeeping. - * - * @var list - */ - private const USER_DATA_ACTIONS = ['storage']; - - protected $signature = 'laranail::toolkit.tidy - {action? : What to tidy (cache|logs|temp|storage|db|all) — defaults to all} - {--days= : Only delete files older than this many days} - {--size= : Only delete files larger than this many MB} - {--seed : (db) also run db:seed after migrate:fresh} - {--optimize : (cache) also run optimize:clear} - {--dry-run : Show what would be removed without deleting anything} - {--unfiltered : (storage) sweep user files with no age/size filter — refused in production} - {--force : Skip confirmation prompts (required for the db action)}'; - - protected $description = 'Unified, path-confined maintenance: cache|logs|temp|storage|db|all'; - - private int $filesProcessed = 0; - - private int $spaceFreed = 0; - - public function __construct( - private readonly FileServiceInterface $files, - private readonly CacheRepositoryInterface $cache, - private readonly LoggerServiceInterface $log, - ) { - parent::__construct(); - } - - public function handle(): int - { - $argument = $this->argument('action'); - $action = is_string($argument) && $argument !== '' ? $argument : 'all'; - - // Wire SIGTERM/SIGINT graceful-stop handlers (no-op without ext-pcntl) - // so the file sweep can stop between files on Ctrl-C. Time the whole run - // so the completion summary carries a real execution-time figure. - $this->services->signals()->setupSignalHandling(); - $this->services->performance()->startTimer(); - $this->services->metadata()->addMany([ - 'action' => $action, - 'dry_run' => $this->isDryRun(), - ]); - - $result = match ($action) { - 'cache' => $this->tidyCache(), - 'logs', 'temp', 'storage' => $this->tidyFiles($action), - 'db' => $this->tidyDatabase(), - 'all' => $this->tidyAll(), - default => $this->invalidAction($action), - }; - - $this->logSummary($action, $result); - - return $result; - } - - /** - * Record a structured completion summary (files processed, bytes freed, - * execution time) via the logger service after every run. - */ - private function logSummary(string $action, int $exitCode): void - { - $performance = $this->services->performance(); - $performance->endTimer(); - - $this->services->metadata()->addMany([ - 'files_processed' => $this->filesProcessed, - 'space_freed' => $this->services->display()->formatBytes($this->spaceFreed), - ]); - - $this->services->logger()->logCompletion($exitCode, [ - 'execution_time' => $performance->getFormattedExecutionTime(), - ], $this->services->metadata()->all()); - } - - // ----------------------------------------------------------------------- - // cache - // ----------------------------------------------------------------------- - - private function tidyCache(): int - { - $writer = $this->consoleWriter(); - - if ($this->isDryRun()) { - $writer->info('[dry-run] Would flush the application cache' . ($this->boolOption('optimize') ? ' and run optimize:clear' : '') . '.'); - - return self::SUCCESS; - } - - if (! $this->confirmAction('Flush the application cache?')) { - return self::SUCCESS; - } - - $this->cache->forget('__laranail_tidy_probe__'); - - try { - // Flush via the framework's own command so every store/driver is - // honoured; the injected cache repo is exercised above for DI parity. - $this->call('cache:clear'); - } catch (Throwable $e) { - $this->log->error('Cache flush failed during tidy', ['error' => $e->getMessage()]); - } - - if ($this->boolOption('optimize')) { - $this->call('optimize:clear'); - } - - $writer->success('Cache tidied.'); - - return self::SUCCESS; - } - - // ----------------------------------------------------------------------- - // logs / temp / storage (path-confined deletion) - // ----------------------------------------------------------------------- - - private function tidyFiles(string $action, bool $confirm = true): int - { - $this->filesProcessed = 0; - $this->spaceFreed = 0; - - $base = realpath(storage_path()); - - if ($base === false) { - $this->consoleWriter()->error('storage_path() does not resolve — nothing to tidy.'); - - return self::FAILURE; - } - - if (! $this->maySweepUnfiltered($action)) { - return self::FAILURE; - } - - // The `all` action confirms once up front, so its per-root sweeps skip - // the prompt (passing $confirm = false) to avoid re-asking per action. - if ($confirm && ! $this->isDryRun() && ! $this->confirmAction("Clean {$action} files under storage?")) { - return self::SUCCESS; - } - - $signals = $this->services->signals(); - - foreach (self::ROOTS[$action] as $relative) { - // Stop sweeping further roots if a termination signal arrived. - // shouldKeepRunning() defaults true (and without pcntl), so a normal - // run sweeps every root. - if (! $signals->shouldKeepRunning()) { - break; - } - - $this->sweep($base, $relative); - } - - $verb = $this->isDryRun() ? 'Would free' : 'Freed'; - $this->consoleWriter()->success(sprintf( - '%s: %d file(s), %s%s.', - $this->isDryRun() ? '[dry-run] ' . $verb : $verb, - $this->filesProcessed, - $this->files->formatFileSize($this->spaceFreed), - $this->isDryRun() ? ' (nothing deleted)' : '', - )); - - return self::SUCCESS; - } - - /** - * Sweep one storage-relative directory, deleting (or previewing) the files - * that pass the age/size filters. Each candidate is realpath-resolved and - * proven to sit inside the swept root before any delete — a `..` path or a - * symlink pointing outside storage is skipped, never followed. - */ - private function sweep(string $base, string $relative): void - { - if (! $this->isSafePath($relative)) { - return; - } - - $root = realpath($base . DIRECTORY_SEPARATOR . str_replace('/', DIRECTORY_SEPARATOR, $relative)); - - if ($root === false || ! is_dir($root) || ! $this->isWithin($root, $base)) { - return; - } - - $cutoff = $this->ageCutoff(); - $minBytes = $this->minBytes(); - - $iterator = new RecursiveIteratorIterator( - new RecursiveDirectoryIterator($root, FilesystemIterator::SKIP_DOTS), - ); - - $signals = $this->services->signals(); - - /** @var SplFileInfo $file */ - foreach ($iterator as $file) { - // Signal-safe sweep: stop deleting mid-directory on Ctrl-C/SIGTERM. - // Defaults true (and without pcntl), so a normal run is unaffected. - if (! $signals->shouldKeepRunning()) { - break; - } - - if (! $file->isFile()) { - continue; - } - - $real = realpath($file->getPathname()); - - // Containment re-check on the resolved path: a symlink that escapes - // the swept root is never deleted. - if ($real === false || ! $this->isWithin($real, $root)) { - continue; - } - - if (str_ends_with($file->getFilename(), '.gitignore')) { - continue; - } - - $size = $file->getSize(); - - if (! $this->matchesFilters($real, $size, $cutoff, $minBytes)) { - continue; - } - - $this->filesProcessed++; - - if ($this->isDryRun()) { - $this->consoleWriter()->line(" would delete {$real}"); - - continue; - } - - if (@unlink($real)) { - $this->spaceFreed += $size; - } - } - } - - private function matchesFilters(string $path, int $size, ?int $cutoff, ?int $minBytes): bool - { - // With no filters, every (non-gitignore) file qualifies. - if ($cutoff === null && $minBytes === null) { - return true; - } - - // Each supplied filter must pass — with both `--days` and `--size` a file - // must be older AND larger, so a huge but fresh file is not swept. - if ($cutoff !== null) { - $mtime = @filemtime($path); - - if ($mtime === false || $mtime >= $cutoff) { - return false; - } - } - - if ($minBytes !== null && $size < $minBytes) { - return false; - } - - return true; - } - - // ----------------------------------------------------------------------- - // db (migrate:fresh — excluded from all, hard-gated) - // ----------------------------------------------------------------------- - - private function tidyDatabase(): int - { - $writer = $this->consoleWriter(); - - if ($this->isDryRun()) { - $writer->info('[dry-run] Would run migrate:fresh' . ($this->boolOption('seed') ? ' --seed' : '') . ' (skipped in dry-run).'); - - return self::SUCCESS; - } - - if (! $this->boolOption('force')) { - $writer->error('The db action runs migrate:fresh and DROPS ALL TABLES — re-run with --force.'); - - return self::FAILURE; - } - - // Production-safety prompt (no-op when --force is honoured outside prod). - if (! $this->confirmToProceed()) { - return self::FAILURE; - } - - $this->call('migrate:fresh', ['--force' => true]); - - if ($this->boolOption('seed')) { - $this->call('db:seed', ['--force' => true]); - } - - $writer->success('Database refreshed.'); - - return self::SUCCESS; - } - - // ----------------------------------------------------------------------- - // all (cache + file roots; NEVER db) - // ----------------------------------------------------------------------- - - /** - * `all` sweeps the regenerable roots unconditionally and the user-file - * roots only when scoped. - * - * Skipping rather than refusing, because `logs` and `temp` are what `all` - * is for and failing the whole run over `storage` would make the useful - * part unreachable. The skip is printed — an omission this consequential - * should not be inferred from a file count. - */ - private function tidyAll(): int - { - if (! $this->isDryRun() && ! $this->confirmAction('Tidy cache, logs, temp and storage? (the db action is excluded.)')) { - return self::SUCCESS; - } - - $this->tidyCacheQuietly(); - - $signals = $this->services->signals(); - $skipped = []; - - foreach (['logs', 'temp', 'storage'] as $action) { - if (! $signals->shouldKeepRunning()) { - break; - } - - if (in_array($action, self::USER_DATA_ACTIONS, true) && ! $this->maySweepUnfiltered($action)) { - $skipped[] = $action; - - continue; - } - - $this->tidyFiles($action, confirm: false); - } - - $this->consoleWriter()->success(sprintf( - 'All tidied (db excluded — run the db action explicitly)%s.', - $skipped === [] ? '' : ', ' . implode(' and ', $skipped) . ' skipped', - )); - - return self::SUCCESS; - } - - private function tidyCacheQuietly(): void - { - if ($this->isDryRun()) { - $this->consoleWriter()->info('[dry-run] Would flush the application cache.'); - - return; - } - - try { - $this->call('cache:clear'); - } catch (Throwable $e) { - $this->log->log(LogLevel::Error, 'Cache flush failed during tidy all', ['error' => $e->getMessage()]); - } - } - - // ----------------------------------------------------------------------- - // filters / helpers - // ----------------------------------------------------------------------- - - private function ageCutoff(): ?int - { - $days = $this->option('days'); - - if (! is_string($days) || trim($days) === '') { - return null; - } - - return time() - (max(0, (int) $days) * 86400); - } - - private function minBytes(): ?int - { - $size = $this->option('size'); - - if (! is_string($size) || trim($size) === '') { - return null; - } - - return max(0, (int) $size) * 1024 * 1024; - } - - private function isDryRun(): bool - { - return $this->boolOption('dry-run'); - } - - /** - * Refuse an unfiltered sweep of the roots that hold user files. - * - * `--force` is habitual — it is in every CI invocation and most of this - * command's own test suite — so it cannot be what stands between a - * maintenance command and every user upload. The gate is therefore a flag - * that exists for nothing else and cannot be typed by muscle memory. - * - * A filter makes the sweep intentional on its own: `--days=30` deletes what - * is stale, `--size=100` what is oversized, and both together only what is - * stale AND oversized. Those pass unchanged. What is refused is the - * no-filter case, which matches every file in `app/public`. - * - * In production the acknowledgement is not offered at all. There is no - * legitimate reason to empty a production public disk through a housekeeping - * command, and `--force` bypasses Laravel's own `confirmToProceed()` by - * design, so a prompt would not have been a gate either. - */ - private function maySweepUnfiltered(string $action): bool - { - if (! in_array($action, self::USER_DATA_ACTIONS, true)) { - return true; - } - - if ($this->ageCutoff() !== null || $this->minBytes() !== null) { - return true; - } - - $roots = implode(', ', array_map( - static fn (string $relative): string => "storage/{$relative}", - self::ROOTS[$action], - )); - - $writer = $this->consoleWriter(); - - if ($this->getLaravel()->environment('production')) { - $writer->error("Refusing to sweep {$roots} unfiltered in production — these hold user files."); - $writer->line(' Pass --days= or --size= to delete only what is stale or oversized.'); - - return false; - } - - if (! $this->boolOption('unfiltered')) { - $writer->error("Refusing to sweep {$roots} with no filter — these hold user files, and every one would match."); - $writer->line(' Pass --days=30 or --size=100 to scope it,'); - $writer->line(' or --unfiltered to delete all of it anyway (never in production).'); - - return false; - } - - return true; - } - - private function boolOption(string $key): bool - { - return $this->option($key) === true; - } - - private function confirmAction(string $message): bool - { - if ($this->boolOption('force')) { - return true; - } - - // Routed through the interaction service: Laravel Prompts on a TTY, and - // the default (false) in non-interactive mode — a piped/CI run never - // silently proceeds with a destructive delete. - return $this->services->interaction()->confirmAction($message, false); - } - - private function isWithin(string $path, string $root): bool - { - $root = rtrim($root, DIRECTORY_SEPARATOR) . DIRECTORY_SEPARATOR; - - return $path === rtrim($root, DIRECTORY_SEPARATOR) || str_starts_with($path, $root); - } - - private function invalidAction(string $action): int - { - $this->consoleWriter()->error("Invalid action [{$action}]. Available: cache, logs, temp, storage, db, all."); - - return self::FAILURE; - } -} diff --git a/src/Providers/ToolkitServiceProvider.php b/src/Providers/ToolkitServiceProvider.php index cb4bc1d..6f13fa7 100644 --- a/src/Providers/ToolkitServiceProvider.php +++ b/src/Providers/ToolkitServiceProvider.php @@ -12,7 +12,6 @@ use Illuminate\Support\ServiceProvider; use Illuminate\Support\Facades\Validator; use Illuminate\View\Factory as ViewFactory; -use Simtabi\Laranail\Toolkit\Commands\Tidy; use Simtabi\Laranail\Toolkit\Helpers\Helper; use Simtabi\Laranail\Toolkit\ToolkitManager; use Illuminate\Foundation\Console\AboutCommand; @@ -195,7 +194,7 @@ public function boot(): void $this->loadJsonTranslationsFrom($this->app->langPath('vendor/laranail-toolkit')); $this->loadMigrationsFrom("{$root}/database/migrations"); - $this->commands([MakeCrud::class, IdeHelperMacros::class, Tidy::class]); + $this->commands([MakeCrud::class, IdeHelperMacros::class]); $router = $this->app->make(Router::class); foreach (self::MIDDLEWARE_ALIASES as $alias => $class) { diff --git a/tests/Fixtures/Legacy/removed-symbols.json b/tests/Fixtures/Legacy/removed-symbols.json index e27539a..2ebf08c 100644 --- a/tests/Fixtures/Legacy/removed-symbols.json +++ b/tests/Fixtures/Legacy/removed-symbols.json @@ -293,7 +293,7 @@ }, "Simtabi\\Laranail\\Laravel\\Commands\\TidyCommand": { "status": "merged", - "target": "Merged + hardened into Simtabi\\Laranail\\Toolkit\\Commands\\Tidy (laranail::toolkit.tidy; storage-confined deletion, gated migrate:fresh)." + "target": "Merged + hardened into Simtabi\\Laranail\\Toolkit\\Commands\\Tidy (laranail::toolkit.tidy), which moved to laranail/artisan-ui as laranail::artisan-ui.tidy in toolkit 0.3.0." }, "Simtabi\\Laranail\\Laravel\\Listeners\\BaseListener": { "status": "merged", diff --git a/tests/Unit/Console/CommandConsoleFeaturesTest.php b/tests/Unit/Console/CommandConsoleFeaturesTest.php deleted file mode 100644 index 66e8e2f..0000000 --- a/tests/Unit/Console/CommandConsoleFeaturesTest.php +++ /dev/null @@ -1,111 +0,0 @@ -services` lifecycle (metadata, signals, - * performance, logger) and the fluent `consoleWriter()` output. Signal handling - * is driven through the service's running flag — no real OS signal is raised, so - * these pass with or without ext-pcntl. - */ -class CommandConsoleFeaturesTest extends TestCase -{ - // ----------------------------------------------------------------------- - // metadata is populated through the lifecycle - // ----------------------------------------------------------------------- - - public function test_tidy_records_files_processed_and_space_freed_metadata(): void - { - $log = storage_path('logs/meta_' . uniqid() . '.log'); - @mkdir(dirname($log), 0777, true); - file_put_contents($log, str_repeat('x', 128)); - - /** @var Tidy $command */ - $command = $this->app->make(Tidy::class); - - $exit = $this->runCommand($command, ['action' => 'logs', '--force' => true]); - - $this->assertSame(0, $exit); - - $metadata = $command->getServices()->metadata(); - $this->assertSame('logs', $metadata->get('action')); - $this->assertGreaterThanOrEqual(1, $metadata->get('files_processed')); - $this->assertIsString($metadata->get('space_freed')); - $this->assertFileDoesNotExist($log); - } - - // ----------------------------------------------------------------------- - // signal-safe sweep / clean loop (driven via the service flag, no OS signal) - // ----------------------------------------------------------------------- - - public function test_tidy_sweep_stops_when_termination_requested(): void - { - $log = storage_path('logs/keep_' . uniqid() . '.log'); - @mkdir(dirname($log), 0777, true); - file_put_contents($log, 'keep-me'); - - /** @var Tidy $command */ - $command = $this->app->make(Tidy::class); - - // Flip the running flag off before the run so the file sweep bails on the - // first iteration — no file is deleted. shouldKeepRunning() defaults true, - // so this isolates the signal-safe break path without an OS signal. - $exit = $this->runCommand($command, ['action' => 'logs', '--force' => true], static function (Tidy $c): void { - $c->getServices()->signals()->stop(); - }); - - $this->assertSame(0, $exit); - $this->assertFileExists($log, 'A termination request must stop the sweep before deleting.'); - - @unlink($log); - } - - public function test_tidy_all_stops_sweeping_roots_when_termination_requested(): void - { - $upload = storage_path('app/uploads/all_' . uniqid() . '.bin'); - @mkdir(dirname($upload), 0777, true); - file_put_contents($upload, 'keep-me'); - - /** @var Tidy $command */ - $command = $this->app->make(Tidy::class); - - // Stop before the run: tidyAll flushes the cache, then the per-root loop - // breaks on the first check — so the storage upload is never swept. - $exit = $this->runCommand($command, ['action' => 'all', '--force' => true], static function (Tidy $c): void { - $c->getServices()->signals()->stop(); - }); - - $this->assertSame(0, $exit); - $this->assertFileExists($upload); - - @unlink($upload); - } - - /** - * Resolve a registered command, bind a buffered output and run it with the - * given input array — returning the exit code so the caller can assert on - * both the effect and the populated command metadata. - * - * @param array $input - */ - private function runCommand(object $command, array $input, ?callable $before = null): int - { - $command->setLaravel($this->app); - $command->setApplication(new Application); - - if ($before !== null) { - $before($command); - } - - return $command->run(new ArrayInput($input), new BufferedOutput); - } -} diff --git a/tests/Unit/Console/TidyBranchesTest.php b/tests/Unit/Console/TidyBranchesTest.php deleted file mode 100644 index 60cce7d..0000000 --- a/tests/Unit/Console/TidyBranchesTest.php +++ /dev/null @@ -1,263 +0,0 @@ - */ - private array $created = []; - - protected function tearDown(): void - { - foreach ($this->created as $path) { - if (is_file($path) || is_link($path)) { - @unlink($path); - } - } - - parent::tearDown(); - } - - // ----------------------------------------------------------------------- - // Declined confirmations (non-interactive, no --force) - // ----------------------------------------------------------------------- - - public function test_cache_action_declined_in_non_interactive_mode_is_a_noop(): void - { - $this->artisan('laranail::toolkit.tidy', ['action' => 'cache', '--no-interaction' => true]) - ->doesntExpectOutputToContain('Cache tidied.') - ->assertExitCode(0); - } - - public function test_all_action_declined_in_non_interactive_mode_is_a_noop(): void - { - $this->artisan('laranail::toolkit.tidy', ['action' => 'all', '--no-interaction' => true]) - ->doesntExpectOutputToContain('All tidied') - ->assertExitCode(0); - } - - public function test_all_dry_run_previews_the_cache_flush(): void - { - $this->artisan('laranail::toolkit.tidy', ['action' => 'all', '--dry-run' => true]) - ->expectsOutputToContain('[dry-run] Would flush the application cache.') - ->assertExitCode(0); - } - - // ----------------------------------------------------------------------- - // Unresolvable storage path - // ----------------------------------------------------------------------- - - public function test_unresolvable_storage_path_fails(): void - { - $this->app->useStoragePath('/laranail-nonexistent-' . uniqid()); - - $this->artisan('laranail::toolkit.tidy', ['action' => 'logs', '--force' => true]) - ->expectsOutputToContain('storage_path() does not resolve') - ->assertExitCode(1); - } - - // ----------------------------------------------------------------------- - // Cache-flush failure catch - // ----------------------------------------------------------------------- - - public function test_cache_flush_failure_is_caught_and_the_run_completes(): void - { - $spy = $this->makeSpyCommand(); - $spy->throwOnCacheClear = true; - - // cache:clear throws, but the catch logs and the run still reports the - // cache as tidied (exit success). - $this->artisan('laranail::toolkit.tidy-spy', ['action' => 'cache', '--force' => true]) - ->expectsOutputToContain('Cache tidied.') - ->assertExitCode(0); - - $this->assertContains('cache:clear', $spy->calledCommands); - } - - // ----------------------------------------------------------------------- - // db action: declined confirmation + seed - // ----------------------------------------------------------------------- - - public function test_db_action_fails_when_confirm_to_proceed_declines(): void - { - $spy = $this->makeSpyCommand(); - $spy->confirmResult = false; - - $this->artisan('laranail::toolkit.tidy-spy', ['action' => 'db', '--force' => true]) - ->assertExitCode(1); - - // It bailed out before invoking the destructive refresh. - $this->assertNotContains('migrate:fresh', $spy->calledCommands); - } - - public function test_db_action_with_seed_runs_migrate_fresh_then_seed(): void - { - $spy = $this->makeSpyCommand(); - - $this->artisan('laranail::toolkit.tidy-spy', ['action' => 'db', '--force' => true, '--seed' => true]) - ->expectsOutputToContain('Database refreshed.') - ->assertExitCode(0); - - $this->assertSame(['migrate:fresh', 'db:seed'], $spy->calledCommands); - } - - // ----------------------------------------------------------------------- - // sweep(): guard + signal skips (driven directly) - // ----------------------------------------------------------------------- - - public function test_sweep_skips_an_unsafe_relative_root(): void - { - $keep = $this->makeStorageFile('logs/guarded_' . uniqid() . '.log', 'keep'); - - $command = $this->app->make(Tidy::class); - $base = (string) realpath(storage_path()); - - (new ReflectionMethod($command, 'sweep'))->invoke($command, $base, '../escape'); - - $this->assertSame(0, $this->filesProcessed($command)); - $this->assertFileExists($keep); - } - - public function test_sweep_stops_when_a_termination_signal_arrived(): void - { - $keep = $this->makeStorageFile('logs/signalled_' . uniqid() . '.log', 'keep'); - - $command = $this->app->make(Tidy::class); - $command->setLaravel($this->app); - - // Provide an input so the age/size option reads resolve to their defaults. - $input = new ArrayInput([], $command->getDefinition()); - (new ReflectionProperty(Command::class, 'input'))->setValue($command, $input); - - // Flip the running flag off so the per-file loop breaks immediately. - $services = (new ReflectionProperty($command, 'services'))->getValue($command); - $services->signals()->stop(); - - $base = (string) realpath(storage_path()); - (new ReflectionMethod($command, 'sweep'))->invoke($command, $base, 'logs'); - - $this->assertSame(0, $this->filesProcessed($command)); - $this->assertFileExists($keep); - } - - // ----------------------------------------------------------------------- - // sweep(): non-file entries are skipped (via the normal run) - // ----------------------------------------------------------------------- - - public function test_empty_subdirectories_are_skipped_while_files_are_deleted(): void - { - $dir = storage_path('logs/branch_' . uniqid()); - @mkdir($dir . '/emptydir', 0777, true); - $file = $dir . '/old.log'; - file_put_contents($file, 'log'); - $this->created[] = $file; - - $this->artisan('laranail::toolkit.tidy', ['action' => 'logs', '--force' => true]) - ->assertExitCode(0); - - $this->assertFileDoesNotExist($file); - - @rmdir($dir . '/emptydir'); - @rmdir($dir); - } - - private function makeStorageFile(string $relative, string $contents = 'x'): string - { - $path = storage_path($relative); - @mkdir(dirname($path), 0777, true); - file_put_contents($path, $contents); - $this->created[] = $path; - - return $path; - } - - private function makeSpyCommand(): SpyTidy - { - $spy = new SpyTidy( - $this->app->make(FileServiceInterface::class), - $this->app->make(CacheRepositoryInterface::class), - $this->app->make(LoggerServiceInterface::class), - ); - $spy->setLaravel($this->app); - $this->app->make(Kernel::class)->registerCommand($spy); - - return $spy; - } - - private function filesProcessed(Tidy $command): int - { - $value = (new ReflectionProperty(Tidy::class, 'filesProcessed'))->getValue($command); - - return is_int($value) ? $value : -1; - } -} - -/** - * A Tidy subclass that records dispatched sub-commands (without running them), - * can force a cache:clear failure, and can override the production-safety - * confirmation — exercising the db/cache branches deterministically. - */ -class SpyTidy extends Tidy -{ - /** @var list */ - public array $calledCommands = []; - - public bool $throwOnCacheClear = false; - - public ?bool $confirmResult = null; - - protected $signature = 'laranail::toolkit.tidy-spy - {action? : What to tidy} - {--days= : Only delete files older than this many days} - {--size= : Only delete files larger than this many MB} - {--seed : also run db:seed} - {--optimize : also run optimize:clear} - {--dry-run : Show what would be removed} - {--force : Skip confirmation prompts}'; - - /** - * @param array $arguments - */ - public function call($command, array $arguments = []) - { - $name = $command instanceof \Symfony\Component\Console\Command\Command - ? (string) $command->getName() - : $command; - - $this->calledCommands[] = $name; - - if ($this->throwOnCacheClear && $name === 'cache:clear') { - throw new RuntimeException('cache flush failed'); - } - - return self::SUCCESS; - } - - public function confirmToProceed($warning = 'Application In Production', $callback = null) - { - if ($this->confirmResult !== null) { - return $this->confirmResult; - } - - return parent::confirmToProceed($warning, $callback); - } -} diff --git a/tests/Unit/Console/TidyCommandTest.php b/tests/Unit/Console/TidyCommandTest.php deleted file mode 100644 index ae1441e..0000000 --- a/tests/Unit/Console/TidyCommandTest.php +++ /dev/null @@ -1,317 +0,0 @@ -created as $path) { - if (is_file($path) || is_link($path)) { - @unlink($path); - } - } - - parent::tearDown(); - } - - // ----------------------------------------------------------------------- - // Registration - // ----------------------------------------------------------------------- - - public function test_command_is_registered_under_namespaced_name_and_alias(): void - { - $all = collect($this->app[Kernel::class]->all()); - - $this->assertTrue($all->has('laranail::toolkit.tidy')); - $this->assertTrue($all->has('laranail::toolkit.tidy')); - } - - public function test_invalid_action_fails(): void - { - $this->artisan('laranail::toolkit.tidy', ['action' => 'nope']) - ->expectsOutputToContain('Invalid action') - ->assertExitCode(1); - } - - // ----------------------------------------------------------------------- - // logs / temp / storage - // ----------------------------------------------------------------------- - - public function test_logs_action_deletes_log_files_in_storage(): void - { - // A uniquely-named log file so the console lifecycle logger (which may - // (re)write laravel.log during the run) cannot mask the deletion. - $log = $this->makeStorageFile('logs/tidy_' . uniqid() . '.log', 'old log'); - - $this->artisan('laranail::toolkit.tidy', ['action' => 'logs', '--force' => true]) - ->assertExitCode(0); - - $this->assertFileDoesNotExist($log); - } - - public function test_gitignore_is_never_deleted(): void - { - $keep = $this->makeStorageFile('logs/.gitignore', '*'); - $this->makeStorageFile('logs/app.log', 'log'); - - $this->artisan('laranail::toolkit.tidy', ['action' => 'logs', '--force' => true]) - ->assertExitCode(0); - - $this->assertFileExists($keep); - } - - public function test_age_filter_keeps_recent_files(): void - { - $recent = $this->makeStorageFile('logs/recent.log', 'recent'); - - // Keep only files older than 30 days — the just-created file survives. - $this->artisan('laranail::toolkit.tidy', ['action' => 'logs', '--days' => '30', '--force' => true]) - ->assertExitCode(0); - - $this->assertFileExists($recent); - } - - public function test_temp_action_deletes_temp_files(): void - { - $tmp = $this->makeStorageFile('app/temp/old.tmp', 'tmp'); - - $this->artisan('laranail::toolkit.tidy', ['action' => 'temp', '--force' => true]) - ->assertExitCode(0); - - $this->assertFileDoesNotExist($tmp); - } - - public function test_storage_action_deletes_uploaded_files_when_scoped(): void - { - $upload = $this->makeStorageFile('app/uploads/photo.bin', 'bytes'); - touch($upload, time() - (60 * 86400)); - - // Scoped by age. This assertion used to pass with no filter at all, - // which is the defect: --force plus a housekeeping verb emptied - // app/public, app/uploads and app/exports in one non-interactive run. - $this->artisan('laranail::toolkit.tidy', ['action' => 'storage', '--days' => '30', '--force' => true]) - ->assertExitCode(0); - - $this->assertFileDoesNotExist($upload); - } - - public function test_size_filter_only_deletes_large_files(): void - { - $small = $this->makeStorageFile('logs/small.log', 'x'); // 1 byte - $big = $this->makeStorageFile('logs/big.log', str_repeat('y', 3 * 1024 * 1024)); // 3 MB - - // Delete only files >= 2 MB. - $this->artisan('laranail::toolkit.tidy', ['action' => 'logs', '--size' => '2', '--force' => true]) - ->assertExitCode(0); - - $this->assertFileExists($small); - $this->assertFileDoesNotExist($big); - } - - public function test_dry_run_deletes_nothing_and_reports(): void - { - $log = $this->makeStorageFile('logs/dry.log', 'log'); - - $this->artisan('laranail::toolkit.tidy', ['action' => 'logs', '--dry-run' => true]) - ->expectsOutputToContain('[dry-run]') - ->assertExitCode(0); - - $this->assertFileExists($log); - } - - // ----------------------------------------------------------------------- - // db gating - // ----------------------------------------------------------------------- - - public function test_db_action_requires_force(): void - { - $this->artisan('laranail::toolkit.tidy', ['action' => 'db']) - ->expectsOutputToContain('re-run with --force') - ->assertExitCode(1); - } - - public function test_db_action_dry_run_is_noop(): void - { - $this->artisan('laranail::toolkit.tidy', ['action' => 'db', '--dry-run' => true]) - ->expectsOutputToContain('[dry-run]') - ->assertExitCode(0); - } - - public function test_db_action_with_force_runs_migrate_fresh(): void - { - // The in-memory sqlite test DB makes migrate:fresh safe to actually run. - // --force satisfies the gate and confirmToProceed() is a no-op outside - // production, so the real refresh path executes and reports success. - $this->artisan('laranail::toolkit.tidy', ['action' => 'db', '--force' => true]) - ->expectsOutputToContain('Database refreshed') - ->assertExitCode(0); - - // migrate:fresh re-runs the package's own filesystem migrations, so its - // tables exist after the refresh. - $this->assertTrue(Schema::hasTable('access_logs')); - } - - public function test_all_excludes_db_action(): void - { - // `all` must not drop tables: the users table (from loadLaravelMigrations) - // still exists afterwards. - $this->artisan('laranail::toolkit.tidy', ['action' => 'all', '--force' => true]) - ->expectsOutputToContain('db excluded') - ->assertExitCode(0); - - $this->assertTrue(Schema::hasTable('users')); - } - - // ----------------------------------------------------------------------- - // cache - // ----------------------------------------------------------------------- - - public function test_cache_action_succeeds(): void - { - $this->artisan('laranail::toolkit.tidy', ['action' => 'cache', '--force' => true]) - ->assertExitCode(0); - } - - public function test_cache_dry_run_previews(): void - { - $this->artisan('laranail::toolkit.tidy', ['action' => 'cache', '--dry-run' => true]) - ->expectsOutputToContain('[dry-run]') - ->assertExitCode(0); - } - - public function test_cache_optimize_flag_also_clears_optimized_caches(): void - { - // --optimize runs optimize:clear alongside the cache flush. - $this->artisan('laranail::toolkit.tidy', ['action' => 'cache', '--optimize' => true, '--force' => true]) - ->assertExitCode(0); - } - - public function test_cache_dry_run_optimize_is_previewed_in_message(): void - { - $this->artisan('laranail::toolkit.tidy', ['action' => 'cache', '--optimize' => true, '--dry-run' => true]) - ->expectsOutputToContain('optimize:clear') - ->assertExitCode(0); - } - - // ----------------------------------------------------------------------- - // non-interactive confirmation (declines without --force) - // ----------------------------------------------------------------------- - - public function test_logs_without_force_in_non_interactive_mode_deletes_nothing(): void - { - // --no-interaction (no --force): the interaction service is put in - // non-interactive mode and confirmAction() returns the default (false), - // so the destructive sweep is declined and the file survives — a piped/CI - // run never silently deletes. - $keep = $this->makeStorageFile('logs/noforce_' . uniqid() . '.log', 'keep'); - - $this->artisan('laranail::toolkit.tidy', ['action' => 'logs', '--no-interaction' => true]) - ->assertExitCode(0); - - $this->assertFileExists($keep); - } - - // ----------------------------------------------------------------------- - // combined age + size filter - // ----------------------------------------------------------------------- - - public function test_age_filter_deletes_files_older_than_threshold(): void - { - $old = $this->makeStorageFile('logs/aged_' . uniqid() . '.log', 'old'); - // Backdate well beyond the threshold so the age branch deletes it. - touch($old, time() - (40 * 86400)); - - $this->artisan('laranail::toolkit.tidy', ['action' => 'logs', '--days' => '30', '--force' => true]) - ->assertExitCode(0); - - $this->assertFileDoesNotExist($old); - } - - // ----------------------------------------------------------------------- - // Security - // ----------------------------------------------------------------------- - - #[Group('security')] - public function test_symlink_escaping_storage_is_not_deleted(): void - { - $outsideDir = sys_get_temp_dir() . '/laranail-tidy-out-' . uniqid(); - @mkdir($outsideDir, 0777, true); - $secret = $outsideDir . '/secret.log'; - file_put_contents($secret, 'do-not-delete'); - - // A symlink inside storage/logs pointing at the external secret. - $escape = storage_path('logs/escape.log'); - @mkdir(dirname($escape), 0777, true); - @symlink($secret, $escape); - $this->created[] = $escape; - - $this->artisan('laranail::toolkit.tidy', ['action' => 'logs', '--force' => true]) - ->assertExitCode(0); - - // The external target survives; only in-tree files are removed. - $this->assertFileExists($secret); - $this->assertSame('do-not-delete', file_get_contents($secret)); - - @unlink($escape); - @unlink($secret); - @rmdir($outsideDir); - } - - #[Group('security')] - public function test_deletion_is_confined_to_storage_path_in_source(): void - { - $code = $this->executableSource(dirname(__DIR__, 3) . '/src/Commands/Tidy.php'); - - // Roots are storage-relative; deletion uses realpath containment and the - // FilePathGuard; the executable code never sweeps sys_get_temp_dir(). - $this->assertStringContainsString('realpath(storage_path())', $code); - $this->assertStringContainsString('$this->isWithin($real, $root)', $code); - $this->assertStringContainsString('use FilePathGuard;', $code); - $this->assertStringNotContainsString('sys_get_temp_dir()', $code); - } - - private function makeStorageFile(string $relative, string $contents = 'x'): string - { - $path = storage_path($relative); - @mkdir(dirname($path), 0777, true); - file_put_contents($path, $contents); - $this->created[] = $path; - - return $path; - } - - /** - * Return a file's source with comments/docblocks stripped, so assertions - * reflect executable code (not prose that mentions a pattern). - */ - private function executableSource(string $path): string - { - $code = ''; - - foreach (token_get_all((string) file_get_contents($path)) as $token) { - if (is_array($token)) { - if (in_array($token[0], [T_COMMENT, T_DOC_COMMENT], true)) { - continue; - } - - $code .= $token[1]; - - continue; - } - - $code .= $token; - } - - return $code; - } -} diff --git a/tests/Unit/Console/TidyUserFileGuardTest.php b/tests/Unit/Console/TidyUserFileGuardTest.php deleted file mode 100644 index 7dae4be..0000000 --- a/tests/Unit/Console/TidyUserFileGuardTest.php +++ /dev/null @@ -1,223 +0,0 @@ - */ - private array $created = []; - - private ?string $originalEnv = null; - - protected function tearDown(): void - { - foreach ($this->created as $path) { - if (is_file($path)) { - @unlink($path); - } - } - - // Restore before parent::tearDown(): Testbench rolls migrations back - // there, and `migrate:rollback` runs confirmToProceed(), which prompts - // in production against a mocked output that expects no questions. - if ($this->originalEnv !== null) { - $this->app['env'] = $this->originalEnv; - } - - parent::tearDown(); - } - - #[Test] - public function an_unfiltered_storage_sweep_is_refused_even_with_force(): void - { - $upload = $this->upload(); - - $this->artisan('laranail::toolkit.tidy', ['action' => 'storage', '--force' => true]) - ->expectsOutputToContain('Refusing to sweep') - ->assertExitCode(1); - - $this->assertFileExists($upload); - } - - #[Test] - public function the_refusal_names_the_roots_it_declined(): void - { - $this->upload(); - - $this->artisan('laranail::toolkit.tidy', ['action' => 'storage', '--force' => true]) - ->expectsOutputToContain('storage/app/public') - ->assertExitCode(1); - } - - #[Test] - public function an_age_filter_makes_the_sweep_intentional(): void - { - $stale = $this->upload(); - touch($stale, time() - (60 * 86400)); - - $this->artisan('laranail::toolkit.tidy', ['action' => 'storage', '--days' => '30', '--force' => true]) - ->assertExitCode(0); - - $this->assertFileDoesNotExist($stale); - } - - #[Test] - public function a_size_filter_makes_the_sweep_intentional(): void - { - $big = $this->upload('app/exports/report.csv'); - file_put_contents($big, str_repeat('y', 3 * 1024 * 1024)); - - $this->artisan('laranail::toolkit.tidy', ['action' => 'storage', '--size' => '2', '--force' => true]) - ->assertExitCode(0); - - $this->assertFileDoesNotExist($big); - } - - #[Test] - public function unfiltered_is_the_explicit_way_through(): void - { - // Deleting everything remains reachable — it just cannot be reached by - // a flag anyone types for another reason. - $upload = $this->upload(); - - $this->artisan('laranail::toolkit.tidy', [ - 'action' => 'storage', - '--unfiltered' => true, - '--force' => true, - ])->assertExitCode(0); - - $this->assertFileDoesNotExist($upload); - } - - #[Test] - public function unfiltered_is_refused_outright_in_production(): void - { - // No override, deliberately. --force bypasses Laravel's own - // confirmToProceed() by design, so a prompt would not be a gate here. - $this->pretendProduction(); - - $upload = $this->upload(); - - $this->artisan('laranail::toolkit.tidy', [ - 'action' => 'storage', - '--unfiltered' => true, - '--force' => true, - ]) - ->expectsOutputToContain('in production') - ->assertExitCode(1); - - $this->assertFileExists($upload); - } - - #[Test] - public function a_scoped_sweep_still_runs_in_production(): void - { - $this->pretendProduction(); - - $stale = $this->upload(); - touch($stale, time() - (60 * 86400)); - - $this->artisan('laranail::toolkit.tidy', ['action' => 'storage', '--days' => '30', '--force' => true]) - ->assertExitCode(0); - - $this->assertFileDoesNotExist($stale); - } - - #[Test] - public function tidy_all_skips_storage_rather_than_failing(): void - { - // `all` exists for logs and temp. Failing the whole run over storage - // would make the useful part unreachable, so it skips — and says so, - // because an omission this consequential should not have to be - // inferred from a file count. - $upload = $this->upload(); - $log = storage_path('logs/tidy-all-guard.log'); - @mkdir(dirname($log), 0777, true); - file_put_contents($log, 'noise'); - $this->created[] = $log; - - $this->artisan('laranail::toolkit.tidy', ['action' => 'all', '--force' => true]) - ->expectsOutputToContain('storage skipped') - ->assertExitCode(0); - - $this->assertFileExists($upload, 'tidy all --force deleted a user upload.'); - $this->assertFileDoesNotExist($log, 'tidy all stopped sweeping logs.'); - } - - #[Test] - public function tidy_all_sweeps_storage_when_scoped(): void - { - $stale = $this->upload(); - touch($stale, time() - (60 * 86400)); - - $this->artisan('laranail::toolkit.tidy', ['action' => 'all', '--days' => '30', '--force' => true]) - ->assertExitCode(0); - - $this->assertFileDoesNotExist($stale); - } - - #[Test] - public function logs_and_temp_are_unaffected_by_the_guard(): void - { - // They hold regenerable data, which is what makes an unfiltered sweep - // the whole point of the command there. - $log = storage_path('logs/regenerable.log'); - @mkdir(dirname($log), 0777, true); - file_put_contents($log, 'noise'); - $this->created[] = $log; - - $this->artisan('laranail::toolkit.tidy', ['action' => 'logs', '--force' => true]) - ->assertExitCode(0); - - $this->assertFileDoesNotExist($log); - } - - #[Test] - public function a_dry_run_is_refused_too_rather_than_previewing_the_lot(): void - { - // A preview is harmless, but it would print every file in app/public as - // "would delete" — which reads as a plan the next --force will carry - // out, and that plan is the bug. - $upload = $this->upload(); - - $this->artisan('laranail::toolkit.tidy', ['action' => 'storage', '--dry-run' => true]) - ->expectsOutputToContain('Refusing to sweep') - ->assertExitCode(1); - - $this->assertFileExists($upload); - } - - private function pretendProduction(): void - { - $this->originalEnv = (string) $this->app['env']; - $this->app['env'] = 'production'; - } - - private function upload(string $relative = 'app/public/invoice.pdf'): string - { - $path = storage_path($relative); - @mkdir(dirname($path), 0777, true); - file_put_contents($path, 'a user uploaded this'); - $this->created[] = $path; - - return $path; - } -}