TenantAtlas/apps/platform/tests/Unit/Support/TenantConfiguration/Spec435ExchangeStructuredOutputEnvelopeTest.php
ahmido a09cc6ca8d Spec 435: Exchange structured output readiness (#502)
## Summary

- Add Spec 435 backend-only Exchange structured output envelope and target normalizer readiness helpers for transportRule, remoteDomain, and inboundConnector.
- Add deterministic hash input/readiness handling and no-promotion guard coverage.
- Add Spec 435 spec, plan, tasks, checklist, and implementation report artifacts.

## Validation

- git diff --cached --check
- cd apps/platform && php artisan test --filter=Spec435 --compact

## Product Surface / Deployment

- Livewire v4 compliance unchanged.
- Filament provider registration unchanged under apps/platform/bootstrap/providers.php.
- Global search unchanged; no resources added or modified.
- Destructive/high-impact actions: none.
- Asset strategy: none; no filament:assets requirement for this slice.
- Browser proof: N/A - no rendered UI surface changed.
- Deployment impact: no env vars, migrations, queues, scheduler, storage, assets, or Dokploy runtime behavior changes.

Co-authored-by: Ahmed Darrazi <ahmed.darrazi@live.de>
Reviewed-on: #502
2026-07-08 14:33:23 +00:00

175 lines
7.8 KiB
PHP

<?php
declare(strict_types=1);
use App\Services\TenantConfiguration\ExchangePowerShellCommandContract;
use App\Services\TenantConfiguration\ExchangePowerShellCommandContracts;
use App\Services\TenantConfiguration\ExchangePowerShellOutputGuard;
use App\Services\TenantConfiguration\ExchangePowerShellProcessResult;
use App\Services\TenantConfiguration\ExchangePowerShellRuntimePolicy;
use App\Services\TenantConfiguration\ExchangePowerShellStructuredOutputEnvelope;
it('Spec435 accepts structured Exchange collections and structured empty collections without raw process output', function (): void {
$contract = spec435VerifiedContract('transportRule');
$collection = (new ExchangePowerShellOutputGuard)->guard(
ExchangePowerShellProcessResult::completed(0, '[{"Guid":"rule-1","client_secret":"unsafe"}]', durationMs: 10),
$contract,
spec435RuntimePolicy(),
);
$empty = (new ExchangePowerShellOutputGuard)->guard(
ExchangePowerShellProcessResult::completed(0, '', durationMs: 11),
$contract,
spec435RuntimePolicy(),
);
$collectionEnvelope = ExchangePowerShellStructuredOutputEnvelope::fromInvocationResult(
resourceType: 'transportRule',
contract: $contract,
runnerMode: 'fake',
result: $collection,
);
$emptyEnvelope = ExchangePowerShellStructuredOutputEnvelope::fromInvocationResult(
resourceType: 'transportRule',
contract: $contract,
runnerMode: 'fake',
result: $empty,
);
$collectionItemsJson = json_encode($collectionEnvelope->items, JSON_THROW_ON_ERROR);
expect($collectionEnvelope->accepted())->toBeTrue()
->and($collectionEnvelope->shapeState)->toBe(ExchangePowerShellStructuredOutputEnvelope::SHAPE_STRUCTURED_COLLECTION)
->and($collectionEnvelope->itemCount)->toBe(1)
->and($collectionEnvelope->items[0]['Guid'] ?? null)->toBe('rule-1')
->and($collectionItemsJson)
->not->toContain('client_secret')
->not->toContain('unsafe')
->and($collectionEnvelope->safeSummary())
->not->toHaveKey('stdout')
->not->toHaveKey('stderr')
->and(json_encode($collectionEnvelope->safeSummary(), JSON_THROW_ON_ERROR))
->not->toContain('client_secret')
->and($emptyEnvelope->accepted())->toBeTrue()
->and($emptyEnvelope->shapeState)->toBe(ExchangePowerShellStructuredOutputEnvelope::SHAPE_STRUCTURED_EMPTY_COLLECTION)
->and($emptyEnvelope->emptyCollection)->toBeTrue()
->and($emptyEnvelope->itemCount)->toBe(0);
});
it('Spec435 strips forbidden secret and raw-output key families from public envelope items recursively', function (): void {
$contract = spec435VerifiedContract('transportRule');
$collection = (new ExchangePowerShellOutputGuard)->guard(
ExchangePowerShellProcessResult::completed(0, json_encode([[
'Guid' => 'rule-1',
'Actions' => [
'SetHeaderValue' => 'business-value-redacted-later',
'authorization_header' => 'Bearer unsafe',
],
'Nested' => [
'stdout' => 'unsafe-direct-stdout',
'stderr' => 'unsafe-direct-stderr',
'tokens' => ['unsafe-token'],
'Cookies' => ['unsafe-cookie'],
'CredentialMaterial' => 'unsafe-credential',
'PowerShellTranscript' => 'unsafe-transcript',
'RawShellStdout' => 'unsafe-stdout',
],
]], JSON_THROW_ON_ERROR), durationMs: 10),
$contract,
spec435RuntimePolicy(),
);
$envelope = ExchangePowerShellStructuredOutputEnvelope::fromInvocationResult(
resourceType: 'transportRule',
contract: $contract,
runnerMode: 'fake',
result: $collection,
);
$itemsJson = json_encode($envelope->items, JSON_THROW_ON_ERROR);
expect($envelope->accepted())->toBeTrue()
->and(data_get($envelope->items, '0.Guid'))->toBe('rule-1')
->and(data_get($envelope->items, '0.Actions.SetHeaderValue'))->toBe('business-value-redacted-later')
->and($itemsJson)
->not->toContain('authorization_header')
->not->toContain('unsafe-token')
->not->toContain('unsafe-cookie')
->not->toContain('unsafe-credential')
->not->toContain('unsafe-transcript')
->not->toContain('unsafe-stdout')
->not->toContain('unsafe-direct-stdout')
->not->toContain('unsafe-direct-stderr');
});
it('Spec435 maps unsafe Exchange process output shapes to explicit readiness blockers', function (
ExchangePowerShellProcessResult $processResult,
string $expectedShapeState,
string $expectedBlocker,
): void {
$contract = spec435VerifiedContract('transportRule');
$result = (new ExchangePowerShellOutputGuard)->guard(
$processResult,
$contract,
spec435RuntimePolicy(stdoutMaxBytes: 64, stderrMaxBytes: 64, itemLimit: 1),
);
$envelope = ExchangePowerShellStructuredOutputEnvelope::fromInvocationResult(
resourceType: 'transportRule',
contract: $contract,
runnerMode: 'fake',
result: $result,
);
expect($envelope->accepted())->toBeFalse()
->and($envelope->shapeState)->toBe($expectedShapeState)
->and($envelope->readinessBlocker)->toBe($expectedBlocker)
->and($envelope->normalizerReadinessState)->toBe(ExchangePowerShellStructuredOutputEnvelope::READINESS_BLOCKED)
->and(json_encode($envelope->safeSummary(), JSON_THROW_ON_ERROR))
->not->toContain('secret-output');
})->with([
'malformed text' => [ExchangePowerShellProcessResult::completed(0, 'raw secret-output transcript'), 'malformed_json_collection', 'blocked_malformed_output'],
'scalar output' => [ExchangePowerShellProcessResult::completed(0, '42'), 'malformed_json_collection', 'blocked_malformed_output'],
'warning-prefixed output' => [ExchangePowerShellProcessResult::completed(0, "WARNING: secret-output\n[]"), 'warning_prefixed', 'blocked_warning_prefixed_output'],
'binary output' => [ExchangePowerShellProcessResult::completed(0, "\x00\x01"), 'non_utf8_or_binary', 'blocked_binary_output'],
'oversized stdout' => [ExchangePowerShellProcessResult::completed(0, str_repeat('A', 128)), 'stdout_oversized', 'blocked_oversized_output'],
'oversized stderr' => [ExchangePowerShellProcessResult::completed(0, '[]', str_repeat('B', 128)), 'stderr_oversized', 'blocked_oversized_output'],
'non-zero exit' => [ExchangePowerShellProcessResult::completed(1, '[{"Guid":"rule-1"}]'), 'nonzero_exit', 'blocked_nonzero_exit'],
'timeout' => [ExchangePowerShellProcessResult::timedOut(60000), 'timeout', 'blocked_timeout'],
]);
function spec435RuntimePolicy(
int $stdoutMaxBytes = 524288,
int $stderrMaxBytes = 65536,
int $itemLimit = 1000,
): ExchangePowerShellRuntimePolicy {
return new ExchangePowerShellRuntimePolicy(
invocationEnabled: true,
productionRunnerEnabled: true,
allowedEnvironments: [app()->environment()],
currentEnvironment: app()->environment(),
powershellBinary: 'pwsh',
moduleCheckEnabled: true,
checkTimeoutSeconds: 5,
invocationTimeoutSeconds: 60,
stdoutMaxBytes: $stdoutMaxBytes,
stderrMaxBytes: $stderrMaxBytes,
itemLimit: $itemLimit,
lockTtlSeconds: 300,
tempFilesEnabled: false,
tempDirectory: null,
);
}
function spec435VerifiedContract(string $canonicalType): ExchangePowerShellCommandContract
{
$contracts = new ExchangePowerShellCommandContracts;
$contract = $contracts->contractForCanonicalType($canonicalType);
if (! is_array($contract)) {
throw new RuntimeException('Missing Spec435 command contract.');
}
return ExchangePowerShellCommandContract::fromVerifiedArray($contract, $contracts, []);
}