TenantAtlas/apps/platform/tests/Feature/TenantConfiguration/Spec420M365CaptureOperationRunTest.php
ahmido a73a8f5882 feat: complete m365 generic evidence coverage pack (#487)
Committing and publishing the current Spec 420 package changes.

Includes updated services, coverage tests, browser smoke coverage, and the spec/plan/tasks artifacts for the package.

Co-authored-by: Ahmed Darrazi <ahmed.darrazi@live.de>
Reviewed-on: #487
2026-06-27 12:24:00 +00:00

215 lines
8.8 KiB
PHP

<?php
declare(strict_types=1);
use App\Jobs\TenantConfiguration\CaptureTenantConfigurationEvidenceJob;
use App\Models\AuditLog;
use App\Models\OperationRun;
use App\Models\ProviderConnection;
use App\Models\TenantConfigurationResourceEvidence;
use App\Services\Audit\AuditRecorder;
use App\Services\Graph\GraphClientInterface;
use App\Services\Graph\GraphResponse;
use App\Services\OperationRunService;
use App\Services\TenantConfiguration\GenericContentEvidenceCaptureService;
use App\Services\TenantConfiguration\ResourceTypeRegistry;
use App\Services\TenantConfiguration\StartTenantConfigurationCapture;
use App\Support\OperationRunOutcome;
use App\Support\OperationRunStatus;
use App\Support\OperationRunType;
use App\Support\OpsUx\OperationSummaryKeys;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Facades\Queue;
it('Spec420 reuses tenant configuration capture OperationRun semantics and summary counts', function (): void {
app(ResourceTypeRegistry::class)->syncDefaults();
[$user, $environment] = createMinimalUserWithTenant(role: 'owner');
$connection = ProviderConnection::factory()->withCredential()->create([
'workspace_id' => (int) $environment->workspace_id,
'managed_environment_id' => (int) $environment->getKey(),
]);
$graph = spec420OperationGraphClient([
'conditionalAccessPolicy' => [[
'id' => 'cap-1',
'displayName' => 'Require MFA',
'clientSecret' => 'spec420-operation-client-secret',
'tokenClaims' => [
'access_token' => 'spec420-operation-access-token',
],
]],
]);
app()->instance(GraphClientInterface::class, $graph);
$originalLogger = Log::getFacadeRoot();
$logger = spec420RecordingLogger();
Log::swap($logger);
$run = OperationRun::factory()->withUser($user)->forTenant($environment)->create([
'type' => OperationRunType::TenantConfigurationCapture->value,
'status' => OperationRunStatus::Queued->value,
'outcome' => OperationRunOutcome::Pending->value,
'context' => [
'target_scope' => [
'workspace_id' => (int) $environment->workspace_id,
'managed_environment_id' => (int) $environment->getKey(),
'provider_connection_id' => (int) $connection->getKey(),
],
'resource_types' => ['conditionalAccessPolicy', 'acceptedDomain', 'appPermissionPolicy', 'dlpCompliancePolicy'],
'required_capability' => 'evidence.manage',
],
]);
try {
app(CaptureTenantConfigurationEvidenceJob::class, ['run' => $run])->handle(
app(GenericContentEvidenceCaptureService::class),
app(OperationRunService::class),
app(AuditRecorder::class),
);
$run->refresh();
expect($run->type)->toBe(OperationRunType::TenantConfigurationCapture->value)
->and($run->type)->not->toBe('tenant_configuration.m365_capture')
->and($run->status)->toBe(OperationRunStatus::Completed->value)
->and($run->outcome)->toBe(OperationRunOutcome::Succeeded->value)
->and($run->summary_counts)->toMatchArray([
'total' => 4,
'processed' => 4,
'succeeded' => 1,
'skipped' => 3,
'failed' => 0,
'errors_recorded' => 0,
])
->and(array_diff(array_keys($run->summary_counts), OperationSummaryKeys::all()))->toBe([])
->and(json_encode($run->context, JSON_THROW_ON_ERROR))->not->toContain('client_secret')
->and(json_encode($run->context, JSON_THROW_ON_ERROR))->not->toContain('access_token')
->and(json_encode($run->context, JSON_THROW_ON_ERROR))->not->toContain('spec420-operation-client-secret')
->and(json_encode($run->context, JSON_THROW_ON_ERROR))->not->toContain('spec420-operation-access-token')
->and(json_encode($run->failure_summary ?? [], JSON_THROW_ON_ERROR))->not->toContain('spec420-operation-client-secret')
->and(json_encode($run->failure_summary ?? [], JSON_THROW_ON_ERROR))->not->toContain('spec420-operation-access-token');
$evidence = TenantConfigurationResourceEvidence::query()->sole();
$audit = AuditLog::query()
->where('action', 'tenant_configuration.capture.completed')
->latest('id')
->first();
expect($evidence->raw_payload['clientSecret'])->toBe('spec420-operation-client-secret')
->and($evidence->normalized_payload['clientSecret'])->toBe('[redacted]')
->and($evidence->normalized_payload['tokenClaims'])->toBe('[redacted]')
->and(json_encode($evidence->normalized_payload, JSON_THROW_ON_ERROR))->not->toContain('spec420-operation-client-secret')
->and(json_encode($evidence->normalized_payload, JSON_THROW_ON_ERROR))->not->toContain('spec420-operation-access-token')
->and($audit)->not->toBeNull()
->and(json_encode($audit?->metadata, JSON_THROW_ON_ERROR))->not->toContain('spec420-operation-client-secret')
->and(json_encode($audit?->metadata, JSON_THROW_ON_ERROR))->not->toContain('spec420-operation-access-token');
spec420AssertSecretsWereNotLogged($logger, [
'spec420-operation-client-secret',
'spec420-operation-access-token',
]);
} finally {
Log::swap($originalLogger);
}
});
it('Spec420 deduplicates active capture starts for the selected M365 first pack', function (): void {
Queue::fake();
[$user, $environment] = createMinimalUserWithTenant(role: 'owner');
$connection = ProviderConnection::factory()->create([
'workspace_id' => (int) $environment->workspace_id,
'managed_environment_id' => (int) $environment->getKey(),
]);
$types = ['conditionalAccessPolicy', 'acceptedDomain', 'appPermissionPolicy', 'dlpCompliancePolicy'];
$first = app(StartTenantConfigurationCapture::class)->start($environment, $connection, $user, $types);
$second = app(StartTenantConfigurationCapture::class)->start($environment, $connection, $user, $types);
expect($second->getKey())->toBe($first->getKey())
->and($first->type)->toBe(OperationRunType::TenantConfigurationCapture->value)
->and(data_get($first->context, 'resource_types'))->toBe(collect($types)->sort()->values()->all());
Queue::assertPushed(CaptureTenantConfigurationEvidenceJob::class, 1);
});
function spec420OperationGraphClient(array $responses): GraphClientInterface
{
return new class($responses) implements GraphClientInterface
{
public function __construct(private readonly array $responses) {}
public function listPolicies(string $policyType, array $options = []): GraphResponse
{
return new GraphResponse(true, $this->responses[$policyType] ?? []);
}
public function getPolicy(string $policyType, string $policyId, array $options = []): GraphResponse
{
return new GraphResponse(false, [], 501);
}
public function getOrganization(array $options = []): GraphResponse
{
return new GraphResponse(false, [], 501);
}
public function applyPolicy(string $policyType, string $policyId, array $payload, array $options = []): GraphResponse
{
return new GraphResponse(false, [], 501);
}
public function getServicePrincipalPermissions(array $options = []): GraphResponse
{
return new GraphResponse(false, [], 501);
}
public function request(string $method, string $path, array $options = []): GraphResponse
{
return new GraphResponse(false, [], 501);
}
};
}
function spec420RecordingLogger(): object
{
return new class
{
/**
* @var list<array{level: string, arguments: array<int, mixed>}>
*/
public array $entries = [];
public function __call(string $method, array $arguments): self
{
if (in_array($method, ['debug', 'info', 'notice', 'warning', 'error', 'critical', 'alert', 'emergency'], true)) {
$this->entries[] = ['level' => $method, 'arguments' => $arguments];
}
return $this;
}
};
}
/**
* @param list<string> $secrets
*/
function spec420AssertSecretsWereNotLogged(object $logger, array $secrets): void
{
$entries = property_exists($logger, 'entries') && is_array($logger->entries)
? $logger->entries
: [];
$logged = spec420LogArguments($entries);
foreach ($secrets as $secret) {
expect($logged)->not->toContain($secret);
}
}
/**
* @param array<int, mixed> $arguments
*/
function spec420LogArguments(array $arguments): string
{
return json_encode($arguments, JSON_PARTIAL_OUTPUT_ON_ERROR | JSON_UNESCAPED_SLASHES) ?: '';
}