diff --git a/.env.example b/.env.example
index 27e98eb..fd1e9e1 100644
--- a/.env.example
+++ b/.env.example
@@ -1,12 +1,6 @@
-# ==============================================================================
-# FILAMENT ACTIONGUARD - ENTERPRISE ENVIRONMENT CONFIGURATION
-# ==============================================================================
-# Local-development example only. Do not copy it to production unchanged.
-# ==============================================================================
+# Filament ActionGuard local Workbench configuration.
+# Local development only; review every value before using it elsewhere.
-# ------------------------------------------------------------------------------
-# 1. APPLICATION & GENERAL SECURITY
-# ------------------------------------------------------------------------------
APP_NAME="Filament ActionGuard"
APP_ENV=local
APP_KEY=
@@ -15,133 +9,33 @@ APP_URL=http://localhost:8000
APP_TIMEZONE=UTC
APP_LOCALE=en
APP_FALLBACK_LOCALE=en
-APP_FAKER_LOCALE=en_US
-APP_MAINTENANCE_DRIVER=cache
-# ------------------------------------------------------------------------------
-# 2. LOGGING & AUDIT TRAIL
-# ------------------------------------------------------------------------------
LOG_CHANNEL=stack
-LOG_STACK=single
-LOG_DEPRECATIONS_CHANNEL=null
LOG_LEVEL=debug
-LOG_DAILY_DAYS=14
-# ------------------------------------------------------------------------------
-# 3. DATABASE CONFIGURATION (Multi-Engine Resilience)
-# ------------------------------------------------------------------------------
-# Default: SQLite for zero-latency local development & testbench
DB_CONNECTION=sqlite
DB_DATABASE="${APP_BASE_PATH:-.}/workbench/database.sqlite"
DB_FOREIGN_KEYS=true
-# Enterprise PostgreSQL Profile (Supports pgvector for Semantic Search)
-# DB_CONNECTION=pgsql
-# DB_HOST=127.0.0.1
-# DB_PORT=5432
-# DB_DATABASE=actionguard_enterprise
-# DB_USERNAME=actionguard_app
-# DB_PASSWORD=your_secure_password
-# DB_SSLMODE=prefer
-
-# Enterprise MySQL/MariaDB Profile (Strict Mode Compliant)
-# DB_CONNECTION=mysql
-# DB_HOST=127.0.0.1
-# DB_PORT=3306
-# DB_DATABASE=actionguard_enterprise
-# DB_USERNAME=actionguard_app
-# DB_PASSWORD=your_secure_password
-
-# ------------------------------------------------------------------------------
-# 4. SESSION & COOKIE SETTINGS
-# ------------------------------------------------------------------------------
SESSION_DRIVER=file
-SESSION_LIFETIME=120
-SESSION_ENCRYPT=true
-# Laravel 13 default: json serialization prevents PHP deserialization gadget chain attacks
-SESSION_SERIALIZATION=json
-SESSION_PATH=/
-SESSION_DOMAIN=null
-# Set true in every HTTPS deployment.
-SESSION_SECURE_COOKIE=false
-SESSION_HTTP_ONLY=true
-SESSION_SAME_SITE=lax
-
-# ------------------------------------------------------------------------------
-# 5. CACHING & APPLICATION ACCELERATION (Laravel 13 Standards)
-# ------------------------------------------------------------------------------
CACHE_STORE=file
-# Laravel 13 uses hyphenated prefix standard: {app-name}-cache-
-CACHE_PREFIX="actionguard-cache-"
-# Laravel 13 security: restricts arbitrary cached object unserialization
-CACHE_SERIALIZABLE_CLASSES=false
-
-# ------------------------------------------------------------------------------
-# 6. QUEUE & ASYNC TASK PROCESSING (Laravel 13 Queue Routing)
-# ------------------------------------------------------------------------------
QUEUE_CONNECTION=sync
-QUEUE_FAILED_DRIVER=database-uuids
-
-# ------------------------------------------------------------------------------
-# 7. REDIS CONFIGURATION (Clustering & TLS Support)
-# ------------------------------------------------------------------------------
-REDIS_CLIENT=phpredis
-REDIS_SCHEME=tcp
-REDIS_HOST=127.0.0.1
-REDIS_PASSWORD=null
-REDIS_PORT=6379
-REDIS_DB=0
-REDIS_CACHE_DB=1
-REDIS_PREFIX="actionguard-database-"
-# ------------------------------------------------------------------------------
-# 8. FILAMENT 5 PANEL CONFIGURATION
-# ------------------------------------------------------------------------------
-FILAMENT_FILESYSTEM_DISK=public
-FILAMENT_SERVE_ASSETS_FROM_VITE=false
-FILAMENT_BROADCASTING_ENABLED=false
-
-# ------------------------------------------------------------------------------
-# 9. MAIL & NOTIFICATION DELIVERY
-# ------------------------------------------------------------------------------
-MAIL_MAILER=log
-MAIL_SCHEME=null
-MAIL_HOST=127.0.0.1
-MAIL_PORT=1025
-MAIL_USERNAME=null
-MAIL_PASSWORD=null
-MAIL_ENCRYPTION=null
-MAIL_FROM_ADDRESS="no-reply@actionguard.example.com"
-MAIL_FROM_NAME="${APP_NAME}"
-
-# ------------------------------------------------------------------------------
-# 10. OBJECT STORAGE (AWS S3 / S3-Compatible Storage)
-# ------------------------------------------------------------------------------
-FILESYSTEM_DISK=local
-AWS_ACCESS_KEY_ID=
-AWS_SECRET_ACCESS_KEY=
-AWS_DEFAULT_REGION=eu-central-1
-AWS_BUCKET=
-AWS_USE_PATH_STYLE_ENDPOINT=false
-
-# ------------------------------------------------------------------------------
-# 11. ACTIONGUARD POLICY SETTINGS
-# ------------------------------------------------------------------------------
-# Enable or disable ActionGuard evaluation globally. Keep true in production.
+# Keep ActionGuard and fail-closed behavior enabled in production.
ACTIONGUARD_ENABLED=true
-
-# Security posture: Fail-closed (true) blocks actions if a check throws an unhandled exception
ACTIONGUARD_FAIL_CLOSED=true
-# Disabled by default. Enable only for a controlled, logged maintenance task.
+# Enable only for a controlled and documented maintenance callback.
ACTIONGUARD_ALLOW_BYPASS=false
-# Dispatch user-facing Filament danger notifications on state invariant violations
ACTIONGUARD_NOTIFICATIONS_ENABLED=true
-# Enable data-minimised structured audit events; route the channel to your SIEM.
-ACTIONGUARD_AUDIT_TRAIL=true
+# Audit output is opt-in and data-minimised. Configure a suitable channel when enabled.
+# Enable model type or state only for a documented and necessary audit purpose.
+ACTIONGUARD_AUDIT_TRAIL=false
ACTIONGUARD_AUDIT_CHANNEL=stack
+ACTIONGUARD_AUDIT_INCLUDE_MODEL_TYPE=false
+ACTIONGUARD_AUDIT_INCLUDE_STATE=false
-# Relative and HTTPS resolution links are allowed. HTTP is blocked by default.
+# Relative paths and HTTPS are allowed. HTTP requires this explicit opt-in.
ACTIONGUARD_ALLOW_INSECURE_RESOLUTION_URLS=false
diff --git a/CHANGELOG.md b/CHANGELOG.md
index e415322..306dfb6 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -5,6 +5,44 @@ All notable changes to `filament-actionguard` will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
+## [Unreleased]
+
+## [1.3.0] - 2026-09-14
+
+### Added
+
+- Publishable runtime configuration for enablement, fail-closed behavior,
+ notifications, guarded bypasses, audit logging, and resolution URL policy.
+- Data-minimised structured audit events for action evaluation, invariant
+ blocking, and explicitly enabled bypass usage.
+- Independent, disabled-by-default audit opt-ins for model types and state values;
+ unknown context fields are removed by a central event allow-list.
+- Locked and prefer-lowest dependency verification in CI.
+- Privacy responsibility and AI-generated artwork disclosures.
+- Central resolution URL sanitization shared by Community and Enterprise checks.
+- Filament Livewire integration coverage for table actions, page header actions,
+ modal halting, model validation errors, and notification configuration.
+
+### Changed
+
+- Composer now resolves stable dependencies by default and locks against the
+ minimum supported PHP 8.3 platform.
+- ActionGuard bypasses are disabled by default and scoped to their callback.
+- Resolution links allow HTTPS and relative URLs by default; HTTP requires an
+ explicit compatibility opt-in.
+- Security policy now documents supported versions and response expectations.
+- The local environment example now contains only Workbench and ActionGuard
+ settings and keeps audit logging disabled by default.
+
+### Security
+
+- Check exceptions are reported server-side with correlation IDs instead of
+ exposing internal exception messages in the Filament interface.
+- Explicit checks are validated against `ActionGuardCheckContract`, and rich
+ action labels are stripped of HTML before use as modal headings.
+- Direct `CheckResolution` URLs now use the same scheme allow-list and malformed
+ URL rejection as Enterprise resolutions.
+
## [1.0.0] - 2026-09-11
### Added
@@ -20,7 +58,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- `RequiredFieldCheck`: Validates presence and non-null status of critical model attributes.
- `NotEmptyCheck`: Validates strings, arrays, and collections with precise numeric zero (`0`, `'0'`, `0.00`) preservation.
- `ConditionCheck`: Custom boolean and closure-based rule evaluations with contextual failure messages.
- - `RelationshipCheck`: Ensures required relationships exist and meet minimum count constraints.
+ - `RelationshipCheck`: Ensures required relationships resolve to a model or non-empty collection.
- `MediaCheck`: Verifies attachments via Spatie MediaLibrary collections or standard URL/path attributes.
- `CallbackCheck`: Flexible closure-driven check returning custom `CheckResult` instances with explicit severity and descriptions.
@@ -55,6 +93,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- **Developer Experience & Tooling**
- Root `artisan` CLI bridge to Orchestra Testbench and Workbench environment.
- - Comprehensive test suite with 103 Pest tests and 234 assertions covering architecture, unit checks, traits, enterprise bridge hardening, and demo product scenarios.
+ - Comprehensive test suite with 133 Pest tests and 315 assertions covering architecture, unit checks, traits, enterprise bridge hardening, Livewire integration, security, and demo product scenarios.
[1.0.0]: https://github.com/allgorithm/filament-actionguard/releases/tag/v1.0.0
+[1.3.0]: https://github.com/allgorithm/filament-actionguard/releases/tag/v1.3.0
+[Unreleased]: https://github.com/allgorithm/filament-actionguard/compare/v1.3.0...HEAD
diff --git a/README.md b/README.md
index affc0a2..8d2d904 100644
--- a/README.md
+++ b/README.md
@@ -6,7 +6,7 @@
-
+
@@ -62,7 +62,13 @@ ActionGuard introduces a robust **Two-Phase Invariant Defense System**:
- `MediaCheck`: Checks media collections (Spatie MediaLibrary or file upload paths).
- `CallbackCheck`: Flexible check with full `CheckResult` control.
- ๐ **Fully Localized (i18n):** Complete English and German translations included out-of-the-box.
-- ๐ **Bulletproof Quality:** PHPStan **Level 8** (0 errors), 100% PSR-12 code style, and 103 comprehensive Pest tests.
+- ๐ **Bulletproof Quality:** PHPStan **Level 8** (0 errors), 100% PSR-12 code style, and 133 comprehensive Pest tests.
+
+## Compatibility
+
+| ActionGuard | PHP | Laravel | Filament |
+| :--- | :--- | :--- | :--- |
+| `1.x` | `8.3โ8.5` | `11.28โ13.x` (through Filament) | `5.x` |
---
@@ -207,15 +213,23 @@ ActionGuardAction::make('publish')
| `RequiredFieldCheck` | Attribute is not `null` | `RequiredFieldCheck::make('title')` |
| `NotEmptyCheck` | Value is not empty string/whitespace/empty array/empty collection (safely preserves `0`, `'0'`, `0.00`) | `NotEmptyCheck::make('price')` |
| `ConditionCheck` | Boolean callback closure | `ConditionCheck::make('min_stock', fn ($record) => $record->stock > 0, 'Stock must be greater than zero')` |
-| `RelationshipCheck` | Eloquent relationship exists and is loaded | `RelationshipCheck::make('category')` |
+| `RelationshipCheck` | Eloquent relationship resolves to a model or non-empty collection; it may lazy-load the relation | `RelationshipCheck::make('category')` |
| `MediaCheck` | Spatie MediaLibrary or array of image URLs | `MediaCheck::make('image_url')` |
| `CallbackCheck` | Evaluates custom logic returning `CheckResult` | `CallbackCheck::make('vat_id', fn ($record) => ...)` |
---
-## โ๏ธ Fluent Customization & Zero-Config
+## โ๏ธ Secure defaults & optional configuration
+
+ActionGuard works with secure defaults without publishing a configuration file.
+To review or override its operational safeguards, publish the package config:
+
+```bash
+php artisan vendor:publish --tag="filament-actionguard-config"
+```
-ActionGuard is designed as **Zero-Configuration by Default**. It requires no separate configuration file. You can customize the modal appearance, heading, and width directly on the action using Filament's fluent API:
+You can customize the modal appearance, heading, and width directly on the
+action using Filament's fluent API:
```php
ActionGuardAction::make('publish')
@@ -224,6 +238,28 @@ ActionGuardAction::make('publish')
->checks([...]);
```
+The operational defaults and environment variables are:
+
+| Setting | Environment variable | Default |
+| :--- | :--- | :---: |
+| `enabled` | `ACTIONGUARD_ENABLED` | `true` |
+| `fail_closed` | `ACTIONGUARD_FAIL_CLOSED` | `true` |
+| `allow_bypass` | `ACTIONGUARD_ALLOW_BYPASS` | `false` |
+| `notifications` | `ACTIONGUARD_NOTIFICATIONS_ENABLED` | `true` |
+| `audit.enabled` | `ACTIONGUARD_AUDIT_TRAIL` | `false` |
+| `audit.channel` | `ACTIONGUARD_AUDIT_CHANNEL` | `null` |
+| `audit.include_model_type` | `ACTIONGUARD_AUDIT_INCLUDE_MODEL_TYPE` | `false` |
+| `audit.include_state` | `ACTIONGUARD_AUDIT_INCLUDE_STATE` | `false` |
+| `allow_insecure_resolution_urls` | `ACTIONGUARD_ALLOW_INSECURE_RESOLUTION_URLS` | `false` |
+
+Resolution links accept local absolute paths and HTTPS URLs. HTTP is available
+only through its explicit compatibility switch; executable, protocol-relative,
+malformed, and control-character URLs are discarded.
+
+ActionGuard evaluates checks when the modal is rendered, when confirmation is
+prepared, and immediately before execution. Checks must therefore be
+side-effect-free and safe to run repeatedly. The final evaluation is decisive.
+
---
## ๐งช Testing & Quality Assurance
@@ -231,7 +267,7 @@ ActionGuardAction::make('publish')
ActionGuard is built with strict quality standards:
```bash
-# Run automated test suite (103 tests, 234 assertions)
+# Run automated test suite (133 tests, 315 assertions)
composer test
# Run code style fixer and static analysis (PHPStan Level 8)
@@ -255,7 +291,9 @@ the optional data-minimised audit channel to your central logging system.
Filament ActionGuard was designed with data minimisation in mind. The package
does not transmit record data to external services and its optional audit events
-exclude model attributes, record IDs, actor IDs, and check messages by default.
+exclude model types, state values, record IDs, actor IDs, check messages, and
+arbitrary model attributes by default. Model type and state can be enabled
+independently when an application's documented audit purpose requires them.
Whether a deployment complies with the GDPR / DSGVO depends on the operator's
specific processing purposes, legal basis, access controls, logging and
diff --git a/composer.json b/composer.json
index 31b5ad3..b1298be 100644
--- a/composer.json
+++ b/composer.json
@@ -73,6 +73,32 @@
"suggest": {
"allgorithm/business-core": "Required only for the licensed Enterprise Bridge (^1.2); Community checks work without it."
},
+ "archive": {
+ "exclude": [
+ "/.ai",
+ "/.github",
+ "/art",
+ "/artisan",
+ "/build",
+ "/docker",
+ "/docker-compose.yml",
+ "/docs",
+ "/public",
+ "/requirements",
+ "/storage",
+ "/tests",
+ "/vendor",
+ "/workbench",
+ "/.DS_Store",
+ "/.env",
+ "/.env.example",
+ "/.gitattributes",
+ "/.gitignore",
+ "/phpstan.neon",
+ "/phpunit.xml.dist",
+ "/testbench.yaml"
+ ]
+ },
"scripts": {
"post-autoload-dump": [
"@clear",
diff --git a/config/filament-actionguard.php b/config/filament-actionguard.php
index 26541fa..b31282a 100644
--- a/config/filament-actionguard.php
+++ b/config/filament-actionguard.php
@@ -18,12 +18,15 @@
'notifications' => (bool) env('ACTIONGUARD_NOTIFICATIONS_ENABLED', true),
/*
- * The package never logs model attributes, messages, record IDs, or actor IDs.
- * Applications may route this channel to their central audit system.
+ * Model types and state values are excluded by default and require separate,
+ * explicit opt-ins. Messages, record IDs, actor IDs, and arbitrary model
+ * attributes are never part of the package's audit context.
*/
'audit' => [
'enabled' => (bool) env('ACTIONGUARD_AUDIT_TRAIL', false),
'channel' => env('ACTIONGUARD_AUDIT_CHANNEL'),
+ 'include_model_type' => (bool) env('ACTIONGUARD_AUDIT_INCLUDE_MODEL_TYPE', false),
+ 'include_state' => (bool) env('ACTIONGUARD_AUDIT_INCLUDE_STATE', false),
],
/* Relative URLs and HTTPS are allowed; HTTP requires an explicit opt-in. */
diff --git a/src/Actions/ActionGuardAction.php b/src/Actions/ActionGuardAction.php
index 73686dd..2a7d21e 100644
--- a/src/Actions/ActionGuardAction.php
+++ b/src/Actions/ActionGuardAction.php
@@ -31,9 +31,12 @@ protected function setUp(): void
$this->modalDescription(null);
$this->modalWidth('lg');
- $rawLabel = $this->getLabel();
- $label = $rawLabel instanceof Htmlable ? strip_tags($rawLabel->toHtml()) : (string) ($rawLabel ?? '');
- $this->modalHeading(__('filament-actionguard::ui.modal.heading', ['label' => $label]));
+ $this->modalHeading(function (): string {
+ $rawLabel = $this->getLabel();
+ $label = $rawLabel instanceof Htmlable ? strip_tags($rawLabel->toHtml()) : (string) ($rawLabel ?? '');
+
+ return __('filament-actionguard::ui.modal.heading', ['label' => $label]);
+ });
$this->modalContent(function (ActionGuardAction $action, ?Model $record) {
return $action->evaluateAndRender($record);
});
diff --git a/src/Bridge/BusinessCoreGuardAdapter.php b/src/Bridge/BusinessCoreGuardAdapter.php
index 98f12e6..a8ccbf6 100644
--- a/src/Bridge/BusinessCoreGuardAdapter.php
+++ b/src/Bridge/BusinessCoreGuardAdapter.php
@@ -9,6 +9,7 @@
use Allgorithm\FilamentActionGuard\Results\CheckResolution;
use Allgorithm\FilamentActionGuard\Results\CheckResult;
use Allgorithm\FilamentActionGuard\Results\CheckStatus;
+use Allgorithm\FilamentActionGuard\Support\ResolutionUrlSanitizer;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Support\Str;
@@ -106,7 +107,7 @@ protected function mapCoreResult(mixed $coreResult): CheckResult
$resolution = $coreResult->resolution;
} elseif (is_object($coreResult->resolution) && property_exists($coreResult->resolution, 'label')) {
$action = property_exists($coreResult->resolution, 'action')
- ? $this->sanitizeResolutionUrl($coreResult->resolution->action)
+ ? ResolutionUrlSanitizer::sanitize($coreResult->resolution->action)
: null;
$resolution = new CheckResolution(
label: (string) $coreResult->resolution->label,
@@ -137,29 +138,4 @@ protected function makeOperationContext(Model $record, string $correlationId): o
$correlationId,
);
}
-
- protected function sanitizeResolutionUrl(mixed $url): ?string
- {
- if (! is_string($url)) {
- return null;
- }
-
- $url = trim($url);
- if ($url === '' || preg_match('/[\x00-\x20\x7F\\\\]/', $url) === 1 || str_starts_with($url, '//')) {
- return null;
- }
-
- if (str_starts_with($url, '/')) {
- return $url;
- }
-
- $scheme = parse_url($url, PHP_URL_SCHEME);
-
- $allowedSchemes = ['https'];
- if (config('filament-actionguard.allow_insecure_resolution_urls', false)) {
- $allowedSchemes[] = 'http';
- }
-
- return in_array(strtolower((string) $scheme), $allowedSchemes, true) ? $url : null;
- }
}
diff --git a/src/Results/CheckResolution.php b/src/Results/CheckResolution.php
index ec81cf1..b9d7cda 100644
--- a/src/Results/CheckResolution.php
+++ b/src/Results/CheckResolution.php
@@ -4,10 +4,19 @@
namespace Allgorithm\FilamentActionGuard\Results;
+use Allgorithm\FilamentActionGuard\Support\ResolutionUrlSanitizer;
+
final readonly class CheckResolution
{
+ public string $label;
+
+ public ?string $url;
+
public function __construct(
- public string $label,
- public ?string $url = null,
- ) {}
+ string $label,
+ ?string $url = null,
+ ) {
+ $this->label = $label;
+ $this->url = ResolutionUrlSanitizer::sanitize($url);
+ }
}
diff --git a/src/Support/ActionGuardAudit.php b/src/Support/ActionGuardAudit.php
index 56c8142..3a550b9 100644
--- a/src/Support/ActionGuardAudit.php
+++ b/src/Support/ActionGuardAudit.php
@@ -21,6 +21,30 @@ public static function record(string $event, array $context = []): void
}
Log::channel(config('filament-actionguard.audit.channel'))
- ->notice('filament-actionguard.'.$event, $context);
+ ->notice('filament-actionguard.'.$event, self::filterContext($event, $context));
+ }
+
+ /**
+ * @param array $context
+ * @return array
+ */
+ private static function filterContext(string $event, array $context): array
+ {
+ $allowedKeys = match ($event) {
+ 'action_evaluated' => ['passed', 'failed', 'errors'],
+ 'invariant_blocked' => ['failed', 'errors'],
+ default => [],
+ };
+
+ if (config('filament-actionguard.audit.include_model_type', false)
+ && in_array($event, ['invariant_blocked', 'bypass_used'], true)) {
+ $allowedKeys[] = 'model';
+ }
+
+ if (config('filament-actionguard.audit.include_state', false) && $event === 'invariant_blocked') {
+ $allowedKeys[] = 'state';
+ }
+
+ return array_intersect_key($context, array_flip($allowedKeys));
}
}
diff --git a/src/Support/ResolutionUrlSanitizer.php b/src/Support/ResolutionUrlSanitizer.php
new file mode 100644
index 0000000..019d5e5
--- /dev/null
+++ b/src/Support/ResolutionUrlSanitizer.php
@@ -0,0 +1,33 @@
+and($result->checks[0]->message)->toContain('Reference:');
});
+it('allows an action to continue after a technical error only with explicit fail-open configuration', function () {
+ config()->set('filament-actionguard.fail_closed', false);
+ $executed = false;
+ $record = new class extends Model {};
+ $action = ActionGuardAction::make('publish')
+ ->checks([
+ new class implements ActionGuardCheckContract
+ {
+ public function evaluate(Model $record): CheckResult
+ {
+ throw new RuntimeException('technical outage');
+ }
+ },
+ ])
+ ->action(function () use (&$executed): void {
+ $executed = true;
+ })
+ ->record($record);
+
+ $action->callBefore();
+ $action->call(['record' => $record]);
+
+ expect($executed)->toBeTrue();
+});
+
it('renders preflight modal view with correct result payload', function () {
$action = ActionGuardAction::make('publish')
->checks([
@@ -181,3 +207,47 @@ public function evaluate(Model $record): CheckResult
expect($data['passed'])->toBeTrue()
->and($data['result']->summary['passed'])->toBe(1);
});
+
+it('rejects invalid explicit checks before evaluation', function (mixed $invalidCheck) {
+ expect(fn () => ActionGuardAction::make('publish')->checks([$invalidCheck]))
+ ->toThrow(InvalidArgumentException::class);
+})->with([
+ 'object' => fn () => new stdClass,
+ 'existing incompatible class string' => stdClass::class,
+ 'unknown class string' => 'Tests\\MissingActionGuardCheck',
+ 'scalar' => 42,
+]);
+
+it('strips HTML from a late-bound Htmlable action label in the modal heading', function () {
+ $action = ActionGuardAction::make('publish')
+ ->label(new HtmlString('Publish safely'));
+
+ expect($action->getModalHeading())
+ ->toBe('Publish safelyalert(1)')
+ ->not->toContain('')
+ ->not->toContain('