Files
coolify/tests/Feature/AuditEventsTest.php
T

615 lines
22 KiB
PHP

<?php
use App\Http\Kernel;
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\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\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::drop('audit_events');
auditLog('ui.project.updated', ['team_id' => $this->team->id]);
expect(true)->toBeTrue();
});
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('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('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('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('audit source filter omits the unused system source', function () {
$view = file_get_contents(resource_path('views/livewire/team/audit-log.blade.php'));
expect($view)->not->toContain("['value' => 'system', 'label' => 'System']");
});
test('critical UI operations have explicit audit events', function (string $path, string $event) {
expect(file_get_contents(base_path($path)))->toContain("'{$event}'");
})->with([
['app/Livewire/Project/Application/Heading.php', 'ui.application.stopped'],
['app/Livewire/Project/Application/Previews.php', 'ui.application.preview_stopped'],
['app/Livewire/Project/Shared/Destination.php', 'ui.application.destination_stopped'],
['app/Livewire/Project/Service/Heading.php', 'ui.service.started'],
['app/Livewire/Project/Service/Heading.php', 'ui.service.stopped'],
['app/Livewire/Project/Service/Heading.php', 'ui.service.restarted'],
['app/Livewire/Project/Database/Heading.php', 'ui.database.started'],
['app/Livewire/Project/Database/Heading.php', 'ui.database.stopped'],
['app/Livewire/Project/Database/Heading.php', 'ui.database.restarted'],
['app/Livewire/Server/Navbar.php', 'ui.proxy.stopped'],
['app/Livewire/Server/Navbar.php', 'ui.proxy.restarted'],
['app/Livewire/Project/Database/BackupEdit.php', 'ui.database.backup_started'],
['app/Livewire/Project/Database/BackupEdit.php', 'ui.database.backup_schedule_deleted'],
['app/Livewire/Project/Database/ImportForm.php', 'ui.database.import_started'],
['app/Livewire/Project/Database/ImportForm.php', 'ui.database.restore_started'],
['app/Livewire/Project/Shared/ScheduledTask/Show.php', 'ui.scheduled_task.executed'],
['app/Livewire/Security/ApiTokens.php', 'ui.api_token.created'],
['app/Livewire/Security/ApiTokens.php', 'ui.api_token.revoked'],
['app/Livewire/Team/Member.php', 'ui.team_member.role_updated'],
['app/Livewire/Team/Member.php', 'ui.team_member.removed'],
['app/Livewire/Team/InviteLink.php', 'ui.team_invitation.created'],
['app/Livewire/Team/Invitations.php', 'ui.team_invitation.revoked'],
['app/Livewire/Server/DockerCleanup.php', 'ui.server.docker_cleanup_started'],
['app/Livewire/Server/TransferImport.php', 'ui.server.imported'],
['app/Livewire/Project/CloneMe.php', 'ui.project.clone_started'],
['app/Livewire/Project/Shared/ResourceOperations.php', 'ui.resource.clone_started'],
]);
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 () {
$view = file_get_contents(resource_path('views/livewire/team/audit-log.blade.php'));
expect($view)->toContain('overflow-x-auto')
->toContain('min-w-[760px]')
->not->toContain('hidden lg:block">Actor');
});
test('audit log displays source abbreviations in uppercase', function () {
$view = file_get_contents(resource_path('views/livewire/team/audit-log.blade.php'));
expect($view)->toContain('Str::upper($event->source)');
});
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();
});