diff --git a/app/Console/Commands/DeleteAllKasSubdomains.php b/app/Console/Commands/DeleteAllKasSubdomains.php new file mode 100644 index 00000000..b215265e --- /dev/null +++ b/app/Console/Commands/DeleteAllKasSubdomains.php @@ -0,0 +1,214 @@ +option('domain') !== null + ? trim((string) $this->option('domain'), '.') + : null; + $limit = $this->option('limit') !== null ? (int) $this->option('limit') : null; + $isForce = (bool) $this->option('force'); + + if ($domain === '') { + $this->error('Die Option --domain darf nicht leer sein.'); + + return self::FAILURE; + } + + if ($limit !== null && $limit < 1) { + $this->error('Die Option --limit muss größer als 0 sein.'); + + return self::FAILURE; + } + + try { + $candidates = $this->getKasSubdomains($domain); + + if ($limit !== null) { + $candidates = $candidates->take($limit)->values(); + } + + $listFilePath = $this->writeTemporaryList($candidates, $domain, $isForce); + $this->printPreview($candidates, $domain, $listFilePath, $isForce); + + if ($candidates->isEmpty()) { + $this->info('Keine KAS-Subdomains gefunden.'); + + return self::SUCCESS; + } + + if (! $isForce) { + $this->warn('Dry-Run: Es wurde nichts gelöscht. Mit --force werden die Subdomains aus der temporären Liste gelöscht.'); + + return self::SUCCESS; + } + + return $this->deleteSubdomains($candidates); + } catch (Exception $e) { + $this->error('Fehler beim Löschen der KAS-Subdomains: '.$e->getMessage()); + Log::error('KAS Komplett-Subdomain-Löschung fehlgeschlagen', [ + 'error' => $e->getMessage(), + 'trace' => $e->getTraceAsString(), + ]); + + return self::FAILURE; + } + } + + private function getKasSubdomains(?string $domain): Collection + { + $this->info('Rufe Subdomains direkt von KAS ab...'); + + $subdomains = $this->kas->action('get_subdomains'); + + if (! is_iterable($subdomains)) { + throw new Exception('KAS hat keine auswertbare Subdomain-Liste zurückgegeben: '.json_encode($subdomains)); + } + + return collect($subdomains) + ->filter(function (array $subdomain) use ($domain): bool { + $name = strtolower((string) ($subdomain['subdomain_name'] ?? '')); + + if ($name === '') { + return false; + } + + return $domain === null || str_ends_with($name, '.'.strtolower($domain)); + }) + ->map(fn (array $subdomain): array => [ + 'subdomain_name' => (string) $subdomain['subdomain_name'], + 'subdomain_path' => (string) ($subdomain['subdomain_path'] ?? ''), + 'php_version' => (string) ($subdomain['php_version'] ?? ''), + 'is_active' => (string) ($subdomain['is_active'] ?? ''), + ]) + ->unique('subdomain_name') + ->sortBy('subdomain_name') + ->values(); + } + + private function writeTemporaryList(Collection $candidates, ?string $domain, bool $isForce): string + { + $listFilePath = $this->resolveListFilePath(); + File::ensureDirectoryExists(dirname($listFilePath)); + + File::put($listFilePath, json_encode([ + 'generated_at' => now()->toIso8601String(), + 'mode' => $isForce ? 'delete' : 'dry-run', + 'domain_filter' => $domain, + 'count' => $candidates->count(), + 'subdomains' => $candidates->values()->all(), + ], JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES)); + + return $listFilePath; + } + + private function resolveListFilePath(): string + { + $listFilePath = (string) ($this->option('list-file') ?? ''); + + if ($listFilePath !== '') { + return str_starts_with($listFilePath, '/') ? $listFilePath : base_path($listFilePath); + } + + return storage_path('app/kas-subdomain-delete-lists/kas-subdomains-'.now()->format('Ymd-His').'.json'); + } + + private function printPreview(Collection $candidates, ?string $domain, string $listFilePath, bool $isForce): void + { + $this->newLine(); + $this->info('Modus: '.($isForce ? 'Löschen' : 'Dry-Run')); + $this->info('Domain-Filter: '.($domain ?: 'keiner')); + $this->info('Temporäre Liste: '.$listFilePath); + $this->info('Gefundene KAS-Subdomains: '.$candidates->count()); + + if ($candidates->isEmpty()) { + return; + } + + $this->table( + ['Subdomain', 'Pfad', 'PHP', 'Aktiv'], + $candidates->take(25)->map(fn (array $candidate): array => [ + $candidate['subdomain_name'], + $candidate['subdomain_path'] ?: '-', + $candidate['php_version'] ?: '-', + $candidate['is_active'] ?: '-', + ])->all() + ); + + if ($candidates->count() > 25) { + $this->line('... weitere '.($candidates->count() - 25).' Subdomains ausgeblendet.'); + } + } + + private function deleteSubdomains(Collection $candidates): int + { + $deleted = 0; + $failed = 0; + + foreach ($candidates as $candidate) { + $subdomainName = $candidate['subdomain_name']; + $result = $this->kas->action('delete_subdomain', [ + 'subdomain_name' => $subdomainName, + ]); + + if ($this->isSuccessfulDeleteResult($result, $subdomainName)) { + $deleted++; + $this->info("Gelöscht: {$subdomainName}"); + + continue; + } + + $failed++; + $this->error("Fehler beim Löschen von {$subdomainName}: ".json_encode($result)); + } + + $this->newLine(); + $this->info("Gelöscht: {$deleted}"); + $this->info("Fehler: {$failed}"); + + return $failed === 0 ? self::SUCCESS : self::FAILURE; + } + + private function isSuccessfulDeleteResult(mixed $result, string $subdomainName): bool + { + return $result === true + || $result === 'TRUE' + || $result === $subdomainName; + } +} diff --git a/app/Console/Commands/DeletePartnerShopSubdomains.php b/app/Console/Commands/DeletePartnerShopSubdomains.php new file mode 100644 index 00000000..2384cd50 --- /dev/null +++ b/app/Console/Commands/DeletePartnerShopSubdomains.php @@ -0,0 +1,241 @@ + + */ + private array $protectedPrefixes = ['www.', 'api.', 'checkout.', 'preview.']; + + public function __construct(private KasController $kas) + { + parent::__construct(); + } + + /** + * Execute the console command. + */ + public function handle(): int + { + $domain = trim((string) $this->option('domain'), '.'); + $startId = max(1, (int) $this->option('start')); + $limit = $this->option('limit') !== null ? (int) $this->option('limit') : null; + $isForce = (bool) $this->option('force'); + + if ($domain === '') { + $this->error('Die Option --domain darf nicht leer sein.'); + + return self::FAILURE; + } + + if ($limit !== null && $limit < 1) { + $this->error('Die Option --limit muss größer als 0 sein.'); + + return self::FAILURE; + } + + try { + $subdomains = $this->getKasSubdomains(); + $shopHosts = $this->getPartnerShopHosts($domain, $startId); + $candidates = $this->getDeletionCandidates($subdomains, $shopHosts, $domain); + + if ($limit !== null) { + $candidates = $candidates->take($limit)->values(); + } + + $this->printPreview($candidates, $isForce); + + if ($candidates->isEmpty()) { + $this->info('Keine löschbaren Partnershop-Subdomains gefunden.'); + + return self::SUCCESS; + } + + if (! $isForce) { + $this->warn('Dry-Run: Es wurde nichts gelöscht. Mit --force werden die gefundenen Subdomains gelöscht.'); + + return self::SUCCESS; + } + + return $this->deleteSubdomains($candidates); + } catch (Exception $e) { + $this->error('Fehler beim Löschen der Partnershop-Subdomains: '.$e->getMessage()); + Log::error('KAS Partnershop-Subdomain-Löschung fehlgeschlagen', [ + 'error' => $e->getMessage(), + 'trace' => $e->getTraceAsString(), + ]); + + return self::FAILURE; + } + } + + private function getKasSubdomains(): Collection + { + $this->info('Rufe Subdomains von KAS ab...'); + + $subdomains = $this->kas->action('get_subdomains'); + + if (! is_iterable($subdomains)) { + throw new Exception('KAS hat keine auswertbare Subdomain-Liste zurückgegeben: '.json_encode($subdomains)); + } + + return collect($subdomains); + } + + private function getPartnerShopHosts(string $domain, int $startId): Collection + { + $query = UserShop::query() + ->where('id', '>=', $startId) + ->orderBy('id'); + + if ($this->option('active-only')) { + $query->where('active', true); + } else { + $query->withTrashed(); + } + + return $query + ->pluck('slug') + ->filter() + ->map(fn (string $slug): string => strtolower($slug.'.'.$domain)) + ->unique() + ->values(); + } + + private function getDeletionCandidates(Collection $subdomains, Collection $shopHosts, string $domain): Collection + { + $shopHostLookup = array_flip($shopHosts->all()); + $includePathMatches = (bool) $this->option('include-path-matches'); + $partnerShopPath = (string) $this->option('path'); + + return $subdomains + ->filter(function (array $subdomain) use ($domain, $includePathMatches, $partnerShopPath, $shopHostLookup): bool { + $name = strtolower((string) ($subdomain['subdomain_name'] ?? '')); + + if ($name === '' || ! $this->belongsToDomain($name, $domain) || $this->isProtectedSubdomain($name)) { + return false; + } + + if (isset($shopHostLookup[$name])) { + return true; + } + + return $includePathMatches + && trim((string) ($subdomain['subdomain_path'] ?? '')) === $partnerShopPath; + }) + ->map(fn (array $subdomain): array => [ + 'subdomain_name' => (string) $subdomain['subdomain_name'], + 'subdomain_path' => (string) ($subdomain['subdomain_path'] ?? ''), + 'php_version' => (string) ($subdomain['php_version'] ?? ''), + ]) + ->unique('subdomain_name') + ->sortBy('subdomain_name') + ->values(); + } + + private function belongsToDomain(string $subdomainName, string $domain): bool + { + return str_ends_with($subdomainName, '.'.strtolower($domain)); + } + + private function isProtectedSubdomain(string $subdomainName): bool + { + foreach ($this->protectedPrefixes as $prefix) { + if (str_starts_with($subdomainName, $prefix)) { + return true; + } + } + + return false; + } + + private function printPreview(Collection $candidates, bool $isForce): void + { + $this->newLine(); + $this->info('Modus: '.($isForce ? 'Löschen' : 'Dry-Run')); + $this->info('Gefundene Lösch-Kandidaten: '.$candidates->count()); + + if ($candidates->isEmpty()) { + return; + } + + $this->table( + ['Subdomain', 'Pfad', 'PHP'], + $candidates->take(25)->map(fn (array $candidate): array => [ + $candidate['subdomain_name'], + $candidate['subdomain_path'] ?: '-', + $candidate['php_version'] ?: '-', + ])->all() + ); + + if ($candidates->count() > 25) { + $this->line('... weitere '.($candidates->count() - 25).' Subdomains ausgeblendet.'); + } + } + + private function deleteSubdomains(Collection $candidates): int + { + $deleted = 0; + $failed = 0; + + foreach ($candidates as $candidate) { + $subdomainName = $candidate['subdomain_name']; + $result = $this->kas->action('delete_subdomain', [ + 'subdomain_name' => $subdomainName, + ]); + + if ($this->isSuccessfulDeleteResult($result, $subdomainName)) { + $deleted++; + $this->info("Gelöscht: {$subdomainName}"); + + continue; + } + + $failed++; + $this->error("Fehler beim Löschen von {$subdomainName}: ".json_encode($result)); + } + + $this->newLine(); + $this->info("Gelöscht: {$deleted}"); + $this->info("Fehler: {$failed}"); + + return $failed === 0 ? self::SUCCESS : self::FAILURE; + } + + private function isSuccessfulDeleteResult(mixed $result, string $subdomainName): bool + { + return $result === true + || $result === 'TRUE' + || $result === $subdomainName; + } +} diff --git a/tests/Feature/DeleteAllKasSubdomainsCommandTest.php b/tests/Feature/DeleteAllKasSubdomainsCommandTest.php new file mode 100644 index 00000000..57586286 --- /dev/null +++ b/tests/Feature/DeleteAllKasSubdomainsCommandTest.php @@ -0,0 +1,109 @@ +shouldReceive('action') + ->once() + ->with('get_subdomains') + ->andReturn([ + ['subdomain_name' => 'api.mivita.care', 'subdomain_path' => '/api/public/', 'php_version' => '8.2'], + ['subdomain_name' => 'shop.mivita.care', 'subdomain_path' => '/mein.mivita.care/public/', 'php_version' => '8.2'], + ]); + + $this->app->instance(KasController::class, $kas); + + $this->artisan('subdomains:delete-all-kas', [ + '--list-file' => $listFile, + ])->assertSuccessful(); + + $data = json_decode(file_get_contents($listFile), true); + + expect(file_exists($listFile))->toBeTrue(); + expect($data['count'])->toBe(2); + expect($data['subdomains'])->toHaveCount(2); + expect($data['mode'])->toBe('dry-run'); + + @unlink($listFile); +}); + +it('löscht mit force alle subdomains aus der kas liste ohne datenbankabgleich', function () { + $listFile = sys_get_temp_dir().'/kas-subdomains-force-test-'.uniqid().'.json'; + + $kas = \Mockery::mock(KasController::class); + $kas->shouldReceive('action') + ->once() + ->with('get_subdomains') + ->andReturn([ + ['subdomain_name' => 'api.mivita.care', 'subdomain_path' => '/api/public/'], + ['subdomain_name' => 'preview.mivita.care', 'subdomain_path' => '/preview/public/'], + ['subdomain_name' => 'partner.mivita.care', 'subdomain_path' => '/mein.mivita.care/public/'], + ]); + $kas->shouldReceive('action') + ->once() + ->with('delete_subdomain', ['subdomain_name' => 'api.mivita.care']) + ->andReturn('TRUE'); + $kas->shouldReceive('action') + ->once() + ->with('delete_subdomain', ['subdomain_name' => 'partner.mivita.care']) + ->andReturn('TRUE'); + $kas->shouldReceive('action') + ->once() + ->with('delete_subdomain', ['subdomain_name' => 'preview.mivita.care']) + ->andReturn('TRUE'); + + $this->app->instance(KasController::class, $kas); + + $this->artisan('subdomains:delete-all-kas', [ + '--force' => true, + '--list-file' => $listFile, + ])->assertSuccessful(); + + $data = json_decode(file_get_contents($listFile), true); + + expect($data['count'])->toBe(3); + expect($data['mode'])->toBe('delete'); + + @unlink($listFile); +}); + +it('kann optional auf eine hauptdomain und ein limit begrenzen', function () { + $listFile = sys_get_temp_dir().'/kas-subdomains-filter-test-'.uniqid().'.json'; + + $kas = \Mockery::mock(KasController::class); + $kas->shouldReceive('action') + ->once() + ->with('get_subdomains') + ->andReturn([ + ['subdomain_name' => 'a.mivita.care'], + ['subdomain_name' => 'b.mivita.care'], + ['subdomain_name' => 'extern.example.com'], + ]); + $kas->shouldReceive('action') + ->once() + ->with('delete_subdomain', ['subdomain_name' => 'a.mivita.care']) + ->andReturn('TRUE'); + + $this->app->instance(KasController::class, $kas); + + $this->artisan('subdomains:delete-all-kas', [ + '--force' => true, + '--domain' => 'mivita.care', + '--limit' => 1, + '--list-file' => $listFile, + ])->assertSuccessful(); + + $data = json_decode(file_get_contents($listFile), true); + + expect($data['count'])->toBe(1); + expect($data['domain_filter'])->toBe('mivita.care'); + expect($data['subdomains'])->toHaveCount(1); + + @unlink($listFile); +}); diff --git a/tests/Feature/DeletePartnerShopSubdomainsCommandTest.php b/tests/Feature/DeletePartnerShopSubdomainsCommandTest.php new file mode 100644 index 00000000..72cbbf29 --- /dev/null +++ b/tests/Feature/DeletePartnerShopSubdomainsCommandTest.php @@ -0,0 +1,113 @@ +increments('id'); + $table->string('email')->unique(); + $table->string('password'); + $table->char('lang', 2)->default('de'); + $table->timestamps(); + $table->softDeletes(); + }); + + Schema::create('user_shops', function (Blueprint $table) { + $table->increments('id'); + $table->unsignedInteger('user_id'); + $table->string('name', 20)->unique(); + $table->string('slug', 40)->unique(); + $table->boolean('active')->default(false); + $table->timestamps(); + $table->softDeletes(); + }); +}); + +afterEach(function () { + Schema::dropIfExists('user_shops'); + Schema::dropIfExists('users'); +}); + +it('führt ohne force nur einen dry run aus', function () { + UserShop::forceCreate([ + 'user_id' => User::forceCreate([ + 'email' => 'partner@example.test', + 'password' => 'secret', + 'lang' => 'de', + ])->id, + 'name' => 'Partner Shop', + 'slug' => 'partner', + 'active' => true, + ]); + + $kas = \Mockery::mock(KasController::class); + $kas->shouldReceive('action') + ->once() + ->with('get_subdomains') + ->andReturn([ + ['subdomain_name' => 'partner.mivita.care', 'subdomain_path' => '/mein.mivita.care/public/'], + ]); + $this->app->instance(KasController::class, $kas); + + $this->artisan('subdomains:delete-partnershops')->assertSuccessful(); +}); + +it('löscht passende partnershop subdomains mit force', function () { + UserShop::forceCreate([ + 'user_id' => User::forceCreate([ + 'email' => 'shop@example.test', + 'password' => 'secret', + 'lang' => 'de', + ])->id, + 'name' => 'Shop Eins', + 'slug' => 'shop-eins', + 'active' => true, + ]); + + $kas = \Mockery::mock(KasController::class); + $kas->shouldReceive('action') + ->once() + ->with('get_subdomains') + ->andReturn([ + ['subdomain_name' => 'shop-eins.mivita.care', 'subdomain_path' => '/mein.mivita.care/public/'], + ['subdomain_name' => 'api.mivita.care', 'subdomain_path' => '/mein.mivita.care/public/'], + ['subdomain_name' => 'extern.mivita.care', 'subdomain_path' => '/andere-app/public/'], + ]); + $kas->shouldReceive('action') + ->once() + ->with('delete_subdomain', ['subdomain_name' => 'shop-eins.mivita.care']) + ->andReturn('TRUE'); + + $this->app->instance(KasController::class, $kas); + + $this->artisan('subdomains:delete-partnershops --force')->assertSuccessful(); +}); + +it('kann verwaiste partnershop subdomains über den kas pfad löschen', function () { + $kas = \Mockery::mock(KasController::class); + $kas->shouldReceive('action') + ->once() + ->with('get_subdomains') + ->andReturn([ + ['subdomain_name' => 'alte-partnerin.mivita.care', 'subdomain_path' => '/mein.mivita.care/public/'], + ['subdomain_name' => 'preview.mivita.care', 'subdomain_path' => '/mein.mivita.care/public/'], + ]); + $kas->shouldReceive('action') + ->once() + ->with('delete_subdomain', ['subdomain_name' => 'alte-partnerin.mivita.care']) + ->andReturn('TRUE'); + + $this->app->instance(KasController::class, $kas); + + $this->artisan('subdomains:delete-partnershops --force --include-path-matches')->assertSuccessful(); +});