From 536649652733bc7c69077547c6647b8d0e9c87c0 Mon Sep 17 00:00:00 2001 From: Manfred Obumma Date: Thu, 5 Nov 2026 17:27:49 +0100 Subject: [PATCH] feat(occ): app:purge, to find and delete what disabled and removed apps leave behind --- core/Command/App/Purge.php | 161 ++++++++++++++ core/Command/App/Remove.php | 1 + core/register_command.php | 2 + lib/composer/composer/autoload_classmap.php | 3 + lib/composer/composer/autoload_static.php | 3 + lib/private/App/AppDataPurger.php | 227 ++++++++++++++++++++ lib/private/App/AppLeftovers.php | 56 +++++ lib/private/DB/SchemaChecker.php | 52 +++++ tests/Core/Command/App/PurgeTest.php | 109 ++++++++++ tests/lib/App/AppDataPurgerTest.php | 199 +++++++++++++++++ tests/lib/DB/SchemaCheckerTest.php | 5 + 11 files changed, 818 insertions(+) create mode 100644 core/Command/App/Purge.php create mode 100644 lib/private/App/AppDataPurger.php create mode 100644 lib/private/App/AppLeftovers.php create mode 100644 tests/Core/Command/App/PurgeTest.php create mode 100644 tests/lib/App/AppDataPurgerTest.php diff --git a/core/Command/App/Purge.php b/core/Command/App/Purge.php new file mode 100644 index 0000000000000..30237ea1c0293 --- /dev/null +++ b/core/Command/App/Purge.php @@ -0,0 +1,161 @@ +setName('app:purge') + ->setDescription('List or delete the data that disabled and removed apps left behind') + ->setHelp( + "Without an app id, lists the apps that are not enabled but still have data.\n" + . "With one, shows what that app left behind: its database tables, app config, user preferences,\n" + . "background jobs, migration records and app data folder. Nothing is deleted without --force.\n" + . "\n" + . "An app's tables are found from its migrations, so purge an app before removing its code:\n" + . " occ app:disable \n" + . " occ app:purge --force\n" + . " occ app:remove --keep-data \n" + . "\n" + . "Files the app saved in users' own folders are not touched. Back up the database first." + ) + ->addArgument('app-id', InputArgument::OPTIONAL, 'the app whose data to show or delete') + ->addOption('force', 'f', InputOption::VALUE_NONE, 'delete it, rather than only showing what would be deleted'); + parent::configure(); + } + + #[Override] + protected function execute(InputInterface $input, OutputInterface $output): int { + $appId = $input->getArgument('app-id'); + $plain = $input->getOption('output') === self::OUTPUT_FORMAT_PLAIN; + + if ($appId === null) { + $found = $this->purger->findAppsWithLeftovers(); + if (!$plain) { + $this->writeArrayInOutputFormat($input, $output, array_map(fn (AppLeftovers $l) => $l->toArray(), $found)); + } elseif ($found === []) { + $output->writeln('No disabled or removed app has data left behind.'); + } else { + foreach ($found as $leftovers) { + $code = $leftovers->codePresent ? 'disabled' : 'removed'; + $output->writeln("{$leftovers->appId} ($code): " . $this->summarize($leftovers)); + } + $output->writeln(''); + $output->writeln('Run occ app:purge to see the details for one app.'); + } + return self::SUCCESS; + } + + $appId = (string)$appId; + $refusal = $this->purger->getRefusal($appId); + if ($refusal !== null) { + $output->writeln("$refusal"); + return self::FAILURE; + } + + try { + $leftovers = $this->purger->getLeftovers($appId); + } catch (\RuntimeException $e) { + $output->writeln('' . $e->getMessage() . ''); + $output->writeln('Nothing was deleted.'); + return self::FAILURE; + } + $force = (bool)$input->getOption('force'); + + if (!$plain) { + if ($force && !$leftovers->isEmpty()) { + $this->purger->purge($leftovers); + } + $this->writeArrayInOutputFormat($input, $output, $leftovers->toArray() + ['deleted' => $force && !$leftovers->isEmpty()]); + return self::SUCCESS; + } + + if ($leftovers->isEmpty()) { + $output->writeln("App '$appId' has no data left behind."); + return self::SUCCESS; + } + + $output->writeln(($force ? 'Deleting' : 'Found') . " what app '$appId' left behind:"); + $this->describe($leftovers, $output); + + if (!$leftovers->codePresent) { + $output->writeln(''); + $output->writeln("The app's code is gone, so its tables cannot be found. To delete them too, install it again," + . ' disable it, and run this before removing it. occ db:schema:check -v lists tables no app accounts for.'); + } + + if (!$force) { + $output->writeln(''); + $output->writeln('Nothing was deleted. Run again with --force to delete all of it.'); + return self::SUCCESS; + } + + $this->purger->purge($leftovers); + $output->writeln(''); + $output->writeln("Deleted what app '$appId' left behind."); + return self::SUCCESS; + } + + private function describe(AppLeftovers $leftovers, OutputInterface $output): void { + foreach ($leftovers->tables as $table) { + $output->writeln(" - table $table"); + } + $lines = [ + [$leftovers->configKeys, 'app config value', 'app config values'], + [$leftovers->userPreferences, 'user preference', 'user preferences'], + [$leftovers->backgroundJobs, 'background job', 'background jobs'], + [$leftovers->migrations, 'migration record', 'migration records'], + ]; + foreach ($lines as [$count, $one, $many]) { + if ($count > 0) { + $output->writeln(" - $count " . ($count === 1 ? $one : $many)); + } + } + if ($leftovers->appDataSize !== null) { + $output->writeln(' - app data folder, ' . Util::humanFileSize($leftovers->appDataSize)); + } + } + + private function summarize(AppLeftovers $leftovers): string { + $parts = []; + $tables = count($leftovers->tables); + foreach ([ + [$tables, 'table', 'tables'], + [$leftovers->configKeys, 'config value', 'config values'], + [$leftovers->userPreferences, 'user preference', 'user preferences'], + [$leftovers->backgroundJobs, 'background job', 'background jobs'], + [$leftovers->migrations, 'migration record', 'migration records'], + ] as [$count, $one, $many]) { + if ($count > 0) { + $parts[] = "$count " . ($count === 1 ? $one : $many); + } + } + if ($leftovers->appDataSize !== null) { + $parts[] = 'app data ' . Util::humanFileSize($leftovers->appDataSize); + } + return implode(', ', $parts); + } +} diff --git a/core/Command/App/Remove.php b/core/Command/App/Remove.php index bb69c3fc79228..594aa4b619539 100644 --- a/core/Command/App/Remove.php +++ b/core/Command/App/Remove.php @@ -42,6 +42,7 @@ protected function configure(): void { . "By default, this command runs the app's uninstall steps (which may delete data) and then removes the app files.\n" . "Use `--keep-data` to skip uninstall steps and preserve app data (database tables, configuration, and stored files).\n" . "Note: Some apps may still preserve data either way, depending on their uninstall implementation.\n" + . "To delete what an app leaves behind, disable it and run `occ app:purge` before removing it.\n" ) ->addArgument( 'app-id', diff --git a/core/register_command.php b/core/register_command.php index 610ce4bf802bc..b3059dbfce57b 100644 --- a/core/register_command.php +++ b/core/register_command.php @@ -14,6 +14,7 @@ use OC\Core\Command\App\GetPath; use OC\Core\Command\App\Install; use OC\Core\Command\App\ListApps; +use OC\Core\Command\App\Purge; use OC\Core\Command\App\Remove; use OC\Core\Command\App\Update; use OC\Core\Command\Background\Delete; @@ -146,6 +147,7 @@ $application->addCommand(Server::get(GetPath::class)); $application->addCommand(Server::get(ListApps::class)); $application->addCommand(Server::get(Remove::class)); + $application->addCommand(Server::get(Purge::class)); $application->addCommand(Server::get(Update::class)); $application->addCommand(Server::get(Cleanup::class)); diff --git a/lib/composer/composer/autoload_classmap.php b/lib/composer/composer/autoload_classmap.php index 4e5f58091a09a..67b83bb4f88e9 100644 --- a/lib/composer/composer/autoload_classmap.php +++ b/lib/composer/composer/autoload_classmap.php @@ -1281,6 +1281,8 @@ 'OC\\AppFramework\\Utility\\TimeFactory' => $baseDir . '/lib/private/AppFramework/Utility/TimeFactory.php', 'OC\\AppScriptDependency' => $baseDir . '/lib/private/AppScriptDependency.php', 'OC\\AppScriptSort' => $baseDir . '/lib/private/AppScriptSort.php', + 'OC\\App\\AppDataPurger' => $baseDir . '/lib/private/App/AppDataPurger.php', + 'OC\\App\\AppLeftovers' => $baseDir . '/lib/private/App/AppLeftovers.php', 'OC\\App\\AppManager' => $baseDir . '/lib/private/App/AppManager.php', 'OC\\App\\AppStore\\AppNotFoundException' => $baseDir . '/lib/private/App/AppStore/AppNotFoundException.php', 'OC\\App\\AppStore\\AppStoreLinkVisibility' => $baseDir . '/lib/private/App/AppStore/AppStoreLinkVisibility.php', @@ -1464,6 +1466,7 @@ 'OC\\Core\\Command\\App\\GetPath' => $baseDir . '/core/Command/App/GetPath.php', 'OC\\Core\\Command\\App\\Install' => $baseDir . '/core/Command/App/Install.php', 'OC\\Core\\Command\\App\\ListApps' => $baseDir . '/core/Command/App/ListApps.php', + 'OC\\Core\\Command\\App\\Purge' => $baseDir . '/core/Command/App/Purge.php', 'OC\\Core\\Command\\App\\Remove' => $baseDir . '/core/Command/App/Remove.php', 'OC\\Core\\Command\\App\\Update' => $baseDir . '/core/Command/App/Update.php', 'OC\\Core\\Command\\Background\\Delete' => $baseDir . '/core/Command/Background/Delete.php', diff --git a/lib/composer/composer/autoload_static.php b/lib/composer/composer/autoload_static.php index f0a8b72dbdd85..0fb57f16196aa 100644 --- a/lib/composer/composer/autoload_static.php +++ b/lib/composer/composer/autoload_static.php @@ -1322,6 +1322,8 @@ class ComposerStaticInit749170dad3f5e7f9ca158f5a9f04f6a2 'OC\\AppFramework\\Utility\\TimeFactory' => __DIR__ . '/../../..' . '/lib/private/AppFramework/Utility/TimeFactory.php', 'OC\\AppScriptDependency' => __DIR__ . '/../../..' . '/lib/private/AppScriptDependency.php', 'OC\\AppScriptSort' => __DIR__ . '/../../..' . '/lib/private/AppScriptSort.php', + 'OC\\App\\AppDataPurger' => __DIR__ . '/../../..' . '/lib/private/App/AppDataPurger.php', + 'OC\\App\\AppLeftovers' => __DIR__ . '/../../..' . '/lib/private/App/AppLeftovers.php', 'OC\\App\\AppManager' => __DIR__ . '/../../..' . '/lib/private/App/AppManager.php', 'OC\\App\\AppStore\\AppNotFoundException' => __DIR__ . '/../../..' . '/lib/private/App/AppStore/AppNotFoundException.php', 'OC\\App\\AppStore\\AppStoreLinkVisibility' => __DIR__ . '/../../..' . '/lib/private/App/AppStore/AppStoreLinkVisibility.php', @@ -1505,6 +1507,7 @@ class ComposerStaticInit749170dad3f5e7f9ca158f5a9f04f6a2 'OC\\Core\\Command\\App\\GetPath' => __DIR__ . '/../../..' . '/core/Command/App/GetPath.php', 'OC\\Core\\Command\\App\\Install' => __DIR__ . '/../../..' . '/core/Command/App/Install.php', 'OC\\Core\\Command\\App\\ListApps' => __DIR__ . '/../../..' . '/core/Command/App/ListApps.php', + 'OC\\Core\\Command\\App\\Purge' => __DIR__ . '/../../..' . '/core/Command/App/Purge.php', 'OC\\Core\\Command\\App\\Remove' => __DIR__ . '/../../..' . '/core/Command/App/Remove.php', 'OC\\Core\\Command\\App\\Update' => __DIR__ . '/../../..' . '/core/Command/App/Update.php', 'OC\\Core\\Command\\Background\\Delete' => __DIR__ . '/../../..' . '/core/Command/Background/Delete.php', diff --git a/lib/private/App/AppDataPurger.php b/lib/private/App/AppDataPurger.php new file mode 100644 index 0000000000000..302466366403e --- /dev/null +++ b/lib/private/App/AppDataPurger.php @@ -0,0 +1,227 @@ +appManager->cleanAppId($appId) !== $appId) { + return "'$appId' is not a valid app id."; + } + if ($appId === 'core' || $this->appManager->isShipped($appId)) { + return "App '$appId' is shipped with Nextcloud; its data cannot be purged."; + } + if ($this->appManager->isEnabledForAnyone($appId)) { + return "App '$appId' is enabled. Disable it first with: occ app:disable $appId"; + } + return null; + } + + /** + * Apps that are not enabled but still have data, sorted by id. + * + * @return list + */ + public function findAppsWithLeftovers(): array { + // Apps that were once installed: those with a recorded version, and those whose migrations ran + $candidates = array_keys($this->appConfig->getAppInstalledVersions()); + + $qb = $this->connection->getQueryBuilder(); + $result = $qb->selectDistinct('app')->from('migrations')->executeQuery(); + while (($app = $result->fetchOne()) !== false) { + $candidates[] = (string)$app; + } + $result->closeCursor(); + + $candidates = array_unique($candidates); + sort($candidates); + + $found = []; + foreach ($candidates as $appId) { + if ($this->getRefusal($appId) !== null) { + continue; + } + try { + $leftovers = $this->getLeftovers($appId); + } catch (\RuntimeException) { + // Listed anyway, so it is not overlooked; the details say why its tables are unknown + $found[] = $this->getLeftoversWithoutTables($appId); + continue; + } + if (!$leftovers->isEmpty()) { + $found[] = $leftovers; + } + } + return $found; + } + + /** + * @throws \RuntimeException when the app's migrations cannot be read, so its tables are unknown + */ + public function getLeftovers(string $appId): AppLeftovers { + try { + $tables = $this->schemaChecker->getTablesCreatedBy($appId); + } catch (\Throwable $e) { + $this->logger->warning('Could not replay the migrations of app {app} to find its tables', ['app' => $appId, 'exception' => $e]); + throw new \RuntimeException("Could not find the tables of app '$appId' from its migrations: " . $e->getMessage(), 0, $e); + } + $existing = []; + foreach ($tables ?? [] as $table) { + if ($this->connection->tableExists($this->withoutPrefix($table))) { + $existing[] = $table; + } + } + + $appData = $this->getAppDataFolder($appId); + + return new AppLeftovers( + appId: $appId, + codePresent: $tables !== null, + tables: $existing, + configKeys: count($this->appConfig->getKeys($appId)), + userPreferences: $this->count('preferences', 'appid', $appId), + backgroundJobs: $this->countJobs($appId), + migrations: $this->count('migrations', 'app', $appId), + appDataSize: $appData === null ? null : $this->folderSize($appData), + ); + } + + private function getLeftoversWithoutTables(string $appId): AppLeftovers { + $appData = $this->getAppDataFolder($appId); + return new AppLeftovers( + appId: $appId, + codePresent: true, + tables: [], + configKeys: count($this->appConfig->getKeys($appId)), + userPreferences: $this->count('preferences', 'appid', $appId), + backgroundJobs: $this->countJobs($appId), + migrations: $this->count('migrations', 'app', $appId), + appDataSize: $appData === null ? null : $this->folderSize($appData), + ); + } + + /** + * Deletes all of what getLeftovers() found. The app's code, if still there, is left for `occ app:remove`. + */ + public function purge(AppLeftovers $leftovers): void { + $appId = $leftovers->appId; + $refusal = $this->getRefusal($appId); + if ($refusal !== null) { + throw new \InvalidArgumentException($refusal); + } + + foreach ($leftovers->tables as $table) { + $this->connection->dropTable($this->withoutPrefix($table)); + } + + $qb = $this->connection->getQueryBuilder(); + $qb->delete('migrations') + ->where($qb->expr()->eq('app', $qb->createNamedParameter($appId))) + ->executeStatement(); + + $qb = $this->connection->getQueryBuilder(); + $qb->delete('jobs') + ->where($qb->expr()->like('class', $qb->createNamedParameter($this->jobClassPattern($appId)))) + ->executeStatement(); + + $this->userConfig->deleteApp($appId); + $this->appConfig->deleteApp($appId); + $this->getAppDataFolder($appId)?->delete(); + + $this->logger->info('Purged the data of app {app}', ['app' => $appId, 'leftovers' => $leftovers->toArray()]); + } + + private function withoutPrefix(string $table): string { + $prefix = $this->config->getSystemValueString('dbtableprefix', 'oc_'); + return str_starts_with($table, $prefix) ? substr($table, strlen($prefix)) : $table; + } + + private function count(string $table, string $column, string $appId): int { + $qb = $this->connection->getQueryBuilder(); + $qb->select($qb->func()->count('*')) + ->from($table) + ->where($qb->expr()->eq($column, $qb->createNamedParameter($appId))); + return (int)$qb->executeQuery()->fetchOne(); + } + + private function countJobs(string $appId): int { + $qb = $this->connection->getQueryBuilder(); + $qb->select($qb->func()->count('*')) + ->from('jobs') + ->where($qb->expr()->like('class', $qb->createNamedParameter($this->jobClassPattern($appId), IQueryBuilder::PARAM_STR))); + return (int)$qb->executeQuery()->fetchOne(); + } + + /** + * Background jobs are classes in the app's namespace. Without the app's code its namespace is a guess, + * but Nextcloud drops jobs whose class no longer exists the next time it tries to run them anyway. + */ + private function jobClassPattern(string $appId): string { + $namespace = $this->appManager->getAppNamespace($appId); + return $this->connection->escapeLikeParameter($namespace . '\\') . '%'; + } + + /** + * Summed from the files themselves: a folder's own recorded size can lag behind what is in it. + */ + private function folderSize(Folder $folder): int { + $size = 0; + foreach ($folder->getDirectoryListing() as $node) { + $size += $node instanceof Folder ? $this->folderSize($node) : (int)$node->getSize(); + } + return $size; + } + + private function getAppDataFolder(string $appId): ?Folder { + $instanceId = $this->config->getSystemValueString('instanceid'); + if ($instanceId === '') { + return null; + } + try { + $folder = $this->rootFolder->get('appdata_' . $instanceId . '/' . $appId); + } catch (NotFoundException) { + return null; + } + return $folder instanceof Folder ? $folder : null; + } +} diff --git a/lib/private/App/AppLeftovers.php b/lib/private/App/AppLeftovers.php new file mode 100644 index 0000000000000..1890df903e0f7 --- /dev/null +++ b/lib/private/App/AppLeftovers.php @@ -0,0 +1,56 @@ + $tables tables its migrations create that exist, with the table prefix + * @param ?int $appDataSize size in bytes of its app data folder, or null when it has none + */ + public function __construct( + public readonly string $appId, + public readonly bool $codePresent, + public readonly array $tables, + public readonly int $configKeys, + public readonly int $userPreferences, + public readonly int $backgroundJobs, + public readonly int $migrations, + public readonly ?int $appDataSize, + ) { + } + + public function isEmpty(): bool { + return $this->tables === [] + && $this->configKeys === 0 + && $this->userPreferences === 0 + && $this->backgroundJobs === 0 + && $this->migrations === 0 + && $this->appDataSize === null; + } + + /** + * @return array{app: string, code_present: bool, tables: list, config_keys: int, user_preferences: int, background_jobs: int, migrations: int, app_data_size: ?int} + */ + public function toArray(): array { + return [ + 'app' => $this->appId, + 'code_present' => $this->codePresent, + 'tables' => $this->tables, + 'config_keys' => $this->configKeys, + 'user_preferences' => $this->userPreferences, + 'background_jobs' => $this->backgroundJobs, + 'migrations' => $this->migrations, + 'app_data_size' => $this->appDataSize, + ]; + } +} diff --git a/lib/private/DB/SchemaChecker.php b/lib/private/DB/SchemaChecker.php index 227077ed5f2ff..f86d13531b7b1 100644 --- a/lib/private/DB/SchemaChecker.php +++ b/lib/private/DB/SchemaChecker.php @@ -15,6 +15,7 @@ use Doctrine\DBAL\Types\StringType; use Doctrine\DBAL\Types\Type; use Doctrine\DBAL\Types\Types; +use OC\App\AppManager; use OC\Migration\NullOutput; use OCP\App\AppPathNotFoundException; use OCP\App\IAppManager; @@ -98,6 +99,57 @@ public function getFindings(?string $onlyTable = null): array { }, array_values($findings)); } + /** + * The tables a disabled app's migrations create, with the table prefix, or null when its code is gone and there + * are no migrations to read them from. + * + * Core's and the enabled apps' migrations are replayed first, so the app's own migrations can change their + * tables without failing, and only the tables that are new with the app count as its own. Unlike + * getFindings(), a migration that cannot be replayed is an error rather than a warning: a partial list + * would leave tables behind while seeming complete. + * + * @return list|null + * @throws \Throwable when the app's migrations cannot be replayed + */ + public function getTablesCreatedBy(string $app): ?array { + try { + $appPath = $this->appManager->getAppPath($app); + } catch (AppPathNotFoundException) { + return null; + } + + $schema = new Schema(); + $this->applyMigrations('core', $schema); + foreach ($this->appManager->getEnabledApps() as $enabledApp) { + if ($enabledApp === $app) { + continue; + } + try { + $this->applyMigrations($enabledApp, $schema); + } catch (AppPathNotFoundException) { + } + } + + $before = []; + foreach ($schema->getTables() as $table) { + $before[$table->getName()] = true; + } + + // As when an app is installed or updated: its migrations may need its other classes + if ($this->appManager instanceof AppManager) { + $this->appManager->registerAutoloading($app, $appPath); + } + $this->applyMigrations($app, $schema); + + $tables = []; + foreach ($schema->getTables() as $table) { + if (!isset($before[$table->getName()])) { + $tables[] = $table->getName(); + } + } + return $tables; + } + /** * @param array{table: string, type: string, name?: string, changes?: list, app?: ?string, enabled?: bool} $finding */ diff --git a/tests/Core/Command/App/PurgeTest.php b/tests/Core/Command/App/PurgeTest.php new file mode 100644 index 0000000000000..db5b1e4e92911 --- /dev/null +++ b/tests/Core/Command/App/PurgeTest.php @@ -0,0 +1,109 @@ +purger = $this->createMock(AppDataPurger::class); + $this->command = new CommandTester(new Purge($this->purger)); + } + + private function leftovers(string $app = 'polls', bool $codePresent = true, array $tables = ['oc_polls_polls', 'oc_polls_votes']): AppLeftovers { + return new AppLeftovers($app, $codePresent, $tables, 3, 1, 2, 8, 2048); + } + + public function testListsAppsWithLeftovers(): void { + $this->purger->method('findAppsWithLeftovers')->willReturn([ + $this->leftovers(), + $this->leftovers('oldapp', false, []), + ]); + + $this->assertSame(0, $this->command->execute([])); + $display = $this->command->getDisplay(); + $this->assertStringContainsString('polls (disabled): 2 tables, 3 config values, 1 user preference, 2 background jobs, 8 migration records, app data 2 KB', $display); + $this->assertStringContainsString('oldapp (removed): 3 config values', $display); + } + + public function testSaysSoWhenThereIsNothing(): void { + $this->purger->method('findAppsWithLeftovers')->willReturn([]); + $this->assertSame(0, $this->command->execute([])); + $this->assertStringContainsString('No disabled or removed app has data left behind.', $this->command->getDisplay()); + } + + public function testShowsWithoutDeleting(): void { + $this->purger->method('getRefusal')->willReturn(null); + $this->purger->method('getLeftovers')->with('polls')->willReturn($this->leftovers()); + $this->purger->expects($this->never())->method('purge'); + + $this->assertSame(0, $this->command->execute(['app-id' => 'polls'])); + $display = $this->command->getDisplay(); + $this->assertStringContainsString('- table oc_polls_votes', $display); + $this->assertStringContainsString('- 8 migration records', $display); + $this->assertStringContainsString('Nothing was deleted. Run again with --force', $display); + } + + public function testDeletesWithForce(): void { + $leftovers = $this->leftovers(); + $this->purger->method('getRefusal')->willReturn(null); + $this->purger->method('getLeftovers')->willReturn($leftovers); + $this->purger->expects($this->once())->method('purge')->with($leftovers); + + $this->assertSame(0, $this->command->execute(['app-id' => 'polls', '--force' => true])); + $this->assertStringContainsString("Deleted what app 'polls' left behind.", $this->command->getDisplay()); + } + + public function testExplainsThatTheTablesOfARemovedAppAreUnknown(): void { + $this->purger->method('getRefusal')->willReturn(null); + $this->purger->method('getLeftovers')->willReturn($this->leftovers('oldapp', false, [])); + + $this->command->execute(['app-id' => 'oldapp']); + $this->assertStringContainsString('code is gone, so its tables cannot be found', $this->command->getDisplay()); + } + + public function testRefusesWithAReason(): void { + $this->purger->method('getRefusal')->willReturn("App 'polls' is enabled. Disable it first with: occ app:disable polls"); + $this->purger->expects($this->never())->method('getLeftovers'); + $this->purger->expects($this->never())->method('purge'); + + $this->assertSame(1, $this->command->execute(['app-id' => 'polls', '--force' => true])); + $this->assertStringContainsString('Disable it first', $this->command->getDisplay()); + } + + public function testStopsWhenTheTablesCannotBeFound(): void { + $this->purger->method('getRefusal')->willReturn(null); + $this->purger->method('getLeftovers')->willThrowException(new \RuntimeException("Could not find the tables of app 'polls' from its migrations")); + $this->purger->expects($this->never())->method('purge'); + + $this->assertSame(1, $this->command->execute(['app-id' => 'polls', '--force' => true])); + $this->assertStringContainsString('Nothing was deleted.', $this->command->getDisplay()); + } + + public function testJsonOutput(): void { + $this->purger->method('getRefusal')->willReturn(null); + $this->purger->method('getLeftovers')->willReturn($this->leftovers()); + + $this->command->execute(['app-id' => 'polls', '--output' => 'json']); + $data = json_decode($this->command->getDisplay(), true); + $this->assertSame('polls', $data['app']); + $this->assertSame(['oc_polls_polls', 'oc_polls_votes'], $data['tables']); + $this->assertFalse($data['deleted']); + } +} diff --git a/tests/lib/App/AppDataPurgerTest.php b/tests/lib/App/AppDataPurgerTest.php new file mode 100644 index 0000000000000..cc7f1e4a0db74 --- /dev/null +++ b/tests/lib/App/AppDataPurgerTest.php @@ -0,0 +1,199 @@ +connection = Server::get(IDBConnection::class); + $this->appConfig = Server::get(IAppConfig::class); + $this->userConfig = Server::get(IUserConfig::class); + $this->prefix = Server::get(IConfig::class)->getSystemValueString('dbtableprefix', 'oc_'); + + $this->appManager = $this->createMock(IAppManager::class); + $this->appManager->method('cleanAppId')->willReturnCallback(fn (string $app) => preg_replace('/[^a-z0-9_-]+/', '', $app)); + $this->appManager->method('isShipped')->willReturn(false); + $this->appManager->method('isEnabledForAnyone')->willReturnCallback(fn (string $app) => !in_array($app, [self::APP, self::OTHER_APP], true)); + $this->appManager->method('getAppNamespace')->willReturnCallback(fn (string $app) => 'OCA\\' . ucfirst($app)); + + $this->schemaChecker = $this->createMock(SchemaChecker::class); + $this->schemaChecker->method('getTablesCreatedBy')->willReturnCallback(fn (string $app) => match ($app) { + self::APP => [$this->prefix . 'purgetest_items', $this->prefix . 'purgetest_never_created'], + default => [], + }); + + $this->purger = new AppDataPurger( + $this->appManager, + $this->appConfig, + $this->userConfig, + Server::get(IConfig::class), + $this->connection, + $this->schemaChecker, + Server::get(IRootFolder::class), + $this->createMock(LoggerInterface::class), + ); + + $this->leaveDataBehind(self::APP); + $this->leaveDataBehind(self::OTHER_APP); + $this->connection->executeStatement('CREATE TABLE *PREFIX*purgetest_items (id INTEGER NOT NULL)'); + $this->connection->executeStatement('INSERT INTO *PREFIX*purgetest_items (id) VALUES (1)'); + } + + #[\Override] + protected function tearDown(): void { + foreach ([self::APP, self::OTHER_APP] as $app) { + $this->appConfig->deleteApp($app); + $this->userConfig->deleteApp($app); + foreach (['migrations' => 'app'] as $table => $column) { + $qb = $this->connection->getQueryBuilder(); + $qb->delete($table)->where($qb->expr()->eq($column, $qb->createNamedParameter($app)))->executeStatement(); + } + $qb = $this->connection->getQueryBuilder(); + $qb->delete('jobs')->where($qb->expr()->like('class', $qb->createNamedParameter('OCA\\\\' . ucfirst($app) . '%')))->executeStatement(); + } + $this->connection->dropTable('purgetest_items'); + parent::tearDown(); + } + + private function leaveDataBehind(string $app): void { + $this->appConfig->setValueString($app, 'installed_version', '1.2.3'); + $this->appConfig->setValueString($app, 'some_setting', 'on'); + $this->userConfig->setValueString('purge-user', $app, 'theme', 'dark'); + + $qb = $this->connection->getQueryBuilder(); + $qb->insert('migrations')->values(['app' => $qb->createNamedParameter($app), 'version' => $qb->createNamedParameter('1000Date20260101000000')])->executeStatement(); + + Server::get(IJobList::class)->add('OCA\\' . ucfirst($app) . '\\BackgroundJob\\Cleanup'); + + Server::get(IAppDataFactory::class)->get($app)->newFolder('cache')->newFile('entry.json', '{"cached":true}'); + } + + private function countRows(string $table, string $column, string $value): int { + $qb = $this->connection->getQueryBuilder(); + $qb->select($qb->func()->count('*'))->from($table)->where($qb->expr()->like($column, $qb->createNamedParameter($value))); + return (int)$qb->executeQuery()->fetchOne(); + } + + public function testFindsWhatAnAppLeftBehind(): void { + $leftovers = $this->purger->getLeftovers(self::APP); + + $this->assertTrue($leftovers->codePresent); + // only the tables that exist + $this->assertSame([$this->prefix . 'purgetest_items'], $leftovers->tables); + $this->assertSame(2, $leftovers->configKeys); + $this->assertSame(1, $leftovers->userPreferences); + $this->assertSame(1, $leftovers->backgroundJobs); + $this->assertSame(1, $leftovers->migrations); + $this->assertSame(strlen('{"cached":true}'), $leftovers->appDataSize); + $this->assertFalse($leftovers->isEmpty()); + } + + public function testPurgeDeletesAllOfItAndNothingElse(): void { + $this->purger->purge($this->purger->getLeftovers(self::APP)); + + $this->assertFalse($this->connection->tableExists('purgetest_items')); + $this->assertSame([], $this->appConfig->getKeys(self::APP)); + $this->assertSame(0, $this->countRows('preferences', 'appid', self::APP)); + $this->assertSame(0, $this->countRows('migrations', 'app', self::APP)); + $this->assertSame(0, $this->countRows('jobs', 'class', 'OCA\\\\Purgetest\\\\%')); + $this->assertTrue($this->purger->getLeftovers(self::APP)->isEmpty()); + + // the other app, whose name starts the same way, keeps everything + $other = $this->purger->getLeftovers(self::OTHER_APP); + $this->assertSame(2, $other->configKeys); + $this->assertSame(1, $other->userPreferences); + $this->assertSame(1, $other->backgroundJobs); + $this->assertSame(1, $other->migrations); + $this->assertNotNull($other->appDataSize); + } + + public function testListsAppsWithLeftovers(): void { + $apps = array_map(fn ($l) => $l->appId, $this->purger->findAppsWithLeftovers()); + $this->assertSame([self::APP, self::OTHER_APP], $apps); + } + + public function testRemovedAppsHaveNoKnownTables(): void { + $schemaChecker = $this->createMock(SchemaChecker::class); + $schemaChecker->method('getTablesCreatedBy')->willReturn(null); + $purger = new AppDataPurger($this->appManager, $this->appConfig, $this->userConfig, Server::get(IConfig::class), + $this->connection, $schemaChecker, Server::get(IRootFolder::class), $this->createMock(LoggerInterface::class)); + + $leftovers = $purger->getLeftovers(self::APP); + $this->assertFalse($leftovers->codePresent); + $this->assertSame([], $leftovers->tables); + $this->assertSame(2, $leftovers->configKeys); + } + + public function testMigrationsThatCannotBeReplayedStopThePurge(): void { + $schemaChecker = $this->createMock(SchemaChecker::class); + $schemaChecker->method('getTablesCreatedBy')->willThrowException(new \Error('Class "OCA\\Purgetest\\Db\\TableManager" not found')); + $purger = new AppDataPurger($this->appManager, $this->appConfig, $this->userConfig, Server::get(IConfig::class), + $this->connection, $schemaChecker, Server::get(IRootFolder::class), $this->createMock(LoggerInterface::class)); + + $this->expectException(\RuntimeException::class); + $this->expectExceptionMessage("Could not find the tables of app 'purgetest'"); + $purger->getLeftovers(self::APP); + } + + public static function dataRefusals(): array { + return [ + 'core' => ['core', 'shipped with Nextcloud'], + 'enabled' => ['files_somewhere', 'is enabled'], + 'path traversal' => ['../purgetest', 'not a valid app id'], + 'empty' => ['', 'not a valid app id'], + ]; + } + + #[\PHPUnit\Framework\Attributes\DataProvider('dataRefusals')] + public function testRefusals(string $appId, string $reason): void { + $this->assertStringContainsString($reason, (string)$this->purger->getRefusal($appId)); + } + + public function testPurgeChecksTheRefusalsItself(): void { + $leftovers = $this->purger->getLeftovers(self::APP); + $appManager = $this->createMock(IAppManager::class); + $appManager->method('cleanAppId')->willReturnArgument(0); + $appManager->method('isEnabledForAnyone')->willReturn(true); + $purger = new AppDataPurger($appManager, $this->appConfig, $this->userConfig, Server::get(IConfig::class), + $this->connection, $this->schemaChecker, Server::get(IRootFolder::class), $this->createMock(LoggerInterface::class)); + + $this->expectException(\InvalidArgumentException::class); + $purger->purge($leftovers); + } +} diff --git a/tests/lib/DB/SchemaCheckerTest.php b/tests/lib/DB/SchemaCheckerTest.php index 463f2d078bd09..2ebc7712365c6 100644 --- a/tests/lib/DB/SchemaCheckerTest.php +++ b/tests/lib/DB/SchemaCheckerTest.php @@ -161,4 +161,9 @@ public function testIsOptionalIndexFinding(array $finding, array $optionalIndexN $this->assertSame($expected, $result); } + + public function testGetTablesCreatedByIsNullWithoutTheAppsCode(): void { + $this->appManager->method('getAppPath')->with('removed_app')->willThrowException(new \OCP\App\AppPathNotFoundException()); + $this->assertNull($this->schemaChecker->getTablesCreatedBy('removed_app')); + } }