Files
coolify/tests/Feature/AuditEventsTest.php
T
Andras Bacsai d74352f423 fix(audit): harden audit logging and admin controls
Log audit failures, redact invitation emails, exclude noisy model fields, and gate audit-log filters and pagination by admin access.
2026-08-24 09:13:20 +02:00

808 lines
27 KiB
PHP

<?php
use App\Http\Kernel;
use App\Livewire\Project\Service\Heading;
use App\Livewire\Project\Shared\EnvironmentVariable\Show;
use App\Livewire\Team\AuditLog;
use App\Livewire\Team\Index as TeamIndex;
use App\Models\Application;
use App\Models\ApplicationDeploymentQueue;
use App\Models\AuditEvent;
use App\Models\Environment;
use App\Models\EnvironmentVariable;
use App\Models\GithubApp;
use App\Models\GitlabApp;
use App\Models\InstanceSettings;
use App\Models\PrivateKey;
use App\Models\Project;
use App\Models\Server;
use App\Models\Service;
use App\Models\SharedEnvironmentVariable;
use App\Models\StandaloneClickhouse;
use App\Models\StandaloneDragonfly;
use App\Models\StandaloneKeydb;
use App\Models\StandaloneMariadb;
use App\Models\StandaloneMongodb;
use App\Models\StandaloneMysql;
use App\Models\StandalonePostgresql;
use App\Models\StandaloneRedis;
use App\Models\Team;
use App\Models\User;
use App\Traits\Auditable;
use Illuminate\Database\QueryException;
use Illuminate\Foundation\Http\Middleware\InvokeDeferredCallbacks;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Facades\Schema;
use Illuminate\Support\Once;
use Livewire\Livewire;
uses(RefreshDatabase::class);
beforeEach(function () {
$this->withoutDefer();
InstanceSettings::forceCreate(['id' => 0]);
Once::flush();
$this->team = Team::factory()->create();
$this->user = User::factory()->create();
$this->team->members()->attach($this->user->id, ['role' => 'owner']);
$this->actingAs($this->user);
session(['currentTeam' => $this->team]);
Log::spy();
});
test('audit inserts are deferred until after the response', function () {
$this->withDefer();
auditLog('ui.project.updated', [
'team_id' => $this->team->id,
'project_uuid' => 'project-123',
'project_name' => 'Website',
]);
expect(AuditEvent::query()->count())->toBe(0);
defer()->invoke();
expect(AuditEvent::query()->count())->toBe(1);
});
test('multiple audit inserts in one request are all deferred', function () {
$this->withDefer();
auditLog('ui.application.deployed', [
'team_id' => $this->team->id,
'application_uuid' => 'app-123',
]);
auditLog('ui.application.updated', [
'team_id' => $this->team->id,
'application_uuid' => 'app-123',
]);
defer()->invoke();
expect(AuditEvent::query()->pluck('event')->all())->toBe([
'ui.application.deployed',
'ui.application.updated',
]);
});
test('http kernel invokes deferred callbacks', function () {
$kernel = app(Kernel::class);
$middleware = (new ReflectionClass($kernel))->getProperty('middleware')->getValue($kernel);
expect($middleware)->toContain(InvokeDeferredCallbacks::class);
});
test('audit persistence failures do not fail the action', function () {
Schema::rename('audit_events', 'unavailable_audit_events');
try {
expect(fn () => auditLog('ui.project.updated', ['team_id' => $this->team->id]))
->not->toThrow(Throwable::class);
Log::shouldHaveReceived('warning')->once()->with(
'Audit event persistence failed',
Mockery::on(fn (array $context): bool => $context === [
'event' => 'ui.project.updated',
'exception' => QueryException::class,
]),
);
} finally {
Schema::rename('unavailable_audit_events', 'audit_events');
}
});
test('audit preparation failures log sanitized diagnostics without failing the action', function () {
$resourceName = new class
{
public function __toString(): string
{
throw new RuntimeException('sensitive audit metadata');
}
};
expect(fn () => auditLog('ui.project.updated', [
'team_id' => $this->team->id,
'project_name' => $resourceName,
'secret' => 'must not be logged',
]))->not->toThrow(Throwable::class);
Log::shouldHaveReceived('warning')->once()->with(
'Audit event preparation failed',
Mockery::on(fn (array $context): bool => $context === [
'event' => 'ui.project.updated',
'exception' => RuntimeException::class,
]),
);
});
test('audit log persists a structured event for the current team', function () {
auditLog('ui.application.updated', [
'application_uuid' => 'app-123',
'application_name' => 'Website',
'changed' => ['name'],
]);
$event = AuditEvent::query()->sole();
expect($event->team_id)->toBe($this->team->id)
->and($event->actor_id)->toBe($this->user->id)
->and($event->actor_email)->toBe($this->user->email)
->and($event->source)->toBe('ui')
->and($event->action)->toBe('updated')
->and($event->resource_type)->toBe('application')
->and($event->resource_uuid)->toBe('app-123')
->and($event->resource_name)->toBe('Website')
->and($event->metadata['changed'])->toBe(['name']);
});
test('auditable models record authenticated create update and delete actions', function () {
$project = Project::factory()->create([
'team_id' => $this->team->id,
'name' => 'Website project',
]);
$project->update(['name' => 'Renamed project']);
$project->delete();
$events = AuditEvent::query()->where('resource_type', 'project')->orderBy('id')->get();
expect($events->pluck('event')->all())->toBe([
'ui.project.created',
'ui.project.updated',
'ui.project.deleted',
])->and($events[1]->metadata['changed_fields'])->toBe(['name']);
});
test('deleting a project dispatches deleted events for its environments', function () {
$project = Project::factory()->create(['team_id' => $this->team->id]);
$environment = $project->environments()->sole();
AuditEvent::query()->delete();
$project->delete();
expect(AuditEvent::query()
->where('event', 'ui.environment.deleted')
->where('resource_uuid', $environment->uuid)
->exists())->toBeTrue();
});
test('deleting a team dispatches updated events for transferred system-wide sources', function () {
Team::factory()->create(['id' => 0]);
$githubApp = GithubApp::query()->create([
'name' => 'System GitHub source',
'team_id' => $this->team->id,
'is_system_wide' => true,
'api_url' => 'https://api.github.com',
'html_url' => 'https://github.com',
]);
$gitlabApp = GitlabApp::query()->create([
'name' => 'System GitLab source',
'team_id' => $this->team->id,
'is_system_wide' => true,
'api_url' => 'https://gitlab.com/api/v4',
'html_url' => 'https://gitlab.com',
]);
AuditEvent::query()->delete();
$this->team->delete();
expect(AuditEvent::query()
->where('event', 'ui.github_app.updated')
->where('resource_uuid', $githubApp->uuid)
->exists())->toBeTrue()
->and(AuditEvent::query()
->where('event', 'ui.gitlab_app.updated')
->where('resource_uuid', $gitlabApp->uuid)
->exists())->toBeTrue();
});
test('auditable model mutations succeed when audit persistence fails', function () {
Schema::rename('audit_events', 'unavailable_audit_events');
try {
$project = Project::factory()->create([
'team_id' => $this->team->id,
'name' => 'Persisted project',
]);
} finally {
Schema::rename('unavailable_audit_events', 'audit_events');
}
expect($project->exists)->toBeTrue()
->and(Project::query()->whereKey($project->id)->exists())->toBeTrue();
});
test('repeated events for the same resource are each persisted', function () {
auditLog('api.project.updated', [
'team_id' => $this->team->id,
'project_uuid' => 'project-123',
'changed_fields' => ['name'],
]);
auditLog('api.project.updated', [
'team_id' => $this->team->id,
'project_uuid' => 'project-123',
'changed_fields' => ['description'],
]);
$events = AuditEvent::query()->orderBy('id')->get();
expect($events)->toHaveCount(2)
->and($events[0]->metadata['changed_fields'])->toBe(['name'])
->and($events[1]->metadata['changed_fields'])->toBe(['description']);
});
test('automatic and explicit auditing both preserve their events', function () {
$project = Project::factory()->create([
'team_id' => $this->team->id,
'name' => 'Website project',
]);
auditLog('ui.project.created', [
'team_id' => $this->team->id,
'project_uuid' => $project->uuid,
'project_name' => $project->name,
'audit_description' => 'Project created through the API',
'request_field' => 'preserved',
]);
$events = AuditEvent::query()->where('event', 'ui.project.created')->orderBy('id')->get();
expect($events)->toHaveCount(2)
->and($events[1]->description)->toBe('Project created through the API')
->and($events[1]->metadata['request_field'])->toBe('preserved');
});
test('auditable models ignore unauthenticated mutations', function () {
auth()->logout();
Project::factory()->create(['team_id' => $this->team->id]);
expect(AuditEvent::query()->count())->toBe(0);
});
test('webhook audits resolve the team from the application', function () {
$project = Project::factory()->create(['team_id' => $this->team->id]);
$environment = Environment::factory()->create(['project_id' => $project->id]);
$application = Application::factory()->create(['environment_id' => $environment->id]);
AuditEvent::query()->delete();
auth()->logout();
session()->forget('currentTeam');
auditLog('webhook.deployment.queued', [
'application_uuid' => $application->uuid,
'application_name' => $application->name,
]);
$this->assertDatabaseHas('audit_events', [
'team_id' => $this->team->id,
'event' => 'webhook.deployment.queued',
'resource_uuid' => $application->uuid,
]);
});
test('unauthenticated webhook failures without a team are preserved', function () {
auth()->logout();
session()->forget('currentTeam');
auditLogWebhookFailure('sentinel', 'token_missing');
auditLogWebhookFailure('stripe', 'invalid_signature');
$events = AuditEvent::query()->orderBy('id')->get();
expect($events)->toHaveCount(2)
->and($events->pluck('event')->all())->toBe([
'webhook.sentinel.signature_failed',
'webhook.stripe.signature_failed',
])
->and($events->pluck('team_id')->all())->toBe([null, null]);
});
test('early Sentinel and Stripe rejections persist unscoped audit events', function () {
auth()->logout();
session()->forget('currentTeam');
$this->postJson('/api/v1/sentinel/push', [])->assertUnauthorized();
config(['subscription.stripe_webhook_secret' => 'whsec_test']);
$this->withHeader('Stripe-Signature', 'invalid')
->call('POST', '/webhooks/payments/stripe/events', [], [], [], [], '{}')
->assertBadRequest();
expect(AuditEvent::query()->orderBy('id')->pluck('event')->all())->toBe([
'webhook.sentinel.signature_failed',
'webhook.stripe.signature_failed',
])->and(AuditEvent::query()->whereNotNull('team_id')->doesntExist())->toBeTrue();
});
test('unscoped audit events are only visible to the instance team', function () {
AuditEvent::factory()->create([
'team_id' => null,
'description' => 'Unscoped security failure',
]);
Livewire::test(AuditLog::class)
->assertDontSee('Unscoped security failure');
$instanceTeam = Team::factory()->create(['id' => 0]);
$instanceTeam->members()->attach($this->user->id, ['role' => 'owner']);
$this->user->unsetRelation('teams');
session(['currentTeam' => $instanceTeam]);
Livewire::test(AuditLog::class)
->assertSee('Unscoped security failure');
});
test('auditable models identify personal access token mutations as api events', function () {
$newToken = $this->user->createToken('audit-api');
$newToken->accessToken->forceFill(['team_id' => $this->team->id])->save();
$this->actingAs($this->user->withAccessToken($newToken->accessToken->fresh()));
Project::factory()->create(['team_id' => $this->team->id]);
expect(AuditEvent::query()->where('resource_type', 'project')->firstOrFail()->event)
->toBe('api.project.created');
});
test('API audit events identify the responsible access token', function () {
$firstToken = $this->user->createToken('first-token');
$firstToken->accessToken->forceFill(['team_id' => $this->team->id])->save();
$secondToken = $this->user->createToken('second-token');
$secondToken->accessToken->forceFill(['team_id' => $this->team->id])->save();
foreach ([$firstToken->accessToken->fresh(), $secondToken->accessToken->fresh()] as $token) {
$this->actingAs($this->user->withAccessToken($token));
auditLog('api.project.updated', ['team_id' => $this->team->id]);
}
$events = AuditEvent::query()->orderBy('id')->get();
expect($events->pluck('actor_token_id')->all())->toBe([
$firstToken->accessToken->id,
$secondToken->accessToken->id,
])->and($events->pluck('actor_token_name')->all())->toBe([
'first-token',
'second-token',
]);
Livewire::test(AuditLog::class)
->assertSee('Token: first-token')
->assertSee('Token: second-token');
});
test('API model mutations produce one audit event', function () {
$this->withoutExceptionHandling();
$token = $this->user->createToken('audit-api', ['root']);
$token->accessToken->forceFill(['team_id' => $this->team->id])->save();
auth()->logout();
auth()->forgetGuards();
$response = $this->withToken($token->plainTextToken)->postJson('/api/v1/projects', [
'name' => 'Single API audit event',
]);
$response->assertCreated();
expect(AuditEvent::query()
->where('event', 'api.project.created')
->where('resource_uuid', $response->json('uuid'))
->count())->toBe(1);
});
test('deployment queue records rollback and cancellation operations', function () {
$project = Project::factory()->create(['team_id' => $this->team->id]);
$environment = Environment::factory()->create(['project_id' => $project->id]);
$application = Application::factory()->create(['environment_id' => $environment->id]);
AuditEvent::query()->delete();
$deployment = ApplicationDeploymentQueue::query()->create([
'application_id' => $application->id,
'deployment_uuid' => 'rollback-deployment',
'commit' => 'abc123',
'rollback' => true,
'status' => 'queued',
]);
$deployment->update(['status' => 'cancelled-by-user']);
expect(AuditEvent::query()->orderBy('id')->pluck('event')->all())->toBe([
'ui.application.rollback',
'ui.deployment.cancelled',
]);
});
test('team resource models opt in to automatic auditing', function (string $model) {
expect(class_uses_recursive($model))->toContain(Auditable::class);
})->with([
Application::class,
Service::class,
Server::class,
Project::class,
Environment::class,
EnvironmentVariable::class,
SharedEnvironmentVariable::class,
PrivateKey::class,
StandalonePostgresql::class,
StandaloneMysql::class,
StandaloneMariadb::class,
StandaloneMongodb::class,
StandaloneRedis::class,
StandaloneKeydb::class,
StandaloneDragonfly::class,
StandaloneClickhouse::class,
]);
test('status-only database updates do not record last online audit changes', function () {
$project = Project::factory()->create(['team_id' => $this->team->id]);
$environment = Environment::factory()->create(['project_id' => $project->id]);
foreach ([StandaloneClickhouse::class, StandaloneRedis::class] as $model) {
$attributes = [
'uuid' => fake()->uuid(),
'name' => 'Status test database',
'status' => 'exited',
'environment_id' => $environment->id,
'destination_type' => Server::class,
'destination_id' => 0,
];
if ($model === StandaloneClickhouse::class) {
$attributes['clickhouse_admin_password'] = 'password';
}
$database = $model::create($attributes);
AuditEvent::query()->delete();
$database->update(['status' => 'running']);
expect(AuditEvent::query()->count())->toBe(0);
$database->update(['name' => 'Renamed status test database']);
$event = AuditEvent::query()->sole();
expect($event->metadata['changed_fields'])->toBe(['name']);
AuditEvent::query()->delete();
}
});
test('audit log redacts sensitive metadata', function () {
auditLog('api.application.updated', [
'team_id' => $this->team->id,
'application_uuid' => 'app-123',
'token' => 'secret-token',
'nested' => ['password' => 'secret-password', 'safe' => 'visible'],
]);
$metadata = AuditEvent::query()->sole()->metadata;
expect($metadata['token'])->toBe('[REDACTED]')
->and($metadata['nested']['password'])->toBe('[REDACTED]')
->and($metadata['nested']['safe'])->toBe('visible');
});
test('team invitation audit logs redact the invitation email', function () {
auditLog('ui.team_invitation.created', [
'team_id' => $this->team->id,
'invitation_uuid' => 'invitation-123',
'invitation_email' => 'invitee@example.com',
'role' => 'member',
'via' => 'email',
]);
$metadata = AuditEvent::query()->sole()->metadata;
expect($metadata['invitation_email'])->toBe('[REDACTED]')
->and($metadata['invitation_uuid'])->toBe('invitation-123')
->and($metadata['role'])->toBe('member')
->and($metadata['via'])->toBe('email');
});
test('audit log page only shows events for the current team', function () {
AuditEvent::factory()->create([
'team_id' => $this->team->id,
'description' => 'Website created',
]);
AuditEvent::factory()->create([
'team_id' => Team::factory()->create()->id,
'description' => 'Private app deleted',
]);
Livewire::test(AuditLog::class)
->assertSee('Website created')
->assertDontSee('Private app deleted');
});
test('audit log is available under team settings', function () {
$this->get('/team/audit-log')
->assertSuccessful()
->assertSeeLivewire(AuditLog::class);
});
test('team members cannot view the audit log page', function () {
$member = User::factory()->create();
$this->team->members()->attach($member->id, ['role' => 'member']);
$this->actingAs($member);
session(['currentTeam' => $this->team]);
$this->get('/team/audit-log')->assertForbidden();
});
test('demoted team admins cannot make subsequent audit log requests', function () {
$component = Livewire::test(AuditLog::class);
$this->team->members()->updateExistingPivot($this->user->id, ['role' => 'member']);
auth()->setUser($this->user->fresh());
$component->set('search', 'deployment')->assertStatus(403);
});
test('team admins can query only their team audit events through the api', function () {
AuditEvent::factory()->create([
'team_id' => $this->team->id,
'event' => 'api.project.updated',
'source' => 'api',
'action' => 'updated',
'description' => 'Visible event',
]);
AuditEvent::factory()->create([
'team_id' => Team::factory()->create()->id,
'event' => 'api.project.updated',
'source' => 'api',
'action' => 'updated',
'description' => 'Other team event',
]);
$token = $this->user->createToken('audit-read', ['read']);
$token->accessToken->forceFill(['team_id' => $this->team->id])->save();
auth()->logout();
auth()->forgetGuards();
$this->withToken($token->plainTextToken)
->getJson('/api/v1/audit-events?source=api&action=updated')
->assertOk()
->assertJsonCount(1, 'data')
->assertJsonPath('data.0.description', 'Visible event');
});
test('team members cannot query audit events through the api', function () {
$member = User::factory()->create();
$this->team->members()->attach($member->id, ['role' => 'member']);
$this->actingAs($member);
session(['currentTeam' => $this->team]);
$token = $member->createToken('audit-read', ['read']);
$token->accessToken->forceFill(['team_id' => $this->team->id])->save();
auth()->logout();
auth()->forgetGuards();
$this->withToken($token->plainTextToken)
->getJson('/api/v1/audit-events')
->assertForbidden();
});
test('audit source filter omits the unused system source', function () {
Livewire::test(AuditLog::class)
->assertSee('All sources')
->assertSee('Web UI')
->assertSee('API')
->assertSee('MCP')
->assertSee('Webhook')
->assertDontSee('System');
});
test('resource clone audit starts only after the destination server capability check', function () {
$source = file_get_contents(app_path('Livewire/Project/Shared/ResourceOperations.php'));
expect(strpos($source, "auditLog('ui.resource.clone_started'"))
->toBeGreaterThan(strpos($source, 'if (! $server->canHostResources())'));
});
test('pull and restart records the service restart audit event after starting the service', function () {
$method = new ReflectionMethod(Heading::class, 'pullAndRestartEvent');
$source = file($method->getFileName());
$methodSource = implode('', array_slice($source, $method->getStartLine() - 1, $method->getEndLine() - $method->getStartLine() + 1));
expect($methodSource)
->toContain("auditServiceAction('ui.service.restarted')")
->and(strpos($methodSource, 'StartService::run'))->toBeLessThan(strpos($methodSource, 'auditServiceAction'));
});
test('critical operational events persist with their source action and actor', function (string $event) {
auditLog($event, [
'team_id' => $this->team->id,
'resource_uuid' => 'resource-123',
'resource_name' => 'Test resource',
]);
$auditEvent = AuditEvent::query()->sole();
expect($auditEvent->event)->toBe($event)
->and($auditEvent->source)->toBe(str($event)->before('.')->value())
->and($auditEvent->action)->toBe(str($event)->afterLast('.')->value())
->and($auditEvent->actor_email)->toBe($this->user->email);
})->with([
'ui.application.stopped',
'ui.application.preview_stopped',
'ui.application.destination_stopped',
'ui.application.rollback',
'ui.deployment.cancelled',
'ui.service.started',
'ui.service.stopped',
'ui.service.restarted',
'ui.database.started',
'ui.database.stopped',
'ui.database.restarted',
'ui.proxy.stopped',
'ui.proxy.restarted',
'ui.database.backup_started',
'ui.database.backup_schedule_deleted',
'ui.database.import_started',
'ui.database.restore_started',
'ui.scheduled_task.executed',
'ui.api_token.created',
'ui.api_token.revoked',
'ui.team_member.role_updated',
'ui.team_member.removed',
'ui.team_invitation.created',
'ui.team_invitation.revoked',
'ui.server.docker_cleanup_started',
'ui.server.imported',
'ui.project.clone_started',
'ui.resource.clone_started',
'api.database.started',
'api.database.stopped',
'api.database.restarted',
]);
test('audit log table keeps actor details visible in a mobile scroll area', function () {
AuditEvent::factory()->create([
'team_id' => $this->team->id,
'actor_name' => 'Visible Actor',
]);
Livewire::test(AuditLog::class)
->assertSeeHtml('class="overflow-x-auto"')
->assertSeeHtml('min-w-[760px]')
->assertSee('Actor')
->assertSee('Visible Actor');
});
test('audit log displays source abbreviations in uppercase', function () {
AuditEvent::factory()->create([
'team_id' => $this->team->id,
'source' => 'cli',
]);
Livewire::test(AuditLog::class)
->assertSee('CLI');
});
test('audit log page filters events by search and action', function () {
AuditEvent::factory()->create([
'team_id' => $this->team->id,
'action' => 'created',
'description' => 'Website created',
'resource_name' => 'Website',
]);
AuditEvent::factory()->create([
'team_id' => $this->team->id,
'event' => 'api.server.deleted',
'action' => 'deleted',
'description' => 'Build server deleted',
'resource_name' => 'Build server',
]);
Livewire::test(AuditLog::class)
->set('search', 'Website')
->assertSee('Website created')
->assertDontSee('Build server deleted')
->set('search', '')
->set('action', 'deleted')
->assertDontSee('Website created')
->assertSee('Build server deleted');
});
test('updating team settings records an audit event', function () {
Livewire::test(TeamIndex::class)
->set('name', 'Renamed team')
->call('submit')
->assertHasNoErrors();
$event = AuditEvent::query()->where('action', 'updated')->sole();
expect($event->event)->toBe('ui.team.updated')
->and($event->team_id)->toBe($this->team->id)
->and($event->resource_name)->toBe('Renamed team');
});
test('updating an environment variable records an event without its value', function () {
$variable = SharedEnvironmentVariable::create([
'team_id' => $this->team->id,
'type' => 'team',
'key' => 'API_SECRET',
'value' => 'old-secret',
]);
Livewire::test(Show::class, [
'env' => $variable,
'type' => 'team',
])
->call('loadValues')
->set('value', 'new-secret')
->call('submit')
->assertHasNoErrors();
$event = AuditEvent::query()
->where('resource_type', 'shared_environment_variable')
->where('action', 'updated')
->sole();
expect($event->event)->toBe('ui.shared_environment_variable.updated')
->and($event->resource_name)->toBe('API_SECRET')
->and(json_encode($event->metadata))->not->toContain('new-secret');
});
test('creating an application environment variable records an audit event', function () {
$this->withDefer();
$project = Project::factory()->create(['team_id' => $this->team->id]);
$environment = Environment::factory()->create(['project_id' => $project->id]);
$application = Application::factory()->create(['environment_id' => $environment->id]);
$application->environment_variables()->create([
'key' => 'API_SECRET',
'value' => 'secret-value',
]);
defer()->invoke();
$event = AuditEvent::query()
->where('resource_type', 'environment_variable')
->where('action', 'created')
->where('resource_name', 'API_SECRET')
->firstOrFail();
expect($event->team_id)->toBe($this->team->id)
->and($event->resource_name)->toBe('API_SECRET')
->and(json_encode($event->metadata))->not->toContain('secret-value');
});
test('database cleanup removes audit events older than 90 days', function () {
$old = AuditEvent::factory()->create([
'team_id' => $this->team->id,
'created_at' => now()->subDays(91),
]);
$recent = AuditEvent::factory()->create([
'team_id' => $this->team->id,
'created_at' => now()->subDays(89),
]);
AuditEvent::pruneExpired();
expect($old->fresh())->toBeNull()
->and($recent->fresh())->not->toBeNull();
});