215 lines
8.8 KiB
PHP
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) ?: '';
|
|
}
|