## Summary
Implements Spec 145 for tenant action taxonomy and lifecycle-safe visibility.
This PR:
- adds a central tenant action policy surface and supporting value objects
- aligns tenant list, detail, edit, onboarding, and widget surfaces around lifecycle-safe actions
- standardizes operator-facing lifecycle wording around View, Resume onboarding, Archive, Restore, and Complete onboarding
- tightens onboarding and tenant lifecycle authorization semantics, including honest 404 vs 403 behavior
- updates related regression coverage and spec artifacts for Spec 145
- fixes follow-on full-suite regressions uncovered during validation, including onboarding browser flows, provider consent fixtures, workspace redirect DI expectations, and critical table/action/UI expectation drift
## Validation
Executed and passed:
- vendor/bin/sail bin pint --dirty --format agent
- vendor/bin/sail artisan test --compact
Result:
- 2581 passed
- 8 skipped
- 13534 assertions
## Notes
- Base branch: dev
- Feature branch commit: a33a41b
- Filament v5 / Livewire v4 compliance preserved
- No panel provider registration changes; Laravel 12 provider registration remains in bootstrap/providers.php
- No new globally searchable resource behavior added in this slice
- Destructive lifecycle actions remain confirmation-gated and authorization-protected
Co-authored-by: Ahmed Darrazi <ahmed.darrazi@live.de>
Reviewed-on: #174
554 lines
18 KiB
PHP
554 lines
18 KiB
PHP
<?php
|
|
|
|
use App\Models\ProviderConnection;
|
|
use App\Models\ProviderCredential;
|
|
use App\Models\Tenant;
|
|
use App\Models\TenantOnboardingSession;
|
|
use App\Models\User;
|
|
use App\Models\Workspace;
|
|
use App\Models\WorkspaceMembership;
|
|
use App\Services\Graph\GraphClientInterface;
|
|
use App\Services\Tenants\TenantActionPolicySurface;
|
|
use App\Support\Providers\ProviderConnectionType;
|
|
use App\Support\Providers\ProviderConsentStatus;
|
|
use App\Support\Providers\ProviderCredentialKind;
|
|
use App\Support\Providers\ProviderCredentialSource;
|
|
use App\Support\Providers\ProviderVerificationStatus;
|
|
use App\Support\Tenants\TenantActionDescriptor;
|
|
use App\Support\Tenants\TenantActionSurface;
|
|
use App\Support\Workspaces\WorkspaceContext;
|
|
use Illuminate\Foundation\Testing\RefreshDatabase;
|
|
use Pest\PendingCalls\TestCall;
|
|
use Tests\Support\AssertsNoOutboundHttp;
|
|
use Tests\Support\FailHardGraphClient;
|
|
|
|
/*
|
|
|--------------------------------------------------------------------------
|
|
| Test Case
|
|
|--------------------------------------------------------------------------
|
|
|
|
|
| The closure you provide to your test functions is always bound to a specific PHPUnit test
|
|
| case class. By default, that class is "PHPUnit\Framework\TestCase". Of course, you may
|
|
| need to change it using the "pest()" function to bind a different classes or traits.
|
|
|
|
|
*/
|
|
|
|
pest()->extend(Tests\TestCase::class)
|
|
->use(RefreshDatabase::class)
|
|
->in('Feature');
|
|
|
|
pest()->extend(Tests\TestCase::class)
|
|
->use(RefreshDatabase::class)
|
|
->in('Browser');
|
|
|
|
pest()->extend(Tests\TestCase::class)
|
|
->in('Unit');
|
|
|
|
pest()->extend(Tests\TestCase::class)
|
|
->in('Deprecation');
|
|
|
|
beforeEach(function () {
|
|
putenv('INTUNE_TENANT_ID');
|
|
unset($_ENV['INTUNE_TENANT_ID'], $_SERVER['INTUNE_TENANT_ID']);
|
|
});
|
|
|
|
/*
|
|
|--------------------------------------------------------------------------
|
|
| Expectations
|
|
|--------------------------------------------------------------------------
|
|
|
|
|
| When you're writing tests, you often need to check that values meet certain conditions. The
|
|
| "expect()" function gives you access to a set of "expectations" methods that you can use
|
|
| to assert different things. Of course, you may extend the Expectation API at any time.
|
|
|
|
|
*/
|
|
|
|
expect()->extend('toBeOne', function () {
|
|
return $this->toBe(1);
|
|
});
|
|
|
|
function fakeIdToken(string $tenantId): string
|
|
{
|
|
$header = rtrim(strtr(base64_encode(json_encode(['alg' => 'HS256', 'typ' => 'JWT'])), '+/', '-_'), '=');
|
|
$payload = rtrim(strtr(base64_encode(json_encode(['tid' => $tenantId])), '+/', '-_'), '=');
|
|
|
|
return $header.'.'.$payload.'.signature';
|
|
}
|
|
|
|
/*
|
|
|--------------------------------------------------------------------------
|
|
| Functions
|
|
|--------------------------------------------------------------------------
|
|
|
|
|
| While Pest is very powerful out-of-the-box, you may have some testing code specific to your
|
|
| project that you don't want to repeat in every file. Here you can also expose helpers as
|
|
| global functions to help you to reduce the number of lines of code in your test files.
|
|
|
|
|
*/
|
|
|
|
function something()
|
|
{
|
|
// ..
|
|
}
|
|
|
|
/**
|
|
* Spec test naming helper.
|
|
*
|
|
* Convention for focused runs:
|
|
* - Prefix every Spec 081 test title with "Spec081 ".
|
|
* - Keep filenames suffixed with "Spec081Test.php".
|
|
* - Command: `vendor/bin/sail artisan test --compact --filter=Spec081`
|
|
*/
|
|
function spec081(string $description): string
|
|
{
|
|
$normalized = trim($description);
|
|
|
|
if ($normalized === '') {
|
|
return 'Spec081';
|
|
}
|
|
|
|
return str_starts_with($normalized, 'Spec081 ')
|
|
? $normalized
|
|
: 'Spec081 '.$normalized;
|
|
}
|
|
|
|
/**
|
|
* Convenience wrapper for Spec 081 tests.
|
|
*/
|
|
function itSpec081(string $description, ?\Closure $closure = null): TestCall
|
|
{
|
|
$call = it(spec081($description), $closure);
|
|
$call->group('spec081');
|
|
|
|
return $call;
|
|
}
|
|
|
|
function bindFailHardGraphClient(): void
|
|
{
|
|
app()->instance(GraphClientInterface::class, new FailHardGraphClient);
|
|
}
|
|
|
|
function assertNoOutboundHttp(\Closure $callback): mixed
|
|
{
|
|
return AssertsNoOutboundHttp::run($callback);
|
|
}
|
|
|
|
/**
|
|
* @param array<string, mixed> $attributes
|
|
*/
|
|
function createInventorySyncOperationRun(Tenant $tenant, array $attributes = []): \App\Models\OperationRun
|
|
{
|
|
$context = is_array($attributes['context'] ?? null) ? $attributes['context'] : [];
|
|
|
|
if (array_key_exists('selection_hash', $attributes)) {
|
|
if (is_string($attributes['selection_hash']) && $attributes['selection_hash'] !== '') {
|
|
$context['selection_hash'] = $attributes['selection_hash'];
|
|
}
|
|
|
|
unset($attributes['selection_hash']);
|
|
}
|
|
|
|
if (array_key_exists('selection_payload', $attributes)) {
|
|
if (is_array($attributes['selection_payload'])) {
|
|
$context = array_merge($context, $attributes['selection_payload']);
|
|
}
|
|
|
|
unset($attributes['selection_payload']);
|
|
}
|
|
|
|
if (! isset($context['selection_hash']) || ! is_string($context['selection_hash']) || $context['selection_hash'] === '') {
|
|
$context['selection_hash'] = hash('sha256', 'inventory-sync-selection-default');
|
|
}
|
|
|
|
if (! isset($context['policy_types']) || ! is_array($context['policy_types'])) {
|
|
$context['policy_types'] = ['deviceConfiguration'];
|
|
}
|
|
|
|
if (! isset($context['categories']) || ! is_array($context['categories'])) {
|
|
$context['categories'] = [];
|
|
}
|
|
|
|
if (! array_key_exists('include_foundations', $context)) {
|
|
$context['include_foundations'] = false;
|
|
}
|
|
|
|
if (! array_key_exists('include_dependencies', $context)) {
|
|
$context['include_dependencies'] = false;
|
|
}
|
|
|
|
$finishedAt = $attributes['finished_at'] ?? null;
|
|
unset($attributes['finished_at']);
|
|
|
|
$providedStatus = (string) ($attributes['status'] ?? 'success');
|
|
$normalizedStatus = match ($providedStatus) {
|
|
'pending', 'queued' => 'queued',
|
|
'running' => 'running',
|
|
'completed' => 'completed',
|
|
default => 'completed',
|
|
};
|
|
|
|
$normalizedOutcome = match ($providedStatus) {
|
|
'success' => 'succeeded',
|
|
'partial' => 'partially_succeeded',
|
|
'skipped' => 'blocked',
|
|
'failed' => 'failed',
|
|
'pending', 'queued', 'running' => 'pending',
|
|
default => $normalizedStatus === 'completed' ? 'succeeded' : 'pending',
|
|
};
|
|
|
|
$attributes['type'] = (string) ($attributes['type'] ?? 'inventory_sync');
|
|
$attributes['workspace_id'] = (int) ($attributes['workspace_id'] ?? $tenant->workspace_id);
|
|
$attributes['status'] = in_array($providedStatus, ['queued', 'running', 'completed'], true)
|
|
? $providedStatus
|
|
: $normalizedStatus;
|
|
$attributes['outcome'] = (string) ($attributes['outcome'] ?? $normalizedOutcome);
|
|
$attributes['context'] = array_merge($context, is_array($attributes['context'] ?? null) ? $attributes['context'] : []);
|
|
|
|
if ($finishedAt !== null && ! array_key_exists('completed_at', $attributes)) {
|
|
$attributes['completed_at'] = $finishedAt;
|
|
}
|
|
|
|
return \App\Models\OperationRun::factory()
|
|
->for($tenant)
|
|
->create($attributes);
|
|
}
|
|
|
|
/**
|
|
* Create a completed inventory sync run with coverage proof in context.
|
|
*
|
|
* @param array<string, string> $statusByType Example: ['deviceConfiguration' => 'succeeded']
|
|
* @param list<string> $foundationTypes
|
|
* @param array<string, mixed> $attributes
|
|
*/
|
|
function createInventorySyncOperationRunWithCoverage(
|
|
Tenant $tenant,
|
|
array $statusByType,
|
|
array $foundationTypes = [],
|
|
array $attributes = [],
|
|
): \App\Models\OperationRun {
|
|
$context = is_array($attributes['context'] ?? null) ? $attributes['context'] : [];
|
|
$inventory = is_array($context['inventory'] ?? null) ? $context['inventory'] : [];
|
|
|
|
$inventory['coverage'] = \App\Support\Inventory\InventoryCoverage::buildPayload(
|
|
statusByType: $statusByType,
|
|
foundationTypes: $foundationTypes,
|
|
);
|
|
|
|
$context['inventory'] = $inventory;
|
|
$attributes['context'] = $context;
|
|
|
|
if (! array_key_exists('finished_at', $attributes) && ! array_key_exists('completed_at', $attributes)) {
|
|
$attributes['finished_at'] = now();
|
|
}
|
|
|
|
$attributes['type'] ??= \App\Support\OperationRunType::InventorySync->value;
|
|
$attributes['status'] ??= \App\Support\OperationRunStatus::Completed->value;
|
|
$attributes['outcome'] ??= \App\Support\OperationRunOutcome::Succeeded->value;
|
|
|
|
return createInventorySyncOperationRun($tenant, $attributes);
|
|
}
|
|
|
|
/**
|
|
* @return array{0: User, 1: Tenant}
|
|
*/
|
|
function createUserWithTenant(
|
|
?Tenant $tenant = null,
|
|
?User $user = null,
|
|
string $role = 'owner',
|
|
?string $workspaceRole = null,
|
|
bool $ensureDefaultMicrosoftProviderConnection = true,
|
|
string $defaultProviderConnectionType = ProviderConnectionType::Dedicated->value,
|
|
): array {
|
|
$user ??= User::factory()->create();
|
|
$tenant ??= Tenant::factory()->create();
|
|
|
|
$workspaceRole ??= $role;
|
|
|
|
$validWorkspaceRoles = array_map(
|
|
static fn (\App\Support\Auth\WorkspaceRole $role): string => $role->value,
|
|
\App\Support\Auth\WorkspaceRole::cases(),
|
|
);
|
|
|
|
if (! in_array($workspaceRole, $validWorkspaceRoles, true)) {
|
|
$workspaceRole = \App\Support\Auth\WorkspaceRole::Owner->value;
|
|
}
|
|
|
|
$workspace = null;
|
|
|
|
if ($tenant->workspace_id !== null) {
|
|
$workspace = Workspace::query()->whereKey($tenant->workspace_id)->first();
|
|
}
|
|
|
|
if (! $workspace instanceof Workspace) {
|
|
$workspace = Workspace::factory()->create();
|
|
|
|
$tenant->forceFill([
|
|
'workspace_id' => (int) $workspace->getKey(),
|
|
])->save();
|
|
}
|
|
|
|
WorkspaceMembership::query()->updateOrCreate([
|
|
'workspace_id' => (int) $workspace->getKey(),
|
|
'user_id' => (int) $user->getKey(),
|
|
], [
|
|
'role' => $workspaceRole,
|
|
]);
|
|
|
|
session()->put(WorkspaceContext::SESSION_KEY, (int) $workspace->getKey());
|
|
$user->forceFill(['last_workspace_id' => (int) $workspace->getKey()])->save();
|
|
|
|
$user->tenants()->syncWithoutDetaching([
|
|
$tenant->getKey() => ['role' => $role],
|
|
]);
|
|
|
|
if ($ensureDefaultMicrosoftProviderConnection) {
|
|
ensureDefaultProviderConnection($tenant, 'microsoft', $defaultProviderConnectionType);
|
|
}
|
|
|
|
return [$user, $tenant];
|
|
}
|
|
|
|
/**
|
|
* @return array{tenant: string}
|
|
*/
|
|
function filamentTenantRouteParams(Tenant $tenant): array
|
|
{
|
|
return ['tenant' => (string) $tenant->external_id];
|
|
}
|
|
|
|
function ensureDefaultProviderConnection(
|
|
Tenant $tenant,
|
|
string $provider = 'microsoft',
|
|
string $connectionType = ProviderConnectionType::Dedicated->value,
|
|
): ProviderConnection {
|
|
$resolvedConnectionType = ProviderConnectionType::tryFrom($connectionType) ?? ProviderConnectionType::Dedicated;
|
|
|
|
$connection = ProviderConnection::query()
|
|
->where('tenant_id', (int) $tenant->getKey())
|
|
->where('provider', $provider)
|
|
->orderByDesc('is_default')
|
|
->orderBy('id')
|
|
->first();
|
|
|
|
if (! $connection instanceof ProviderConnection) {
|
|
$connectionFactory = ProviderConnection::factory();
|
|
|
|
if ($resolvedConnectionType === ProviderConnectionType::Dedicated) {
|
|
$connectionFactory = $connectionFactory->dedicated();
|
|
}
|
|
|
|
$connection = $connectionFactory->verifiedHealthy()->create([
|
|
'tenant_id' => (int) $tenant->getKey(),
|
|
'provider' => $provider,
|
|
'entra_tenant_id' => (string) ($tenant->tenant_id ?? fake()->uuid()),
|
|
'connection_type' => $resolvedConnectionType->value,
|
|
'is_default' => true,
|
|
]);
|
|
} else {
|
|
$entraTenantId = trim((string) $connection->entra_tenant_id);
|
|
$currentConnectionType = $connection->connection_type instanceof ProviderConnectionType
|
|
? $connection->connection_type->value
|
|
: (is_string($connection->connection_type) ? $connection->connection_type : null);
|
|
$currentConsentStatus = $connection->consent_status instanceof ProviderConsentStatus
|
|
? $connection->consent_status->value
|
|
: (is_string($connection->consent_status) ? $connection->consent_status : null);
|
|
$currentVerificationStatus = $connection->verification_status instanceof ProviderVerificationStatus
|
|
? $connection->verification_status->value
|
|
: (is_string($connection->verification_status) ? $connection->verification_status : null);
|
|
|
|
$updates = [];
|
|
|
|
if ($currentConnectionType !== $resolvedConnectionType->value) {
|
|
$updates['connection_type'] = $resolvedConnectionType->value;
|
|
}
|
|
|
|
if (! $connection->is_default) {
|
|
$updates['is_default'] = true;
|
|
}
|
|
|
|
if ($connection->status !== 'connected') {
|
|
$updates['status'] = 'connected';
|
|
}
|
|
|
|
if ($currentConsentStatus !== ProviderConsentStatus::Granted->value) {
|
|
$updates['consent_status'] = ProviderConsentStatus::Granted->value;
|
|
}
|
|
|
|
if ($currentVerificationStatus !== ProviderVerificationStatus::Healthy->value) {
|
|
$updates['verification_status'] = ProviderVerificationStatus::Healthy->value;
|
|
}
|
|
|
|
if ($connection->health_status !== 'ok') {
|
|
$updates['health_status'] = 'ok';
|
|
}
|
|
|
|
if ($entraTenantId === '') {
|
|
$updates['entra_tenant_id'] = (string) ($tenant->tenant_id ?? fake()->uuid());
|
|
}
|
|
|
|
if (! array_key_exists('consent_granted_at', $updates)) {
|
|
$updates['consent_granted_at'] = now();
|
|
}
|
|
|
|
if (! array_key_exists('consent_last_checked_at', $updates)) {
|
|
$updates['consent_last_checked_at'] = now();
|
|
}
|
|
|
|
if (! array_key_exists('last_health_check_at', $updates)) {
|
|
$updates['last_health_check_at'] = now();
|
|
}
|
|
|
|
if (! array_key_exists('migration_review_required', $updates)) {
|
|
$updates['migration_review_required'] = false;
|
|
}
|
|
|
|
if ($updates !== []) {
|
|
$connection->forceFill($updates)->save();
|
|
$connection->refresh();
|
|
}
|
|
}
|
|
|
|
$credential = $connection->credential()->first();
|
|
|
|
if ($resolvedConnectionType === ProviderConnectionType::Platform) {
|
|
if ($credential instanceof ProviderCredential) {
|
|
$credential->delete();
|
|
$connection->refresh();
|
|
}
|
|
|
|
return $connection;
|
|
}
|
|
|
|
if (! $credential instanceof ProviderCredential) {
|
|
ProviderCredential::factory()->create([
|
|
'provider_connection_id' => (int) $connection->getKey(),
|
|
'type' => ProviderCredentialKind::ClientSecret->value,
|
|
'credential_kind' => ProviderCredentialKind::ClientSecret->value,
|
|
'source' => ProviderCredentialSource::DedicatedManual->value,
|
|
'last_rotated_at' => now(),
|
|
'expires_at' => now()->addYear(),
|
|
'payload' => [
|
|
'client_id' => fake()->uuid(),
|
|
'client_secret' => fake()->sha1(),
|
|
],
|
|
]);
|
|
|
|
$connection->refresh();
|
|
}
|
|
|
|
return $connection;
|
|
}
|
|
|
|
/**
|
|
* @param array<string, mixed> $attributes
|
|
*/
|
|
function createOnboardingDraft(array $attributes = []): TenantOnboardingSession
|
|
{
|
|
$workspace = $attributes['workspace'] ?? Workspace::factory()->create();
|
|
$tenant = $attributes['tenant'] ?? null;
|
|
$startedBy = $attributes['started_by'] ?? User::factory()->create();
|
|
$updatedBy = $attributes['updated_by'] ?? $startedBy;
|
|
|
|
foreach ([$startedBy, $updatedBy] as $member) {
|
|
if (! $member instanceof User) {
|
|
continue;
|
|
}
|
|
|
|
WorkspaceMembership::query()->firstOrCreate([
|
|
'workspace_id' => (int) $workspace->getKey(),
|
|
'user_id' => (int) $member->getKey(),
|
|
], [
|
|
'role' => 'owner',
|
|
]);
|
|
}
|
|
|
|
$factory = TenantOnboardingSession::factory()
|
|
->forWorkspace($workspace)
|
|
->startedBy($startedBy)
|
|
->updatedBy($updatedBy);
|
|
|
|
if ($tenant instanceof Tenant) {
|
|
$factory = $factory->forTenant($tenant);
|
|
}
|
|
|
|
if (($attributes['status'] ?? null) === 'completed') {
|
|
$factory = $factory->completed();
|
|
}
|
|
|
|
if (($attributes['status'] ?? null) === 'cancelled') {
|
|
$factory = $factory->cancelled();
|
|
}
|
|
|
|
unset(
|
|
$attributes['workspace'],
|
|
$attributes['tenant'],
|
|
$attributes['started_by'],
|
|
$attributes['updated_by'],
|
|
$attributes['status'],
|
|
);
|
|
|
|
return $factory->create($attributes);
|
|
}
|
|
|
|
/**
|
|
* @return list<TenantActionDescriptor>
|
|
*/
|
|
function tenantActionCatalog(Tenant $tenant, TenantActionSurface $surface, ?User $user = null): array
|
|
{
|
|
return app(TenantActionPolicySurface::class)->catalogForTenant($tenant, $surface, $user);
|
|
}
|
|
|
|
/**
|
|
* @param list<TenantActionDescriptor> $actions
|
|
* @return list<string>
|
|
*/
|
|
function tenantActionKeys(array $actions): array
|
|
{
|
|
return array_values(array_map(
|
|
static fn (TenantActionDescriptor $action): string => $action->key,
|
|
$actions,
|
|
));
|
|
}
|
|
|
|
function ensureDefaultPlatformProviderConnection(Tenant $tenant, string $provider = 'microsoft'): ProviderConnection
|
|
{
|
|
return ensureDefaultProviderConnection($tenant, $provider, ProviderConnectionType::Platform->value);
|
|
}
|
|
|
|
function ensureDefaultDedicatedProviderConnection(Tenant $tenant, string $provider = 'microsoft'): ProviderConnection
|
|
{
|
|
return ensureDefaultProviderConnection($tenant, $provider, ProviderConnectionType::Dedicated->value);
|
|
}
|
|
|
|
/**
|
|
* @param array<string, mixed> $snapshot
|
|
* @param array<int, array<string, mixed>> $assignments
|
|
* @param array<string, mixed> $scopeTags
|
|
* @param array<string, array<string, string>> $secretFingerprints
|
|
*/
|
|
function expectedPolicyVersionContentHash(
|
|
array $snapshot,
|
|
string $policyType,
|
|
?string $platform = null,
|
|
array $assignments = [],
|
|
array $scopeTags = [],
|
|
array $secretFingerprints = [],
|
|
?int $redactionVersion = 1,
|
|
): string {
|
|
return app(\App\Services\Drift\DriftHasher::class)->hashNormalized([
|
|
'settings' => app(\App\Services\Drift\Normalizers\SettingsNormalizer::class)->normalizeForDiff(
|
|
$snapshot,
|
|
$policyType,
|
|
$platform,
|
|
),
|
|
'assignments' => app(\App\Services\Drift\Normalizers\AssignmentsNormalizer::class)->normalizeForDiff($assignments),
|
|
'scope_tag_ids' => app(\App\Services\Drift\Normalizers\ScopeTagsNormalizer::class)->normalizeIds($scopeTags),
|
|
'secret_fingerprints' => [
|
|
'snapshot' => is_array($secretFingerprints['snapshot'] ?? null) ? $secretFingerprints['snapshot'] : [],
|
|
'assignments' => is_array($secretFingerprints['assignments'] ?? null) ? $secretFingerprints['assignments'] : [],
|
|
'scope_tags' => is_array($secretFingerprints['scope_tags'] ?? null) ? $secretFingerprints['scope_tags'] : [],
|
|
],
|
|
'redaction_version' => $redactionVersion,
|
|
]);
|
|
}
|