mirror of
https://github.com/coollabsio/coolify.git
synced 2026-08-24 02:24:11 -05:00
Log audit failures, redact invitation emails, exclude noisy model fields, and gate audit-log filters and pagination by admin access.
808 lines
27 KiB
PHP
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();
|
|
});
|