Files
api/services/nginx/app/tests/Unit/Bird/BirdControlPlaneContractTest.php
T
Jeppe B a442e70744 Add secure Bird gateway for Pleno Control Plane (#332)
Add the Bird Control Plane gateway, signed webhook ingestion, policy-gated writes, fail-closed production auto-activation, and RSA-OAEP bootstrap credential flow.
2026-07-29 19:59:20 +02:00

184 lines
7.2 KiB
PHP

<?php
app_require('modules/bird/classes/bird_control_plane_contract.php');
app_require('modules/bird/classes/bird_outbound_message_store.php');
app_require('modules/bird/classes/bird_control_plane_schema_bootstrap.php');
use bird\classes\bird_control_plane_contract;
use bird\classes\bird_control_plane_schema_bootstrap;
use bird\classes\bird_outbound_message_store;
use classes\router;
it('executes the exact confirmed hangup contract and intended cause allowlist', function (): void {
$operation = bird_control_plane_contract::hangup([
'action' => 'voice.call.hangup',
'resourceId' => 'call-123',
'confirmed' => true,
'parameters' => [
'channelId' => 'channel-456',
'cause' => 'busy',
],
], ['channel-456']);
$sameOperation = bird_control_plane_contract::hangup([
'confirmed' => true,
'parameters' => ['cause' => 'busy', 'channelId' => 'channel-456'],
'resourceId' => 'call-123',
'action' => 'voice.call.hangup',
], ['channel-456']);
expect($operation)->toBe([
'action' => 'voice.call.hangup',
'resourceId' => 'call-123',
'channelId' => 'channel-456',
'cause' => 'busy',
])->and(bird_control_plane_contract::operationRequestHash($operation))
->toBe(bird_control_plane_contract::operationRequestHash($sameOperation));
expect(fn () => bird_control_plane_contract::hangup([
'action' => 'voice.call.hangup',
'resourceId' => 'call-123',
'confirmed' => true,
'parameters' => [
'channelId' => 'channel-456',
'cause' => 'operator_requested',
],
], ['channel-456']))->toThrow(InvalidArgumentException::class, 'Invalid hangup cause');
});
it('rejects webhook scope before persistence unless workspace and channel exactly match', function (): void {
$payload = [
'data' => [
'workspaceId' => 'workspace-1',
'conversation' => ['channelId' => 'channel-1'],
],
];
expect(bird_control_plane_contract::webhookScope(
$payload,
'workspace-1',
['channel-1', 'channel-2']
))->toBe([
'workspaceId' => 'workspace-1',
'channelId' => 'channel-1',
])->and(fn () => bird_control_plane_contract::webhookScope(
$payload,
'workspace-2',
['channel-1', 'channel-2']
))->toThrow(InvalidArgumentException::class, 'workspace is not allowlisted')
->and(fn () => bird_control_plane_contract::webhookScope(
$payload,
'workspace-1',
['channel-2']
))->toThrow(InvalidArgumentException::class, 'channel is not allowlisted');
});
it('supports multiple explicit channel IDs and fails closed with a legacy fallback only for empty config', function (): void {
expect(bird_control_plane_contract::allowedChannelIds(
'["channel-1","channel-2","channel-1"]',
'legacy-channel'
))->toBe(['channel-1', 'channel-2'])
->and(bird_control_plane_contract::allowedChannelIds('[]', 'legacy-channel'))
->toBe(['legacy-channel'])
->and(bird_control_plane_contract::allowedChannelIds('[]', ''))->toBe([])
->and(bird_control_plane_contract::allowedChannelIds('invalid-json', 'legacy-channel'))
->toBe([]);
});
it('normalizes provider channels into the stable gateway envelope records', function (): void {
expect(bird_control_plane_contract::channels([
'results' => [[
'id' => 'channel-1',
'displayName' => 'Customer WhatsApp',
'type' => 'whatsapp',
'status' => 'active',
'credential' => 'must-not-leak',
]],
]))->toBe([[
'id' => 'channel-1',
'name' => 'Customer WhatsApp',
'platform' => 'whatsapp',
'status' => 'active',
]]);
});
it('confines conversation reads and sends to the exact channel allowlist', function (): void {
expect(bird_control_plane_contract::assertConversationChannel(
['channelId' => 'channel-2'],
['channel-1', 'channel-2']
))->toBe('channel-2')
->and(fn () => bird_control_plane_contract::assertConversationChannel(
['channelId' => 'channel-3'],
['channel-1', 'channel-2']
))->toThrow(InvalidArgumentException::class, 'conversation channel is not allowlisted');
});
it('returns a messageId alias and exposes crash-safe pending reconciliation state', function (): void {
$base = [
'reference_id' => 'reference-1',
'conversation_id' => 'conversation-1',
'message_kind' => 'text',
'request_hash' => str_repeat('a', 64),
'provider_message_id' => 'message-1',
'response_json' => '{"id":"message-1"}',
'created_at' => '2026-07-29 12:00:00',
'updated_at' => '2026-07-29 12:00:01',
];
$completed = bird_outbound_message_store::recordFromRow($base + ['status' => 'completed']);
$pending = bird_outbound_message_store::recordFromRow($base + [
'status' => 'pending',
'provider_message_id' => null,
'response_json' => null,
]);
expect($completed['messageId'])->toBe('message-1')
->and($completed['providerMessageId'])->toBe('message-1')
->and($completed['reconciliationRequired'])->toBeFalse()
->and($pending['reconciliationRequired'])->toBeTrue()
->and($pending['retrySafe'])->toBeFalse();
});
it('returns a structured non-retryable conflict for unresolved reservations', function (): void {
$outcome = bird_control_plane_contract::reservationOutcome([
'reference' => 'reference-1',
'status' => 'ambiguous',
'reconciliationRequired' => true,
]);
expect($outcome['ambiguous'])->toBeTrue()
->and($outcome['statusCode'])->toBe(409)
->and($outcome['payload'])->toMatchArray([
'code' => 'external_action_ambiguous',
'outcomeAmbiguous' => true,
'reference' => 'reference-1',
'status' => 'ambiguous',
'reconciliationRequired' => true,
'retrySafe' => false,
]);
});
it('keeps Bird ledger DDL in the checked-in additive schema bootstrap', function (): void {
$queries = bird_control_plane_schema_bootstrap::queries();
expect(bird_control_plane_schema_bootstrap::VERSION)->toBe(1)
->and($queries)->toHaveCount(3)
->and(implode("\n", $queries))->toContain('bird_control_plane_schema_versions')
->and(implode("\n", $queries))->toContain('bird_webhook_events')
->toContain('bird_outbound_messages')
->toContain('CREATE TABLE IF NOT EXISTS');
});
it('uses exact static message routes without broadening global dynamic segments', function (): void {
$_SERVER['REQUEST_URI'] = '/bird/control-plane/v1/messages';
$_SERVER['REQUEST_METHOD'] = 'POST';
$router = new router();
$matcher = new ReflectionMethod(router::class, 'doesRouteMatchCurrent');
expect($matcher->invoke($router, '/bird/control-plane/v1/messages'))->toBeTrue();
$_SERVER['REQUEST_URI'] = '/bird/control-plane/v1/conversations/uuid-with-hyphens/messages';
$router = new router();
expect($matcher->invoke(
$router,
'/bird/control-plane/v1/conversations/{id}/messages'
))->toBeFalse();
});