Add the Bird Control Plane gateway, signed webhook ingestion, policy-gated writes, fail-closed production auto-activation, and RSA-OAEP bootstrap credential flow.
184 lines
7.2 KiB
PHP
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();
|
|
});
|