0]); Storage::fake('images'); Storage::fake('ssh-keys'); Process::fake(); }); /** * Creates a team with at least one row in every table that holds team data. * * @return array{team: Team, project: Project, server: Server} */ function populatedTeam(User $member): array { $team = Team::factory()->create(); $team->members()->attach($member, ['role' => 'owner']); $member->update(['current_team_id' => $team->id]); Subscription::factory()->create(['team_id' => $team->id]); TeamInvitation::create(['team_id' => $team->id, 'uuid' => (string) Str::uuid(), 'email' => fake()->email(), 'role' => 'member', 'link' => 'https://example.com/invite']); $member->tokens()->create(['name' => 'cli', 'token' => Str::random(64), 'abilities' => ['read'], 'team_id' => $team->id]); $privateKey = PrivateKey::factory()->create(['team_id' => $team->id]); $s3 = S3Storage::forceCreate(['uuid' => (string) Str::uuid(), 'name' => 'usable', 'region' => 'us', 'key' => 'k', 'secret' => 's', 'bucket' => 'b', 'endpoint' => 'https://s3.example.com', 'team_id' => $team->id, 'is_usable' => true]); S3Storage::forceCreate(['uuid' => (string) Str::uuid(), 'name' => 'unusable', 'region' => 'us', 'key' => 'k', 'secret' => 's', 'bucket' => 'b', 'endpoint' => 'https://s3.example.com', 'team_id' => $team->id, 'is_usable' => false]); GithubApp::forceCreate(['uuid' => (string) Str::uuid(), 'name' => 'gh', 'api_url' => 'https://api.github.com', 'html_url' => 'https://github.com', 'is_system_wide' => false, 'is_public' => false, 'team_id' => $team->id, 'private_key_id' => $privateKey->id]); GitlabApp::forceCreate(['uuid' => (string) Str::uuid(), 'name' => 'gl', 'api_url' => 'https://gitlab.com/api/v4', 'html_url' => 'https://gitlab.com', 'is_system_wide' => false, 'is_public' => false, 'team_id' => $team->id]); CloudProviderToken::factory()->create(['team_id' => $team->id]); $integrationToken = IntegrationToken::factory()->create(['team_id' => $team->id]); $dnsRecord = ManagedDnsRecord::factory()->create(['dns_provider_zone_id' => DnsProviderZone::factory()->create(['integration_token_id' => $integrationToken->id])->id]); $tag = Tag::create(['name' => 'tag-'.Str::random(6), 'team_id' => $team->id]); $server = Server::factory()->create(['team_id' => $team->id, 'private_key_id' => $privateKey->id]); $trashedServer = Server::factory()->create(['team_id' => $team->id, 'private_key_id' => $privateKey->id]); $trashedServer->delete(); $destination = StandaloneDocker::query()->where('server_id', $server->id)->firstOrFail(); DockerCleanupExecution::create(['server_id' => $server->id]); SslCertificate::forceCreate(['server_id' => $server->id, 'ssl_certificate' => 'cert', 'ssl_private_key' => 'key', 'common_name' => 'ca', 'valid_until' => now()->addYear(), 'is_ca_certificate' => true]); $project = Project::factory()->create(['team_id' => $team->id]); $environment = Environment::factory()->create(['project_id' => $project->id]); SharedEnvironmentVariable::forceCreate(['key' => 'TEAM', 'value' => 'x', 'type' => 'team', 'team_id' => $team->id]); SharedEnvironmentVariable::forceCreate(['key' => 'PROJECT', 'value' => 'x', 'type' => 'project', 'team_id' => $team->id, 'project_id' => $project->id]); SharedEnvironmentVariable::forceCreate(['key' => 'ENV', 'value' => 'x', 'type' => 'environment', 'team_id' => $team->id, 'project_id' => $project->id, 'environment_id' => $environment->id]); $onDestination = ['environment_id' => $environment->id, 'destination_id' => $destination->id, 'destination_type' => StandaloneDocker::class]; $application = Application::factory()->create($onDestination); $application->tags()->attach($tag); ManagedDnsRecordReference::create(['managed_dns_record_id' => $dnsRecord->id, 'resource_type' => Application::class, 'resource_id' => $application->id]); EnvironmentVariable::create(['key' => 'APP_SECRET', 'value' => 'x', 'resourceable_type' => Application::class, 'resourceable_id' => $application->id]); $appVolume = LocalPersistentVolume::create(['name' => 'app-data-'.Str::random(6), 'mount_path' => '/data', 'resource_type' => Application::class, 'resource_id' => $application->id]); LocalFileVolume::create(['fs_path' => '/tmp/app.env', 'mount_path' => '/app/.env', 'content' => 'SECRET=1', 'resource_type' => Application::class, 'resource_id' => $application->id]); $volumeBackup = ScheduledVolumeBackup::forceCreate(['uuid' => (string) Str::uuid(), 'backupable_type' => LocalPersistentVolume::class, 'backupable_id' => $appVolume->id, 'team_id' => $team->id, 'frequency' => '0 0 * * *']); ScheduledVolumeBackupExecution::forceCreate(['uuid' => (string) Str::uuid(), 'scheduled_volume_backup_id' => $volumeBackup->id, 'status' => 'success']); $preview = ApplicationPreview::forceCreate(['uuid' => (string) Str::uuid(), 'application_id' => $application->id, 'pull_request_id' => 1, 'pull_request_html_url' => 'https://github.com/a/b/pull/1']); $preview->delete(); ApplicationDeploymentQueue::forceCreate(['application_id' => (string) $application->id, 'deployment_uuid' => (string) Str::uuid(), 'server_id' => $server->id, 'status' => 'finished']); $appTask = ScheduledTask::factory()->create(['team_id' => $team->id, 'application_id' => $application->id]); ScheduledTaskExecution::create(['scheduled_task_id' => $appTask->id, 'status' => 'success']); Application::factory()->create($onDestination)->delete(); Application::factory()->create(['environment_id' => $environment->id, 'destination_id' => 999999, 'destination_type' => StandaloneDocker::class]); $database = StandalonePostgresql::create([...$onDestination, 'name' => 'db', 'postgres_user' => 'postgres', 'postgres_password' => 'secret', 'postgres_db' => 'db', 'image' => 'postgres:17']); LocalFileVolume::create(['fs_path' => '/tmp/init.sql', 'mount_path' => '/docker-entrypoint-initdb.d/init.sql', 'content' => 'select 1', 'resource_type' => StandalonePostgresql::class, 'resource_id' => $database->id]); $databaseBackup = ScheduledDatabaseBackup::create(['team_id' => $team->id, 'frequency' => '0 0 * * *', 'database_type' => StandalonePostgresql::class, 'database_id' => $database->id, 's3_storage_id' => $s3->id]); ScheduledDatabaseBackupExecution::create(['scheduled_database_backup_id' => $databaseBackup->id, 'status' => 'success']); $service = Service::factory()->create($onDestination); $service->tags()->attach($tag); EnvironmentVariable::create(['key' => 'SERVICE_PASSWORD', 'value' => 'x', 'resourceable_type' => Service::class, 'resourceable_id' => $service->id]); $serviceApplication = ServiceApplication::create(['name' => 'web', 'service_id' => $service->id, 'image' => 'nginx']); LocalPersistentVolume::create(['name' => 'svc-data-'.Str::random(6), 'mount_path' => '/data', 'resource_type' => ServiceApplication::class, 'resource_id' => $serviceApplication->id]); LocalFileVolume::create(['fs_path' => '/tmp/nginx.conf', 'mount_path' => '/etc/nginx.conf', 'content' => 'server {}', 'resource_type' => ServiceApplication::class, 'resource_id' => $serviceApplication->id]); ServiceApplication::create(['name' => 'old', 'service_id' => $service->id, 'image' => 'nginx'])->delete(); $serviceDatabase = ServiceDatabase::create(['name' => 'db', 'service_id' => $service->id, 'image' => 'postgres:17']); $serviceBackup = ScheduledDatabaseBackup::create(['team_id' => $team->id, 'frequency' => '0 0 * * *', 'database_type' => ServiceDatabase::class, 'database_id' => $serviceDatabase->id]); ScheduledDatabaseBackupExecution::create(['scheduled_database_backup_id' => $serviceBackup->id, 'status' => 'success']); $serviceTask = ScheduledTask::factory()->create(['team_id' => $team->id, 'service_id' => $service->id]); ScheduledTaskExecution::create(['scheduled_task_id' => $serviceTask->id, 'status' => 'success']); return ['team' => $team, 'project' => $project, 'server' => $server]; } /** * @return array */ function tableRowCounts(): array { return collect(Schema::getTableListing(schemaQualified: false)) ->reject(fn (string $table) => in_array($table, ['migrations', 'audit_events', 'activity_log'], true)) ->mapWithKeys(fn (string $table) => [$table => DB::table($table)->count()]) ->sortKeys() ->all(); } test('it deletes every record of the team and leaves other teams untouched', function () { $member = User::factory()->create(); $keptTeam = populatedTeam(User::factory()->create())['team']; $baseline = tableRowCounts(); $doomed = populatedTeam($member); $doomed['project']->forceFill(['icon_path' => 'project-icons/icon.png', 'icon_storage_type' => 'local'])->save(); Storage::disk('images')->put('project-icons/icon.png', 'png'); app(DeleteTeamData::class)->handle($doomed['team']); expect(tableRowCounts())->toBe($baseline) ->and(Storage::disk('images')->exists('project-icons/icon.png'))->toBeFalse() ->and($member->fresh()->current_team_id)->toBeNull(); $this->assertModelExists($keptTeam); $this->assertModelExists($member); }); /** * Stores personal data in every notification settings row of the team. */ function customizeNotificationSettings(Team $team): void { $team->emailNotificationSettings()->update(['smtp_enabled' => true, 'smtp_from_address' => 'owner@example.com', 'smtp_recipients' => 'owner@example.com']); $team->discordNotificationSettings()->update(['discord_enabled' => true, 'discord_webhook_url' => encrypt('https://discord.com/api/webhooks/1/secret')]); $team->slackNotificationSettings()->update(['slack_enabled' => true, 'slack_webhook_url' => encrypt('https://hooks.slack.com/services/secret')]); $team->telegramNotificationSettings()->update(['telegram_enabled' => true, 'telegram_token' => encrypt('123:secret'), 'telegram_chat_id' => encrypt('42')]); $team->pushoverNotificationSettings()->update(['pushover_enabled' => true, 'pushover_user_key' => encrypt('user-key'), 'pushover_api_token' => encrypt('api-token')]); $team->webhookNotificationSettings()->update(['webhook_enabled' => true, 'webhook_url' => encrypt('https://example.com/webhook')]); } /** * The decrypted notification settings of a team, without keys and timestamps. * * @return array> */ function notificationSettingsValues(Team $team): array { $team = $team->fresh(); return collect(['emailNotificationSettings', 'discordNotificationSettings', 'slackNotificationSettings', 'telegramNotificationSettings', 'pushoverNotificationSettings', 'webhookNotificationSettings']) ->mapWithKeys(fn (string $relation) => [$relation => collect($team->{$relation}->getAttributes()) ->except(['id', 'team_id', 'created_at', 'updated_at']) ->map(fn ($value, string $column) => $team->{$relation}->{$column}) ->all()]) ->all(); } test('purging keeps only the team, its memberships, and default notification settings', function () { $member = User::factory()->create(); $otherTeam = populatedTeam(User::factory()->create())['team']; $baseline = tableRowCounts(); $purged = populatedTeam($member); $team = $purged['team']; $team->forceFill(['personal_team' => true])->save(); customizeNotificationSettings($team); DB::table('sessions')->insert(['id' => Str::random(40), 'user_id' => $member->id, 'payload' => '', 'last_activity' => now()->timestamp]); app(DeleteTeamData::class)->purge($team); $expected = $baseline; foreach (['teams', 'team_user', 'sessions', 'email_notification_settings', 'discord_notification_settings', 'slack_notification_settings', 'telegram_notification_settings', 'pushover_notification_settings', 'webhook_notification_settings'] as $table) { $expected[$table]++; } expect(tableRowCounts())->toBe($expected) ->and(notificationSettingsValues($team))->toBe(notificationSettingsValues($otherTeam)) ->and($team->fresh()->personal_team)->toBeTrue() ->and($member->fresh()->current_team_id)->toBe($team->id) ->and($member->teams()->whereKey($team->id)->exists())->toBeTrue(); }); test('it runs nothing on the team servers and leaves the DNS records at the provider', function () { Bus::fake([ReleaseManagedDnsRecordsJob::class]); $doomed = populatedTeam(User::factory()->create()); app(DeleteTeamData::class)->handle($doomed['team']); Process::assertNothingRan(); Bus::assertNotDispatched(ReleaseManagedDnsRecordsJob::class); }); test('it never purges or deletes the root team', function (string $method) { $rootTeam = Team::find(0) ?? Team::forceCreate(['id' => 0, 'name' => 'Root Team', 'personal_team' => false]); $server = Server::factory()->create(['team_id' => 0]); expect(fn () => app(DeleteTeamData::class)->{$method}($rootTeam)) ->toThrow(LogicException::class, 'The root team (id 0) is never deleted or purged.'); expect(Team::find(0))->not->toBeNull() ->and(Server::find($server->id))->not->toBeNull(); })->with(['purge', 'handle']);