mirror of
https://github.com/coollabsio/coolify.git
synced 2026-08-24 02:24:11 -05:00
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.
This commit is contained in:
@@ -183,6 +183,7 @@ class Heading extends Component
|
||||
return;
|
||||
}
|
||||
$activity = StartService::run($this->service, pullLatestImages: true, stopBeforeStart: true);
|
||||
$this->auditServiceAction('ui.service.restarted');
|
||||
$this->js("window.dispatchEvent(new CustomEvent('startservice'))");
|
||||
$this->dispatch('activityMonitor', $activity->id);
|
||||
} catch (\Throwable $e) {
|
||||
|
||||
@@ -81,6 +81,11 @@ class ResourceOperations extends Component
|
||||
if (! $new_destination) {
|
||||
return $this->addError('destination_id', 'Destination not found.');
|
||||
}
|
||||
$uuid = new_public_id();
|
||||
$server = $new_destination->server;
|
||||
if (! $server->canHostResources()) {
|
||||
return $this->addError('destination_id', 'The selected server cannot host resources.');
|
||||
}
|
||||
auditLog('ui.resource.clone_started', [
|
||||
'team_id' => $this->resource->team()?->id,
|
||||
'resource_uuid' => $this->resource->uuid,
|
||||
@@ -89,11 +94,6 @@ class ResourceOperations extends Component
|
||||
'destination_uuid' => $new_destination->uuid,
|
||||
'environment_id' => $new_environment->id,
|
||||
]);
|
||||
$uuid = new_public_id();
|
||||
$server = $new_destination->server;
|
||||
if (! $server->canHostResources()) {
|
||||
return $this->addError('destination_id', 'The selected server cannot host resources.');
|
||||
}
|
||||
|
||||
if ($this->resource->getMorphClass() === Application::class) {
|
||||
$new_resource = clone_application($this->resource, $new_destination, [
|
||||
|
||||
@@ -19,7 +19,7 @@ class AuditLog extends Component
|
||||
|
||||
public int $perPage = 25;
|
||||
|
||||
public function mount(): void
|
||||
public function boot(): void
|
||||
{
|
||||
abort_unless(auth()->user()->isAdminOfTeam(currentTeam()->id), 403);
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@ use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Support\Arr;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use Illuminate\Support\Str;
|
||||
use Throwable;
|
||||
|
||||
@@ -60,11 +61,19 @@ class AuditEvent extends Model
|
||||
defer(function () use ($attributes): void {
|
||||
try {
|
||||
self::query()->create($attributes);
|
||||
} catch (Throwable) {
|
||||
} catch (Throwable $exception) {
|
||||
Log::warning('Audit event persistence failed', [
|
||||
'event' => $attributes['event'],
|
||||
'exception' => $exception::class,
|
||||
]);
|
||||
}
|
||||
})->always();
|
||||
});
|
||||
} catch (Throwable) {
|
||||
} catch (Throwable $exception) {
|
||||
Log::warning('Audit event preparation failed', [
|
||||
'event' => $event,
|
||||
'exception' => $exception::class,
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -154,7 +163,7 @@ class AuditEvent extends Model
|
||||
|
||||
private static function redact(mixed $value, ?string $key = null): mixed
|
||||
{
|
||||
if ($key !== null && preg_match('/password|secret|token|private_key|signature|credential/i', $key)) {
|
||||
if ($key !== null && preg_match('/password|secret|token|private_key|signature|credential|invitation_email/i', $key)) {
|
||||
return '[REDACTED]';
|
||||
}
|
||||
|
||||
|
||||
@@ -64,7 +64,9 @@ class Project extends BaseModel
|
||||
]);
|
||||
});
|
||||
static::deleting(function ($project) {
|
||||
$project->environments()->delete();
|
||||
foreach ($project->environments()->get() as $environment) {
|
||||
$environment->delete();
|
||||
}
|
||||
$project->settings()->delete();
|
||||
$shared_variables = $project->environment_variables();
|
||||
foreach ($shared_variables as $shared_variable) {
|
||||
|
||||
@@ -16,6 +16,8 @@ class StandaloneClickhouse extends BaseModel
|
||||
{
|
||||
use Auditable, ClearsGlobalSearchCache, HasDatabaseHealthCheck, HasFactory, HasMetrics, HasSafeStringAttribute, HasSecretManager, SoftDeletes;
|
||||
|
||||
protected array $auditExclude = ['last_online_at'];
|
||||
|
||||
protected $fillable = [
|
||||
'uuid',
|
||||
'name',
|
||||
|
||||
@@ -16,6 +16,8 @@ class StandaloneRedis extends BaseModel
|
||||
{
|
||||
use Auditable, ClearsGlobalSearchCache, HasDatabaseHealthCheck, HasFactory, HasMetrics, HasSafeStringAttribute, HasSecretManager, SoftDeletes;
|
||||
|
||||
protected array $auditExclude = ['last_online_at'];
|
||||
|
||||
protected $fillable = [
|
||||
'uuid',
|
||||
'name',
|
||||
|
||||
+5
-2
@@ -87,8 +87,11 @@ class Team extends Model implements SendsDiscord, SendsEmail, SendsPushover, Sen
|
||||
}
|
||||
|
||||
// Transfer instance-wide sources to root team so they remain available
|
||||
GithubApp::where('team_id', $team->id)->where('is_system_wide', true)->update(['team_id' => 0]);
|
||||
GitlabApp::where('team_id', $team->id)->where('is_system_wide', true)->update(['team_id' => 0]);
|
||||
$systemWideSources = GithubApp::where('team_id', $team->id)->where('is_system_wide', true)->get()
|
||||
->concat(GitlabApp::where('team_id', $team->id)->where('is_system_wide', true)->get());
|
||||
foreach ($systemWideSources as $source) {
|
||||
$source->update(['team_id' => 0]);
|
||||
}
|
||||
|
||||
// Delete non-instance-wide sources owned by this team
|
||||
$teamSources = GithubApp::where('team_id', $team->id)->get()
|
||||
|
||||
@@ -29,7 +29,12 @@ trait Auditable
|
||||
|
||||
$changedFields = $action === 'updated'
|
||||
? collect(array_keys($this->getChanges()))
|
||||
->reject(fn (string $field): bool => in_array($field, ['updated_at', 'order', 'status'], true))
|
||||
->reject(fn (string $field): bool => in_array($field, [
|
||||
'updated_at',
|
||||
'order',
|
||||
'status',
|
||||
...($this->auditExclude ?? []),
|
||||
], true))
|
||||
->values()
|
||||
->all()
|
||||
: [];
|
||||
|
||||
@@ -84,9 +84,8 @@ function queue_application_deployment(Application $application, string $deployme
|
||||
'only_this_server' => $only_this_server,
|
||||
]);
|
||||
|
||||
if (auth()->check() && ! $is_webhook && ! $is_api) {
|
||||
if (auth()->check() && ! $is_webhook && ! $is_api && ! $rollback) {
|
||||
auditLog($restart_only ? 'ui.application.restarted' : 'ui.application.deployed', [
|
||||
'team_id' => $application->team()?->id,
|
||||
'application_uuid' => $application->uuid,
|
||||
'application_name' => $application->name,
|
||||
'deployment_uuid' => $deployment_uuid,
|
||||
|
||||
@@ -3,8 +3,15 @@
|
||||
'livewire' => false,
|
||||
'options' => [10, 25, 50, 100],
|
||||
'storageKey' => null,
|
||||
'canGate' => null,
|
||||
'canResource' => null,
|
||||
])
|
||||
|
||||
@php
|
||||
$disabled = $canGate && $canResource
|
||||
&& ! Illuminate\Support\Facades\Gate::allows($canGate, $canResource);
|
||||
@endphp
|
||||
|
||||
<div class="mb-0! flex h-7 items-center gap-1.5 text-[11px] text-neutral-500 dark:text-fg-dim"
|
||||
x-data="{
|
||||
selectedPageSize: @js((int) $options[0]),
|
||||
@@ -36,6 +43,7 @@
|
||||
<x-table.dropdown panel-class="min-w-24!">
|
||||
<x-slot:trigger>
|
||||
<button type="button" aria-label="Items per page" aria-haspopup="listbox" :aria-expanded="open"
|
||||
@disabled($disabled)
|
||||
class="inline-flex h-7! w-12! items-center justify-between border-0 px-1 text-[11px]! leading-none! tabular-nums text-neutral-500 transition-colors hover:text-black dark:text-fg-dim dark:hover:text-fg">
|
||||
<span x-text="selectedPageSize"></span>
|
||||
<x-reicon name="chevron-down" class="size-3 text-neutral-400 dark:text-fg-faint" />
|
||||
@@ -43,6 +51,7 @@
|
||||
</x-slot:trigger>
|
||||
@foreach ($options as $option)
|
||||
<button type="button" class="listbox-option" role="option"
|
||||
@disabled($disabled)
|
||||
:aria-selected="selectedPageSize === {{ (int) $option }}"
|
||||
x-on:click="applyPageSize({{ (int) $option }})">
|
||||
<span>{{ $option }}</span>
|
||||
@@ -50,6 +59,7 @@
|
||||
</button>
|
||||
@endforeach
|
||||
<button type="button" class="listbox-option" role="option"
|
||||
@disabled($disabled)
|
||||
x-on:click="customizingPageSize = true; $nextTick(() => $refs.customPageSize.focus())">
|
||||
Custom…
|
||||
</button>
|
||||
@@ -58,6 +68,6 @@
|
||||
<input x-cloak x-show="customizingPageSize" x-ref="customPageSize" x-model.number="customPageSize"
|
||||
x-on:keydown.enter.prevent="applyPageSize(customPageSize)" x-on:keydown.escape.prevent="customizingPageSize = false"
|
||||
x-on:blur="if (customizingPageSize) applyPageSize(customPageSize)" type="number" min="1" max="100" inputmode="numeric"
|
||||
aria-label="Custom items per page"
|
||||
aria-label="Custom items per page" @disabled($disabled)
|
||||
class="mb-0! h-7! w-14! rounded-md! border-neutral-200! bg-transparent! px-1.5! py-0! text-[11px]! tabular-nums shadow-none! focus:border-neutral-300! focus:ring-0! dark:border-white/[0.08]! dark:text-fg-dim!" />
|
||||
</div>
|
||||
|
||||
@@ -18,7 +18,7 @@
|
||||
</div>
|
||||
<div class="grid grid-cols-2 gap-2 sm:flex">
|
||||
<div class="sm:w-36">
|
||||
<x-forms.listbox id="action" live :options="[
|
||||
<x-forms.listbox id="action" live canGate="viewAdmin" :canResource="currentTeam()" :options="[
|
||||
['value' => 'all', 'label' => 'All actions'],
|
||||
['value' => 'created', 'label' => 'Created'],
|
||||
['value' => 'updated', 'label' => 'Updated'],
|
||||
@@ -35,7 +35,7 @@
|
||||
]" />
|
||||
</div>
|
||||
<div class="sm:w-36">
|
||||
<x-forms.listbox id="source" live :options="[
|
||||
<x-forms.listbox id="source" live canGate="viewAdmin" :canResource="currentTeam()" :options="[
|
||||
['value' => 'all', 'label' => 'All sources'],
|
||||
['value' => 'ui', 'label' => 'Web UI'],
|
||||
['value' => 'api', 'label' => 'API'],
|
||||
@@ -103,7 +103,8 @@
|
||||
:last-page="$events->lastPage()" wire-target="setPage,previousPage,nextPage"
|
||||
previous-action="previousPage" next-action="nextPage">
|
||||
<x-slot:pageSize>
|
||||
<x-page-size-select model="perPage" livewire storage-key="coolify.page-size.audit-log" />
|
||||
<x-page-size-select model="perPage" livewire storage-key="coolify.page-size.audit-log"
|
||||
canGate="viewAdmin" :canResource="currentTeam()" />
|
||||
</x-slot:pageSize>
|
||||
</x-table-pagination>
|
||||
@else
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
<?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;
|
||||
@@ -9,6 +10,8 @@ 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;
|
||||
@@ -26,6 +29,7 @@ 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;
|
||||
@@ -94,11 +98,46 @@ test('http kernel invokes deferred callbacks', function () {
|
||||
});
|
||||
|
||||
test('audit persistence failures do not fail the action', function () {
|
||||
Schema::drop('audit_events');
|
||||
Schema::rename('audit_events', 'unavailable_audit_events');
|
||||
|
||||
auditLog('ui.project.updated', ['team_id' => $this->team->id]);
|
||||
try {
|
||||
expect(fn () => auditLog('ui.project.updated', ['team_id' => $this->team->id]))
|
||||
->not->toThrow(Throwable::class);
|
||||
|
||||
expect(true)->toBeTrue();
|
||||
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 () {
|
||||
@@ -138,6 +177,49 @@ test('auditable models record authenticated create update and delete actions', f
|
||||
])->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');
|
||||
|
||||
@@ -373,6 +455,39 @@ test('team resource models opt in to automatic auditing', function (string $mode
|
||||
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,
|
||||
@@ -388,6 +503,23 @@ test('audit log redacts sensitive metadata', function () {
|
||||
->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,
|
||||
@@ -419,6 +551,15 @@ test('team members cannot view the audit log page', function () {
|
||||
$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,
|
||||
@@ -463,41 +604,31 @@ test('team members cannot query audit events through the api', function () {
|
||||
});
|
||||
|
||||
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']");
|
||||
Livewire::test(AuditLog::class)
|
||||
->assertSee('All sources')
|
||||
->assertSee('Web UI')
|
||||
->assertSee('API')
|
||||
->assertSee('MCP')
|
||||
->assertSee('Webhook')
|
||||
->assertDontSee('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('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, [
|
||||
@@ -547,17 +678,26 @@ test('critical operational events persist with their source action and actor', f
|
||||
]);
|
||||
|
||||
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'));
|
||||
AuditEvent::factory()->create([
|
||||
'team_id' => $this->team->id,
|
||||
'actor_name' => 'Visible Actor',
|
||||
]);
|
||||
|
||||
expect($view)->toContain('overflow-x-auto')
|
||||
->toContain('min-w-[760px]')
|
||||
->not->toContain('hidden lg:block">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 () {
|
||||
$view = file_get_contents(resource_path('views/livewire/team/audit-log.blade.php'));
|
||||
AuditEvent::factory()->create([
|
||||
'team_id' => $this->team->id,
|
||||
'source' => 'cli',
|
||||
]);
|
||||
|
||||
expect($view)->toContain('Str::upper($event->source)');
|
||||
Livewire::test(AuditLog::class)
|
||||
->assertSee('CLI');
|
||||
});
|
||||
|
||||
test('audit log page filters events by search and action', function () {
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
use App\Jobs\ApplicationDeploymentJob;
|
||||
use App\Models\Application;
|
||||
use App\Models\ApplicationDeploymentQueue;
|
||||
use App\Models\AuditEvent;
|
||||
use App\Models\Environment;
|
||||
use App\Models\Project;
|
||||
use App\Models\Server;
|
||||
@@ -61,18 +62,39 @@ describe('queue_application_deployment commit resolution', function () {
|
||||
});
|
||||
|
||||
test('uses the deployed application team for the audit event', function () {
|
||||
$actorTeam = Team::factory()->create();
|
||||
$user = User::factory()->create();
|
||||
$this->team->members()->attach($user, ['role' => 'owner']);
|
||||
$actorTeam->members()->attach($user, ['role' => 'owner']);
|
||||
$this->actingAs($user);
|
||||
session()->forget('currentTeam');
|
||||
$application = makeApplication($this->environment->id, $this->destination->id, 'HEAD');
|
||||
|
||||
queue_application_deployment($application, 'resource-team-audit-deploy');
|
||||
|
||||
$this->assertDatabaseHas('audit_events', [
|
||||
'team_id' => $this->team->id,
|
||||
'event' => 'ui.application.deployed',
|
||||
'resource_uuid' => $application->uuid,
|
||||
$event = AuditEvent::query()->where('event', 'ui.application.deployed')->sole();
|
||||
|
||||
expect($event->team_id)->toBe($this->team->id)
|
||||
->and($event->team_id)->not->toBe($actorTeam->id)
|
||||
->and($event->resource_uuid)->toBe($application->uuid)
|
||||
->and($event->metadata)->not->toHaveKey('team_id');
|
||||
});
|
||||
|
||||
test('records only the rollback audit event when a user queues a rollback', function () {
|
||||
$user = User::factory()->create();
|
||||
$this->team->members()->attach($user, ['role' => 'owner']);
|
||||
$this->actingAs($user);
|
||||
$application = makeApplication($this->environment->id, $this->destination->id, 'HEAD');
|
||||
AuditEvent::query()->delete();
|
||||
|
||||
queue_application_deployment(
|
||||
application: $application,
|
||||
deployment_uuid: 'audit-rollback-uuid',
|
||||
commit: 'previous-commit',
|
||||
rollback: true,
|
||||
);
|
||||
|
||||
expect(AuditEvent::query()->pluck('event')->all())->toBe([
|
||||
'ui.application.rollback',
|
||||
]);
|
||||
});
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Support\Facades\Blade;
|
||||
use Illuminate\Support\Facades\Gate;
|
||||
|
||||
it('renders the shared page size selector', function () {
|
||||
$html = Blade::render('<x-page-size-select model="perPage" livewire storage-key="tests.page-size" />');
|
||||
@@ -30,6 +31,18 @@ it('renders the shared page size selector', function () {
|
||||
->toContain('max="100"');
|
||||
});
|
||||
|
||||
it('disables page size controls when the gate denies access', function () {
|
||||
Gate::define('view-audit-log-test', fn (): bool => false);
|
||||
|
||||
$html = Blade::render(<<<'BLADE'
|
||||
<x-page-size-select model="perPage" livewire
|
||||
canGate="view-audit-log-test" :canResource="new stdClass" />
|
||||
BLADE);
|
||||
|
||||
expect($html)->toMatch('/<button[^>]*aria-label="Items per page"[^>]*\sdisabled(?:[=\s>])/')
|
||||
->toMatch('/<input[^>]*aria-label="Custom items per page"[^>]*\sdisabled(?:[=\s>])/');
|
||||
});
|
||||
|
||||
it('positions table dropdown panels outside overflowing containers', function () {
|
||||
$html = Blade::render(<<<'BLADE'
|
||||
<x-table.dropdown>
|
||||
|
||||
Reference in New Issue
Block a user