diff --git a/app/Filament/Admin/Pages/BaseAdminCreateRecord.php b/app/Filament/Admin/Pages/BaseAdminCreateRecord.php new file mode 100644 index 0000000000..fff4c4e939 --- /dev/null +++ b/app/Filament/Admin/Pages/BaseAdminCreateRecord.php @@ -0,0 +1,16 @@ +getRecord()); + } +} diff --git a/app/Filament/Admin/Pages/BaseAdminEditRecord.php b/app/Filament/Admin/Pages/BaseAdminEditRecord.php new file mode 100644 index 0000000000..97b13afc45 --- /dev/null +++ b/app/Filament/Admin/Pages/BaseAdminEditRecord.php @@ -0,0 +1,31 @@ + */ + protected array $attributesBeforeSave = []; + + protected function beforeSave(): void + { + $this->attributesBeforeSave = $this->getRecord()->getAttributes(); + } + + protected function afterSave(): void + { + // getChanges() is cast-aware, so a no-op save produces nothing here. + $changes = static::buildDiff($this->attributesBeforeSave, $this->getRecord()->getChanges()); + + if ($changes === []) { + return; + } + + static::logAdminActivity('update', $this->getRecord(), ['changes' => $changes]); + } +} diff --git a/app/Filament/Admin/Resources/Eggs/Pages/CreateEgg.php b/app/Filament/Admin/Resources/Eggs/Pages/CreateEgg.php index 758d871fa0..3de25ecba5 100644 --- a/app/Filament/Admin/Resources/Eggs/Pages/CreateEgg.php +++ b/app/Filament/Admin/Resources/Eggs/Pages/CreateEgg.php @@ -4,6 +4,7 @@ use App\Enums\EditorLanguages; use App\Enums\TablerIcon; +use App\Filament\Admin\Pages\BaseAdminCreateRecord; use App\Filament\Admin\Resources\Eggs\EggResource; use App\Filament\Components\Forms\Fields\CopyFrom; use App\Filament\Components\Forms\Fields\MonacoEditor; @@ -22,7 +23,6 @@ use Filament\Forms\Components\Textarea; use Filament\Forms\Components\TextInput; use Filament\Forms\Components\Toggle; -use Filament\Resources\Pages\CreateRecord; use Filament\Schemas\Components\Fieldset; use Filament\Schemas\Components\Tabs; use Filament\Schemas\Components\Tabs\Tab; @@ -33,7 +33,7 @@ use Illuminate\Support\Str; use Illuminate\Validation\Rules\Unique; -class CreateEgg extends CreateRecord +class CreateEgg extends BaseAdminCreateRecord { use CanCustomizeHeaderActions; use CanCustomizeHeaderWidgets; diff --git a/app/Filament/Admin/Resources/Eggs/Pages/EditEgg.php b/app/Filament/Admin/Resources/Eggs/Pages/EditEgg.php index 1d8cba551f..dcb2fe47f3 100644 --- a/app/Filament/Admin/Resources/Eggs/Pages/EditEgg.php +++ b/app/Filament/Admin/Resources/Eggs/Pages/EditEgg.php @@ -3,22 +3,22 @@ namespace App\Filament\Admin\Resources\Eggs\Pages; use App\Enums\TablerIcon; +use App\Filament\Admin\Pages\BaseAdminEditRecord; use App\Filament\Admin\Resources\Eggs\EggResource; use App\Filament\Components\Actions\ExportEggAction; use App\Filament\Components\Actions\ImportEggAction; +use App\Filament\Components\Actions\LoggedDeleteAction; use App\Models\Egg; use App\Traits\Filament\CanCustomizeHeaderActions; use App\Traits\Filament\CanCustomizeHeaderWidgets; use App\Traits\Filament\CanCustomizeTabs; use Filament\Actions\Action; use Filament\Actions\ActionGroup; -use Filament\Actions\DeleteAction; -use Filament\Resources\Pages\EditRecord; use Filament\Schemas\Components\Tabs; use Filament\Schemas\Components\Tabs\Tab; use Filament\Schemas\Schema; -class EditEgg extends EditRecord +class EditEgg extends BaseAdminEditRecord { use CanCustomizeHeaderActions; use CanCustomizeHeaderWidgets; @@ -47,7 +47,7 @@ protected function getDefaultTabs(): array protected function getDefaultHeaderActions(): array { return [ - DeleteAction::make() + LoggedDeleteAction::make() ->disabled(fn (Egg $egg): bool => $egg->servers()->count() > 0) ->tooltip(fn (Egg $egg): string => $egg->servers()->count() <= 0 ? trans('filament-actions::delete.single.label') : trans('admin/egg.in_use')), ExportEggAction::make(), diff --git a/app/Filament/Admin/Resources/Mounts/Pages/CreateMount.php b/app/Filament/Admin/Resources/Mounts/Pages/CreateMount.php index c6dd95c9c9..e9a30fcd77 100644 --- a/app/Filament/Admin/Resources/Mounts/Pages/CreateMount.php +++ b/app/Filament/Admin/Resources/Mounts/Pages/CreateMount.php @@ -3,16 +3,16 @@ namespace App\Filament\Admin\Resources\Mounts\Pages; use App\Enums\TablerIcon; +use App\Filament\Admin\Pages\BaseAdminCreateRecord; use App\Filament\Admin\Resources\Mounts\MountResource; use App\Traits\Filament\CanCustomizeHeaderActions; use App\Traits\Filament\CanCustomizeHeaderWidgets; use Filament\Actions\Action; use Filament\Actions\ActionGroup; -use Filament\Resources\Pages\CreateRecord; use Illuminate\Database\Eloquent\Model; use Illuminate\Support\Str; -class CreateMount extends CreateRecord +class CreateMount extends BaseAdminCreateRecord { use CanCustomizeHeaderActions; use CanCustomizeHeaderWidgets; diff --git a/app/Filament/Admin/Resources/Mounts/Pages/EditMount.php b/app/Filament/Admin/Resources/Mounts/Pages/EditMount.php index f520ab78a8..bfd8057b1c 100644 --- a/app/Filament/Admin/Resources/Mounts/Pages/EditMount.php +++ b/app/Filament/Admin/Resources/Mounts/Pages/EditMount.php @@ -3,15 +3,15 @@ namespace App\Filament\Admin\Resources\Mounts\Pages; use App\Enums\TablerIcon; +use App\Filament\Admin\Pages\BaseAdminEditRecord; use App\Filament\Admin\Resources\Mounts\MountResource; +use App\Filament\Components\Actions\LoggedDeleteAction; use App\Traits\Filament\CanCustomizeHeaderActions; use App\Traits\Filament\CanCustomizeHeaderWidgets; use Filament\Actions\Action; use Filament\Actions\ActionGroup; -use Filament\Actions\DeleteAction; -use Filament\Resources\Pages\EditRecord; -class EditMount extends EditRecord +class EditMount extends BaseAdminEditRecord { use CanCustomizeHeaderActions; use CanCustomizeHeaderWidgets; @@ -22,7 +22,7 @@ class EditMount extends EditRecord protected function getDefaultHeaderActions(): array { return [ - DeleteAction::make(), + LoggedDeleteAction::make(), Action::make('save') ->hiddenLabel() ->action('save') diff --git a/app/Filament/Admin/Resources/Nodes/Pages/CreateNode.php b/app/Filament/Admin/Resources/Nodes/Pages/CreateNode.php index 861022f28b..b41c4e9c9a 100644 --- a/app/Filament/Admin/Resources/Nodes/Pages/CreateNode.php +++ b/app/Filament/Admin/Resources/Nodes/Pages/CreateNode.php @@ -3,6 +3,7 @@ namespace App\Filament\Admin\Resources\Nodes\Pages; use App\Enums\TablerIcon; +use App\Filament\Admin\Pages\BaseAdminCreateRecord; use App\Filament\Admin\Resources\Nodes\NodeResource; use App\Models\Node; use App\Traits\Filament\CanCustomizeHeaderActions; @@ -13,7 +14,6 @@ use Filament\Forms\Components\TagsInput; use Filament\Forms\Components\TextInput; use Filament\Forms\Components\ToggleButtons; -use Filament\Resources\Pages\CreateRecord; use Filament\Schemas\Components\Grid; use Filament\Schemas\Components\Utilities\Get; use Filament\Schemas\Components\Utilities\Set; @@ -24,7 +24,7 @@ use Illuminate\Support\Facades\Blade; use Illuminate\Support\HtmlString; -class CreateNode extends CreateRecord +class CreateNode extends BaseAdminCreateRecord { use CanCustomizeHeaderActions; use CanCustomizeHeaderWidgets; diff --git a/app/Filament/Admin/Resources/Nodes/Pages/EditNode.php b/app/Filament/Admin/Resources/Nodes/Pages/EditNode.php index 6ca1ab8fa1..f417d8b186 100644 --- a/app/Filament/Admin/Resources/Nodes/Pages/EditNode.php +++ b/app/Filament/Admin/Resources/Nodes/Pages/EditNode.php @@ -3,23 +3,23 @@ namespace App\Filament\Admin\Resources\Nodes\Pages; use App\Enums\TablerIcon; +use App\Filament\Admin\Pages\BaseAdminEditRecord; use App\Filament\Admin\Resources\Nodes\NodeResource; +use App\Filament\Components\Actions\LoggedDeleteAction; use App\Models\Node; use App\Repositories\Daemon\DaemonSystemRepository; use App\Traits\Filament\CanCustomizeHeaderActions; use App\Traits\Filament\CanCustomizeHeaderWidgets; use App\Traits\Filament\CanCustomizeTabs; use Filament\Actions\Action; -use Filament\Actions\DeleteAction; use Filament\Notifications\Notification; -use Filament\Resources\Pages\EditRecord; use Filament\Schemas\Components\Actions; use Filament\Schemas\Components\Tabs; use Filament\Schemas\Components\Tabs\Tab; use Filament\Schemas\Schema; use Illuminate\Http\Client\ConnectionException; -class EditNode extends EditRecord +class EditNode extends BaseAdminEditRecord { use CanCustomizeHeaderActions; use CanCustomizeHeaderWidgets; @@ -82,7 +82,7 @@ protected function getFormActions(): array protected function getDefaultHeaderActions(): array { return [ - DeleteAction::make() + LoggedDeleteAction::make() ->disabled(fn (Node $node) => $node->servers()->count() > 0) ->tooltip(fn (Node $node) => $node->servers()->count() > 0 ? trans('admin/node.node_has_servers') : trans('filament-actions::delete.single.label')), Action::make('save') @@ -105,6 +105,8 @@ protected function mutateFormDataBeforeSave(array $data): array protected function afterSave(): void { + parent::afterSave(); + $this->fillForm(); /** @var Node $node */ diff --git a/app/Filament/Admin/Resources/Servers/Pages/CreateServer.php b/app/Filament/Admin/Resources/Servers/Pages/CreateServer.php index 92d5aee86b..9eb86cead2 100644 --- a/app/Filament/Admin/Resources/Servers/Pages/CreateServer.php +++ b/app/Filament/Admin/Resources/Servers/Pages/CreateServer.php @@ -3,6 +3,7 @@ namespace App\Filament\Admin\Resources\Servers\Pages; use App\Enums\TablerIcon; +use App\Filament\Admin\Pages\BaseAdminCreateRecord; use App\Filament\Admin\Resources\Servers\ServerResource; use App\Filament\Components\Forms\Fields\StartupVariable; use App\Models\Allocation; @@ -28,7 +29,6 @@ use Filament\Forms\Components\ToggleButtons; use Filament\Infolists\Components\TextEntry; use Filament\Notifications\Notification; -use Filament\Resources\Pages\CreateRecord; use Filament\Schemas\Components\Fieldset; use Filament\Schemas\Components\Grid; use Filament\Schemas\Components\Section; @@ -46,7 +46,7 @@ use LogicException; use Random\RandomException; -class CreateServer extends CreateRecord +class CreateServer extends BaseAdminCreateRecord { use CanCustomizeHeaderActions; use CanCustomizeHeaderWidgets; diff --git a/app/Filament/Admin/Resources/Servers/Pages/EditServer.php b/app/Filament/Admin/Resources/Servers/Pages/EditServer.php index c80a86de1d..3d3c09dc43 100644 --- a/app/Filament/Admin/Resources/Servers/Pages/EditServer.php +++ b/app/Filament/Admin/Resources/Servers/Pages/EditServer.php @@ -3,6 +3,7 @@ namespace App\Filament\Admin\Resources\Servers\Pages; use App\Enums\TablerIcon; +use App\Filament\Admin\Pages\BaseAdminEditRecord; use App\Filament\Admin\Resources\Servers\ServerResource; use App\Filament\Server\Pages\Console; use App\Models\Server; @@ -14,14 +15,13 @@ use Filament\Actions\Action; use Filament\Actions\ActionGroup; use Filament\Notifications\Notification; -use Filament\Resources\Pages\EditRecord; use Filament\Schemas\Components\Tabs; use Filament\Schemas\Components\Tabs\Tab; use Filament\Schemas\Schema; use Illuminate\Http\Client\ConnectionException; use Random\RandomException; -class EditServer extends EditRecord +class EditServer extends BaseAdminEditRecord { use CanCustomizeHeaderActions; use CanCustomizeHeaderWidgets; @@ -83,6 +83,8 @@ protected function getDefaultHeaderActions(): array try { $service->handle($server); + static::logAdminActivity('delete', $server); + return redirect(ListServers::getUrl(panel: 'admin')); } catch (ConnectionException) { cache()->put("servers.$server->uuid.canForceDelete", true, now()->addMinutes(5)); @@ -109,6 +111,8 @@ protected function getDefaultHeaderActions(): array try { $service->withForce()->handle($server); + static::logAdminActivity('delete', $server); + return redirect(ListServers::getUrl(panel: 'admin')); } catch (ConnectionException) { return cache()->forget("servers.$server->uuid.canForceDelete"); @@ -149,6 +153,8 @@ protected function mutateFormDataBeforeSave(array $data): array protected function afterSave(): void { + parent::afterSave(); + /** @var Server $server */ $server = $this->record; diff --git a/app/Filament/Admin/Resources/Users/Pages/CreateUser.php b/app/Filament/Admin/Resources/Users/Pages/CreateUser.php index a0034b3c2e..a09e533325 100644 --- a/app/Filament/Admin/Resources/Users/Pages/CreateUser.php +++ b/app/Filament/Admin/Resources/Users/Pages/CreateUser.php @@ -3,6 +3,7 @@ namespace App\Filament\Admin\Resources\Users\Pages; use App\Enums\TablerIcon; +use App\Filament\Admin\Pages\BaseAdminCreateRecord; use App\Filament\Admin\Resources\Users\UserResource; use App\Models\Role; use App\Services\Users\UserCreationService; @@ -10,10 +11,9 @@ use App\Traits\Filament\CanCustomizeHeaderWidgets; use Filament\Actions\Action; use Filament\Actions\ActionGroup; -use Filament\Resources\Pages\CreateRecord; use Illuminate\Database\Eloquent\Model; -class CreateUser extends CreateRecord +class CreateUser extends BaseAdminCreateRecord { use CanCustomizeHeaderActions; use CanCustomizeHeaderWidgets; diff --git a/app/Filament/Admin/Resources/Users/Pages/EditUser.php b/app/Filament/Admin/Resources/Users/Pages/EditUser.php index 5b575b5d28..94f9bdc5eb 100644 --- a/app/Filament/Admin/Resources/Users/Pages/EditUser.php +++ b/app/Filament/Admin/Resources/Users/Pages/EditUser.php @@ -3,18 +3,18 @@ namespace App\Filament\Admin\Resources\Users\Pages; use App\Enums\TablerIcon; +use App\Filament\Admin\Pages\BaseAdminEditRecord; use App\Filament\Admin\Resources\Users\UserResource; +use App\Filament\Components\Actions\LoggedDeleteAction; use App\Models\User; use App\Services\Users\UserUpdateService; use App\Traits\Filament\CanCustomizeHeaderActions; use App\Traits\Filament\CanCustomizeHeaderWidgets; use Filament\Actions\Action; use Filament\Actions\ActionGroup; -use Filament\Actions\DeleteAction; -use Filament\Resources\Pages\EditRecord; use Illuminate\Database\Eloquent\Model; -class EditUser extends EditRecord +class EditUser extends BaseAdminEditRecord { use CanCustomizeHeaderActions; use CanCustomizeHeaderWidgets; @@ -32,7 +32,7 @@ public function boot(UserUpdateService $service): void protected function getDefaultHeaderActions(): array { return [ - DeleteAction::make() + LoggedDeleteAction::make() ->tooltip(fn (User $user) => user()?->id === $user->id ? trans('admin/user.self_delete') : ($user->servers()->count() > 0 ? trans('admin/user.has_servers') : trans('filament-actions::delete.single.modal.actions.delete.label'))) ->disabled(fn (User $user) => user()?->id === $user->id || $user->servers()->count() > 0), Action::make('save') diff --git a/app/Filament/Components/Actions/LoggedDeleteAction.php b/app/Filament/Components/Actions/LoggedDeleteAction.php new file mode 100644 index 0000000000..bfe7c9fb5d --- /dev/null +++ b/app/Filament/Components/Actions/LoggedDeleteAction.php @@ -0,0 +1,20 @@ +after(fn (Model $record) => static::logAdminActivity('delete', $record)); + } +} diff --git a/app/Providers/AppServiceProvider.php b/app/Providers/AppServiceProvider.php index 31e53d841a..7205b65b2b 100644 --- a/app/Providers/AppServiceProvider.php +++ b/app/Providers/AppServiceProvider.php @@ -18,6 +18,7 @@ use App\Models\Database; use App\Models\Egg; use App\Models\EggVariable; +use App\Models\Mount; use App\Models\Node; use App\Models\Schedule; use App\Models\Server; @@ -77,6 +78,7 @@ public function boot( 'ssh_key' => UserSSHKey::class, 'task' => Task::class, 'user' => User::class, + 'mount' => Mount::class, 'node' => Node::class, ]); diff --git a/app/Traits/Filament/LogsAdminActivity.php b/app/Traits/Filament/LogsAdminActivity.php new file mode 100644 index 0000000000..1fe5e17d2f --- /dev/null +++ b/app/Traits/Filament/LogsAdminActivity.php @@ -0,0 +1,107 @@ + + */ + protected static array $activityKeyOverrides = [ + 'WebhookConfiguration' => 'webhook', + ]; + + /** + * Attribute name patterns that are never written to the log in clear text. + * + * @var string[] + */ + protected static array $redactedAttributePatterns = [ + 'password', + '*password*', + '*_secret', + '*secret*', + '*token*', + 'api_key', + '*_key', + ]; + + /** + * Attributes used to identify a record in create/delete events. + * + * @var string[] + */ + protected static array $identifyingAttributes = ['id', 'uuid', 'name', 'username', 'email']; + + public static function activityKey(Model $record): string + { + $basename = class_basename($record); + + return static::$activityKeyOverrides[$basename] ?? Str::camel($basename); + } + + /** @param array $properties */ + public static function logAdminActivity(string $action, Model $record, array $properties = []): void + { + Activity::event(static::activityKey($record) . ':' . $action) + ->subject($record) + ->property(array_merge(static::identify($record), $properties)) + ->log(); + } + + /** + * Identifying attributes only; the subject link carries the rest. + * + * @return array + */ + public static function identify(Model $record): array + { + return array_intersect_key($record->getAttributes(), array_flip(static::$identifyingAttributes)); + } + + /** + * Old-to-new pairs for every changed attribute, secrets masked. + * + * @param array $old + * @param array $new + * @return array + */ + public static function buildDiff(array $old, array $new): array + { + $diff = []; + + foreach ($new as $key => $value) { + if ($key === 'updated_at' || ($old[$key] ?? null) === $value) { + continue; + } + + $diff[$key] = [ + 'old' => static::redact($key, $old[$key] ?? null), + 'new' => static::redact($key, $value), + ]; + } + + return $diff; + } + + public static function redact(string $key, mixed $value): mixed + { + if ($value !== null && $value !== '' && Str::is(static::$redactedAttributePatterns, strtolower($key))) { + return '********'; + } + + return $value; + } +} diff --git a/lang/en/activity.php b/lang/en/activity.php index 4667ff5f8a..af32e73a95 100644 --- a/lang/en/activity.php +++ b/lang/en/activity.php @@ -37,6 +37,24 @@ 'create' => 'Enabled two-factor auth', 'delete' => 'Disabled two-factor auth', ], + 'create' => 'Created user :username', + 'update' => 'Updated user :username', + 'delete' => 'Deleted user :username', + ], + 'egg' => [ + 'create' => 'Created egg :name', + 'update' => 'Updated egg :name', + 'delete' => 'Deleted egg :name', + ], + 'mount' => [ + 'create' => 'Created mount :name', + 'update' => 'Updated mount :name', + 'delete' => 'Deleted mount :name', + ], + 'node' => [ + 'create' => 'Created node :name', + 'update' => 'Updated node :name', + 'delete' => 'Deleted node :name', ], 'server' => [ 'console' => [ @@ -124,5 +142,8 @@ 'update' => 'Updated the mounts for the server', ], 'crashed' => 'Server crashed', + 'create' => 'Created server :name', + 'update' => 'Updated server :name', + 'delete' => 'Deleted server :name', ], ]; diff --git a/tests/Filament/Admin/AdminActivityLoggingTest.php b/tests/Filament/Admin/AdminActivityLoggingTest.php new file mode 100644 index 0000000000..7abd603238 --- /dev/null +++ b/tests/Filament/Admin/AdminActivityLoggingTest.php @@ -0,0 +1,216 @@ +admin] = generateTestAccount(); + $this->admin->syncRoles(Role::getRootAdmin()); + $this->actingAs($this->admin); +}); +afterEach(fn () => Filament::setCurrentPanel(null)); + +function makeMount(): Mount +{ + return Mount::query()->create([ + 'uuid' => Str::uuid()->toString(), + 'name' => 'Audited Mount', + 'source' => '/mnt/audited', + 'target' => '/srv/audited', + 'read_only' => false, + 'user_mountable' => false, + ]); +} + +it('logs mount create with the admin as actor', function () { + livewire(CreateMount::class) + ->fillForm(['name' => 'Test Mount', 'source' => '/mnt/test', 'target' => '/srv/test']) + ->call('create') + ->assertHasNoFormErrors(); + + $mount = Mount::query()->where('name', 'Test Mount')->firstOrFail(); + $this->assertActivityFor('mount:create', $this->admin, $mount); +}); + +it('logs mount update with an old-to-new diff', function () { + $mount = makeMount(); + + livewire(EditMount::class, ['record' => $mount->getKey()]) + ->fillForm(['name' => 'Renamed Mount']) + ->call('save') + ->assertHasNoFormErrors(); + + $this->assertActivityFor('mount:update', $this->admin, $mount); + Event::assertDispatched(ActivityLogged::class, function (ActivityLogged $e) { + if (!$e->is('mount:update')) { + return false; + } + $changes = $e->model->properties['changes']; + + return $changes['name'] === ['old' => 'Audited Mount', 'new' => 'Renamed Mount']; + }); +}); + +it('logs nothing for a no-op save', function () { + $mount = makeMount(); + + livewire(EditMount::class, ['record' => $mount->getKey()]) + ->call('save') + ->assertHasNoFormErrors(); + + Event::assertNotDispatched(ActivityLogged::class, fn (ActivityLogged $e) => $e->is('mount:update')); +}); + +it('logs mount delete', function () { + $mount = makeMount(); + + livewire(EditMount::class, ['record' => $mount->getKey()]) + ->callAction(DeleteAction::class); + + $this->assertDatabaseMissing('mounts', ['id' => $mount->id]); + $this->assertActivityFor('mount:delete', $this->admin, $mount); +}); + +it('logs node create', function () { + livewire(CreateNode::class) + ->fillForm(['name' => 'audited-node', 'fqdn' => '192.168.1.100']) + ->call('create') + ->assertHasNoFormErrors(); + + $node = Node::query()->where('name', 'audited-node')->firstOrFail(); + $this->assertActivityFor('node:create', $this->admin, $node); +}); + +it('logs node update and masks daemon tokens in the diff', function () { + $node = Node::factory()->create(); + + livewire(EditNode::class, ['record' => $node->getKey()]) + ->fillForm(['name' => 'renamed-node']) + ->call('save') + ->assertHasNoFormErrors(); + + $this->assertActivityFor('node:update', $this->admin, $node); + Event::assertDispatched(ActivityLogged::class, function (ActivityLogged $e) { + if (!$e->is('node:update')) { + return false; + } + + return !str_contains(json_encode($e->model->properties), 'daemon_token_plain'); + }); +}); + +it('logs node delete', function () { + $node = Node::factory()->create(); + + livewire(EditNode::class, ['record' => $node->getKey()]) + ->callAction(DeleteAction::class); + + $this->assertActivityFor('node:delete', $this->admin, $node); +}); + +it('logs egg update', function () { + $egg = Egg::factory()->create(); + + livewire(EditEgg::class, ['record' => $egg->getKey()]) + ->fillForm(['name' => 'Renamed Egg']) + ->call('save') + ->assertHasNoFormErrors(); + + $this->assertActivityFor('egg:update', $this->admin, $egg); +}); + +it('logs egg delete', function () { + $egg = Egg::factory()->create(); + + livewire(EditEgg::class, ['record' => $egg->getKey()]) + ->callAction(DeleteAction::class); + + $this->assertActivityFor('egg:delete', $this->admin, $egg); +}); + +it('logs user create', function () { + livewire(CreateUser::class) + ->fillForm(['username' => 'auditeduser', 'email' => 'audited@example.com']) + ->call('create') + ->assertHasNoFormErrors(); + + $user = User::query()->where('username', 'auditeduser')->firstOrFail(); + $this->assertActivityFor('user:create', $this->admin, $user); +}); + +it('logs user update and masks the password in the diff', function () { + $user = User::factory()->create(); + + livewire(EditUser::class, ['record' => $user->getKey()]) + ->fillForm(['username' => 'renameduser', 'password' => 'NewPassword123!']) + ->call('save') + ->assertHasNoFormErrors(); + + $this->assertActivityFor('user:update', $this->admin, $user); + Event::assertDispatched(ActivityLogged::class, function (ActivityLogged $e) { + if (!$e->is('user:update')) { + return false; + } + $changes = $e->model->properties['changes']; + + return $changes['password']['new'] === '********' && !str_contains(json_encode($e->model->properties), 'NewPassword123!'); + }); +}); + +it('logs user delete', function () { + $user = User::factory()->create(); + + livewire(EditUser::class, ['record' => $user->getKey()]) + ->callAction(DeleteAction::class); + + $this->assertActivityFor('user:delete', $this->admin, $user); +}); + +it('logs server update', function () { + $server = Server::factory()->withNode()->create(); + + livewire(EditServer::class, ['record' => $server->getKey()]) + ->fillForm(['name' => 'renamed-server', 'allocation_limit' => 0, 'database_limit' => 0]) + ->call('save') + ->assertHasNoFormErrors(); + + $this->assertActivityFor('server:update', $this->admin, $server); +}); + +it('logs server delete', function () { + $daemon = Mockery::mock(DaemonServerRepository::class); + $daemon->shouldReceive('setServer')->andReturnSelf(); + $daemon->shouldReceive('getDetails')->andReturn(['state' => 'offline']); + $daemon->shouldReceive('delete'); + $this->swap(DaemonServerRepository::class, $daemon); + + $server = Server::factory()->withNode()->create(); + + livewire(EditServer::class, ['record' => $server->getKey()]) + ->callAction(TestAction::make('Delete')); + + $this->assertActivityFor('server:delete', $this->admin, $server); +}); diff --git a/tests/Unit/Traits/LogsAdminActivityTest.php b/tests/Unit/Traits/LogsAdminActivityTest.php new file mode 100644 index 0000000000..4fa849d2e3 --- /dev/null +++ b/tests/Unit/Traits/LogsAdminActivityTest.php @@ -0,0 +1,64 @@ +assertSame('node', self::activityKey(new Node())); + $this->assertSame('databaseHost', self::activityKey(new DatabaseHost())); + } + + public function test_activity_key_uses_overrides(): void + { + $this->assertSame('webhook', self::activityKey(new WebhookConfiguration())); + } + + public function test_build_diff_returns_old_new_pairs_for_changed_keys_only(): void + { + $diff = self::buildDiff( + ['name' => 'old-name', 'memory' => 1024, 'updated_at' => 'yesterday'], + ['name' => 'new-name', 'memory' => 1024, 'updated_at' => 'today'], + ); + + $this->assertSame(['name' => ['old' => 'old-name', 'new' => 'new-name']], $diff); + } + + public function test_build_diff_masks_secrets(): void + { + $diff = self::buildDiff( + ['password' => 'old-hash', 'daemon_token' => 'abc'], + ['password' => 'new-hash', 'daemon_token' => 'def'], + ); + + $this->assertSame('********', $diff['password']['old']); + $this->assertSame('********', $diff['password']['new']); + $this->assertSame('********', $diff['daemon_token']['new']); + } + + public function test_redact_masks_matching_keys_and_leaves_others(): void + { + $this->assertSame('********', self::redact('client_secret', 'value')); + $this->assertSame('********', self::redact('api_key', 'value')); + $this->assertSame('********', self::redact('totp_secret', 'value')); + $this->assertSame('plain', self::redact('name', 'plain')); + $this->assertNull(self::redact('password', null)); + } + + public function test_identify_returns_identifying_attributes_only(): void + { + $node = new Node(); + $node->setRawAttributes(['id' => 4, 'name' => 'node-a', 'fqdn' => 'node.example.com', 'daemon_token' => 'secret']); + + $this->assertSame(['id' => 4, 'name' => 'node-a'], self::identify($node)); + } +}