Keep Bird activation outside the PHP-FPM/nginx startup path so Bird configuration failures cannot make the core API unavailable. Retain guarded explicit activation and regression coverage.
425 lines
17 KiB
PHP
425 lines
17 KiB
PHP
<?php
|
|
|
|
app_require('modules/bird/classes/bird_control_plane_activator.php');
|
|
app_require('modules/bird/classes/bird_webhook_subscription_reconciler.php');
|
|
app_require('modules/bird/classes/bird_control_plane_bootstrap_sealer.php');
|
|
app_require('modules/bird/classes/bird_control_plane_auto_activation.php');
|
|
|
|
use bird\classes\bird_control_plane_activator;
|
|
use bird\classes\bird_webhook_subscription_reconciler;
|
|
use bird\classes\bird_control_plane_bootstrap_sealer;
|
|
use bird\classes\bird_control_plane_auto_activation;
|
|
|
|
it('canonicalizes legacy workspace and channel configuration without widening scope', function (): void {
|
|
expect(bird_control_plane_activator::canonicalWorkspaceId([
|
|
'workspaceId' => '',
|
|
'workplaceId' => 'legacy-workspace',
|
|
]))->toBe('legacy-workspace')
|
|
->and(bird_control_plane_activator::canonicalAllowedChannelIds([
|
|
'allowed_channel_ids_json' => '',
|
|
'channelId' => 'legacy-channel',
|
|
]))->toBe(['legacy-channel'])
|
|
->and(bird_control_plane_activator::canonicalAllowedChannelIds([
|
|
'allowed_channel_ids_json' => '["channel-b","channel-a","channel-a"]',
|
|
'channelId' => 'legacy-channel',
|
|
]))->toBe(['channel-b', 'channel-a']);
|
|
});
|
|
|
|
it('fails closed for malformed explicit channel configuration', function (): void {
|
|
expect(bird_control_plane_activator::canonicalAllowedChannelIds([
|
|
'allowed_channel_ids_json' => '{bad-json',
|
|
'channelId' => 'legacy-channel',
|
|
]))->toBe([]);
|
|
});
|
|
|
|
it('requires an explicit participant when provider observations are ambiguous', function (): void {
|
|
expect(bird_control_plane_activator::selectParticipantId('', ['participant-1']))
|
|
->toBe('participant-1')
|
|
->and(bird_control_plane_activator::selectParticipantId(
|
|
'configured-participant',
|
|
['participant-1', 'participant-2']
|
|
))->toBe('configured-participant')
|
|
->and(fn () => bird_control_plane_activator::selectParticipantId(
|
|
'',
|
|
['participant-1', 'participant-2']
|
|
))->toThrow(RuntimeException::class);
|
|
});
|
|
|
|
it('discovers only access key participants from supported conversation shapes', function (): void {
|
|
expect(bird_control_plane_activator::accessKeyParticipantIds([
|
|
'participants' => [
|
|
['type' => 'contact', 'id' => 'contact-1'],
|
|
['type' => 'accessKey', 'id' => 'access-key-1'],
|
|
],
|
|
'featuredParticipants' => [
|
|
['type' => 'ACCESSKEY', 'id' => 'access-key-2'],
|
|
],
|
|
'lastMessage' => [
|
|
'sender' => ['type' => 'accessKey', 'id' => 'access-key-1'],
|
|
],
|
|
]))->toBe(['access-key-1', 'access-key-2']);
|
|
});
|
|
|
|
it('keeps automation disabled unless policy documents are explicitly valid', function (): void {
|
|
$validFlow = json_encode([
|
|
'version' => 'v1',
|
|
'rules' => [[
|
|
'id' => 'route-support',
|
|
'enabled' => true,
|
|
'when' => ['platform' => 'whatsapp'],
|
|
'actions' => [['type' => 'tag', 'value' => 'support']],
|
|
]],
|
|
], JSON_THROW_ON_ERROR);
|
|
$invalidFlow = json_encode([
|
|
'version' => 'v1',
|
|
'rules' => [['enabled' => true]],
|
|
], JSON_THROW_ON_ERROR);
|
|
$validTemplates = json_encode([
|
|
'version' => 'v1',
|
|
'templates' => [[
|
|
'enabled' => true,
|
|
'projectId' => 'project-1',
|
|
'version' => 'version-1',
|
|
'locale' => 'da-DK',
|
|
'parameterKeys' => ['name'],
|
|
]],
|
|
], JSON_THROW_ON_ERROR);
|
|
|
|
expect(bird_control_plane_activator::policyActive($validFlow, 'rules'))->toBeTrue()
|
|
->and(bird_control_plane_activator::policyActive($invalidFlow, 'rules'))->toBeFalse()
|
|
->and(bird_control_plane_activator::safePolicy($invalidFlow, 'rules'))
|
|
->toBe('{"version":"v1","rules":[]}')
|
|
->and(bird_control_plane_activator::policyActive($validTemplates, 'templates'))->toBeTrue()
|
|
->and(bird_control_plane_activator::policyActive(
|
|
'{"version":"v1","templates":[]}',
|
|
'templates'
|
|
))->toBeFalse();
|
|
});
|
|
|
|
it('keeps deployment activation provider-read-only and exposes participant status', function (): void {
|
|
$activator = file_get_contents(
|
|
app_path('modules/bird/classes/bird_control_plane_activator.php')
|
|
);
|
|
$route = file_get_contents(app_path('routes/birdControlPlaneRoute.php'));
|
|
|
|
expect($activator)->not->toBeFalse()
|
|
->and($activator)->toContain(bird_control_plane_activator::PUBLIC_WEBHOOK_URL)
|
|
->and($activator)->toContain('provider calls are limited to channels/conversations GETs')
|
|
->and($activator)->not->toContain('CURLOPT_POST')
|
|
->and($activator)->not->toContain('CURLOPT_CUSTOMREQUEST')
|
|
->and($activator)->not->toContain('/webhook-subscriptions')
|
|
->and($route)->toContain("'participantId' => trim(");
|
|
});
|
|
|
|
it('requires strong activation secrets and sanitizes identifiers', function (): void {
|
|
expect(bird_control_plane_activator::secretAcceptable(str_repeat('a', 31)))->toBeFalse()
|
|
->and(bird_control_plane_activator::secretAcceptable(str_repeat('a', 32)))->toBeTrue()
|
|
->and(bird_control_plane_activator::sanitizeId('participant-123456789'))
|
|
->toBe('part...6789')
|
|
->and(bird_control_plane_activator::sanitizeId('short'))->not->toBe('short');
|
|
});
|
|
|
|
it('requires Bird to advertise both conversation events with channel filtering', function (): void {
|
|
$supported = [
|
|
'results' => [[
|
|
'service' => 'conversations',
|
|
'events' => [
|
|
['name' => 'conversation.created', 'filterKeys' => ['channelId']],
|
|
['name' => 'conversation.updated', 'filterKeys' => ['channelId']],
|
|
],
|
|
]],
|
|
];
|
|
$missingFilter = [
|
|
'results' => [[
|
|
'service' => 'conversations',
|
|
'events' => [
|
|
['name' => 'conversation.created', 'filterKeys' => ['channelId']],
|
|
['name' => 'conversation.updated', 'filterKeys' => []],
|
|
],
|
|
]],
|
|
];
|
|
|
|
expect(bird_webhook_subscription_reconciler::capabilitiesAdvertised($supported))->toBeTrue()
|
|
->and(bird_webhook_subscription_reconciler::capabilitiesAdvertised($missingFilter))
|
|
->toBeFalse();
|
|
});
|
|
|
|
it('matches only exact owned webhook targets and leaves unrelated subscriptions alone', function (): void {
|
|
$target = ['event' => 'conversation.created', 'channelId' => 'channel-1'];
|
|
$subscriptions = [
|
|
[
|
|
'id' => 'owned',
|
|
'service' => 'conversations',
|
|
'event' => 'conversation.created',
|
|
'url' => bird_control_plane_activator::PUBLIC_WEBHOOK_URL,
|
|
'eventFilters' => [['key' => 'channelId', 'value' => 'channel-1']],
|
|
],
|
|
[
|
|
'id' => 'other-url',
|
|
'service' => 'conversations',
|
|
'event' => 'conversation.created',
|
|
'url' => 'https://other.example.test/webhook',
|
|
'eventFilters' => [['key' => 'channelId', 'value' => 'channel-1']],
|
|
],
|
|
[
|
|
'id' => 'wider-filter',
|
|
'service' => 'conversations',
|
|
'event' => 'conversation.created',
|
|
'url' => bird_control_plane_activator::PUBLIC_WEBHOOK_URL,
|
|
'eventFilters' => [
|
|
['key' => 'channelId', 'value' => 'channel-1'],
|
|
['key' => 'status', 'value' => 'active'],
|
|
],
|
|
],
|
|
];
|
|
|
|
expect(bird_webhook_subscription_reconciler::matchingSubscriptions(
|
|
$subscriptions,
|
|
$target
|
|
))->toHaveCount(1)
|
|
->and(bird_webhook_subscription_reconciler::matchingSubscriptions(
|
|
$subscriptions,
|
|
$target
|
|
)[0]['id'])->toBe('owned')
|
|
->and(bird_webhook_subscription_reconciler::targets(['channel-1', 'channel-2']))
|
|
->toHaveCount(4);
|
|
});
|
|
|
|
it('discovers an organization only when UUID metadata is unique and workspace-consistent', function (): void {
|
|
$workspaceId = 'a1405560-c8d3-4b1a-877d-3f449ad95352';
|
|
$organizationId = '823fbfaf-f14e-4693-b55a-8ec1c17d649e';
|
|
$discovered = bird_webhook_subscription_reconciler::discoverOrganizationContext([
|
|
['results' => [[
|
|
'id' => 'channel-1',
|
|
'workspaceId' => $workspaceId,
|
|
'organizationId' => $organizationId,
|
|
]]],
|
|
['results' => [[
|
|
'id' => 'conversation-1',
|
|
'workspaceId' => $workspaceId,
|
|
'organizationId' => $organizationId,
|
|
]]],
|
|
], $workspaceId);
|
|
$mismatch = bird_webhook_subscription_reconciler::discoverOrganizationContext([
|
|
['results' => [[
|
|
'workspaceId' => 'b4e02c85-c6d2-4b15-8885-e09671799c61',
|
|
'organizationId' => $organizationId,
|
|
]]],
|
|
], $workspaceId);
|
|
|
|
expect($discovered['candidateIds'])->toBe([$organizationId])
|
|
->and($discovered['workspaceConsistent'])->toBeTrue()
|
|
->and($mismatch['workspaceConsistent'])->toBeFalse();
|
|
});
|
|
|
|
it('prefers organization listing and accepts only explicit workspace fallback collections', function (): void {
|
|
$workspaceId = 'a1405560-c8d3-4b1a-877d-3f449ad95352';
|
|
$organizationId = '823fbfaf-f14e-4693-b55a-8ec1c17d649e';
|
|
|
|
expect(bird_webhook_subscription_reconciler::subscriptionListPath(
|
|
$organizationId,
|
|
$workspaceId
|
|
))->toBe(
|
|
'/organizations/' . $organizationId . '/workspaces/' . $workspaceId
|
|
. '/webhook-subscriptions'
|
|
)->and(bird_webhook_subscription_reconciler::subscriptionListPath(
|
|
'',
|
|
$workspaceId
|
|
))->toBe('/workspaces/' . $workspaceId . '/webhook-subscriptions')
|
|
->and(bird_webhook_subscription_reconciler::validSubscriptionPage([
|
|
'results' => [],
|
|
]))->toBeTrue()
|
|
->and(bird_webhook_subscription_reconciler::validSubscriptionPage([]))->toBeFalse()
|
|
->and(bird_webhook_subscription_reconciler::validSubscriptionPage([
|
|
'error' => 'not a collection',
|
|
]))->toBeFalse();
|
|
});
|
|
|
|
it('seals the bootstrap token with RSA OAEP SHA256 and the committed public key', function (): void {
|
|
$sealer = new bird_control_plane_bootstrap_sealer();
|
|
$token = str_repeat('A', 64);
|
|
$ciphertext = $sealer->seal($token);
|
|
|
|
expect($sealer->fingerprint())->toBe(bird_control_plane_bootstrap_sealer::KEY_FINGERPRINT)
|
|
->and(bird_control_plane_bootstrap_sealer::ciphertextValid($ciphertext))->toBeTrue()
|
|
->and($ciphertext)->not->toContain($token);
|
|
});
|
|
|
|
it('round trips bootstrap ciphertext with RSA OAEP SHA256', function (): void {
|
|
$key = openssl_pkey_new([
|
|
'private_key_bits' => 3072,
|
|
'private_key_type' => OPENSSL_KEYTYPE_RSA,
|
|
]);
|
|
expect($key)->not->toBeFalse();
|
|
openssl_pkey_export($key, $privatePem);
|
|
$details = openssl_pkey_get_details($key);
|
|
expect($details)->toBeArray()->and($details['key'] ?? null)->toBeString();
|
|
$publicPath = tempnam(sys_get_temp_dir(), 'bird-bootstrap-public-');
|
|
$privatePath = tempnam(sys_get_temp_dir(), 'bird-bootstrap-private-');
|
|
file_put_contents($publicPath, $details['key']);
|
|
file_put_contents($privatePath, $privatePem);
|
|
chmod($privatePath, 0600);
|
|
try {
|
|
$probe = new bird_control_plane_bootstrap_sealer($publicPath, '');
|
|
$sealer = new bird_control_plane_bootstrap_sealer($publicPath, $probe->fingerprint());
|
|
$token = str_repeat('D', 64);
|
|
$ciphertext = base64_decode($sealer->seal($token), true);
|
|
$pipes = [];
|
|
$process = proc_open([
|
|
'openssl',
|
|
'pkeyutl',
|
|
'-decrypt',
|
|
'-inkey',
|
|
$privatePath,
|
|
'-pkeyopt',
|
|
'rsa_padding_mode:oaep',
|
|
'-pkeyopt',
|
|
'rsa_oaep_md:sha256',
|
|
'-pkeyopt',
|
|
'rsa_mgf1_md:sha256',
|
|
], [
|
|
0 => ['pipe', 'r'],
|
|
1 => ['pipe', 'w'],
|
|
2 => ['pipe', 'w'],
|
|
], $pipes, null, null, ['bypass_shell' => true]);
|
|
expect($process)->toBeResource();
|
|
fwrite($pipes[0], $ciphertext);
|
|
fclose($pipes[0]);
|
|
$plaintext = stream_get_contents($pipes[1]);
|
|
fclose($pipes[1]);
|
|
stream_get_contents($pipes[2]);
|
|
fclose($pipes[2]);
|
|
expect(proc_close($process))->toBe(0)
|
|
->and($plaintext)->toBe($token);
|
|
} finally {
|
|
unlink($publicPath);
|
|
unlink($privatePath);
|
|
}
|
|
});
|
|
|
|
it('recognizes stable bootstrap metadata without rotating a valid existing token', function (): void {
|
|
$token = str_repeat('B', 64);
|
|
$config = [
|
|
'control_plane_bootstrap_token_hash' => hash('sha256', $token),
|
|
'control_plane_bootstrap_algorithm' => 'RSA-OAEP-256',
|
|
'control_plane_bootstrap_key_fingerprint' =>
|
|
bird_control_plane_bootstrap_sealer::KEY_FINGERPRINT,
|
|
'control_plane_bootstrap_token_version' => '7',
|
|
'control_plane_bootstrap_updated_at' => '2026-07-29T17:30:00Z',
|
|
'control_plane_bootstrap_ciphertext' => base64_encode(str_repeat("\0", 384)),
|
|
];
|
|
|
|
expect(bird_control_plane_auto_activation::existingBootstrapValid($config, $token))
|
|
->toBeTrue()
|
|
->and(bird_control_plane_auto_activation::existingBootstrapValid(
|
|
$config,
|
|
str_repeat('C', 64)
|
|
))->toBeFalse();
|
|
|
|
$payload = json_decode(bird_control_plane_auto_activation::sealedPayload(
|
|
$token,
|
|
7,
|
|
'2026-07-29T17:30:00Z'
|
|
), true, 512, JSON_THROW_ON_ERROR);
|
|
expect($payload)->toBe([
|
|
'algorithm' => 'RSA-OAEP-256',
|
|
'keyFingerprint' => bird_control_plane_bootstrap_sealer::KEY_FINGERPRINT,
|
|
'tokenVersion' => 7,
|
|
'updatedAt' => '2026-07-29T17:30:00Z',
|
|
'token' => $token,
|
|
]);
|
|
});
|
|
|
|
it('keeps capabilities and bootstrap unavailable when webhook reconciliation fails', function (): void {
|
|
$state = [
|
|
'controlPlaneEnabled' => true,
|
|
'outboundMessagesEnabled' => true,
|
|
'operationsActionsEnabled' => true,
|
|
'bootstrapReady' => true,
|
|
];
|
|
$disableCount = 0;
|
|
$enableCount = 0;
|
|
$finalCheckCount = 0;
|
|
$disable = function () use (&$state, &$disableCount): void {
|
|
$disableCount++;
|
|
$state = [
|
|
'controlPlaneEnabled' => false,
|
|
'outboundMessagesEnabled' => false,
|
|
'operationsActionsEnabled' => false,
|
|
'bootstrapReady' => false,
|
|
];
|
|
};
|
|
|
|
expect(fn () => bird_control_plane_auto_activation::guardedActivation(
|
|
$disable,
|
|
static function (): void {
|
|
},
|
|
static function (): void {
|
|
throw new RuntimeException('simulated_webhook_failure');
|
|
},
|
|
function () use (&$enableCount): void {
|
|
$enableCount++;
|
|
},
|
|
function () use (&$finalCheckCount): void {
|
|
$finalCheckCount++;
|
|
}
|
|
))->toThrow(RuntimeException::class, 'simulated_webhook_failure');
|
|
|
|
expect($state)->toBe([
|
|
'controlPlaneEnabled' => false,
|
|
'outboundMessagesEnabled' => false,
|
|
'operationsActionsEnabled' => false,
|
|
'bootstrapReady' => false,
|
|
])->and($disableCount)->toBe(2)
|
|
->and($enableCount)->toBe(0)
|
|
->and($finalCheckCount)->toBe(0);
|
|
});
|
|
|
|
it('accepts staged activation only while every write capability remains disabled', function (): void {
|
|
$status = [
|
|
'schema' => ['ready' => true],
|
|
'providerReadReady' => true,
|
|
'moduleEnabled' => true,
|
|
'providerCredentialConfigured' => true,
|
|
'providerUrlConfigured' => true,
|
|
'workspaceId' => 'wor...pace',
|
|
'allowedChannelCount' => 1,
|
|
'participantConfigured' => true,
|
|
'controlPlaneCredentialConfigured' => true,
|
|
'webhookSigningCredentialConfigured' => true,
|
|
'webhookPublicUrlExact' => true,
|
|
'controlPlaneEnabled' => false,
|
|
'outboundMessagesEnabled' => false,
|
|
'operationsActionsEnabled' => false,
|
|
];
|
|
|
|
expect(bird_control_plane_auto_activation::stagedActivationReady($status))->toBeTrue();
|
|
$status['outboundMessagesEnabled'] = true;
|
|
expect(bird_control_plane_auto_activation::stagedActivationReady($status))->toBeFalse();
|
|
});
|
|
|
|
it('keeps Bird activation outside core API startup and pins local bootstrap without plaintext exposure', function (): void {
|
|
$repoRoot = getenv('PLENO_REPO_ROOT_FOR_TESTS');
|
|
expect($repoRoot)->toBeString()->not->toBe('');
|
|
$start = file_get_contents($repoRoot . '/services/coolify/api/start.sh');
|
|
$local = file_get_contents($repoRoot . '/scripts/bird-control-plane-bootstrap-local.sh');
|
|
$route = file_get_contents(app_path('routes/birdControlPlaneRoute.php'));
|
|
|
|
expect($start)->not->toContain('bird-control-plane-auto-activate.php')
|
|
->and($start)->toContain('php-fpm -D')
|
|
->and($start)->toContain('exec nginx')
|
|
->and($local)->toContain(
|
|
"bootstrap_url='https://api.truckwash.io:4433/bird/control-plane/v1/bootstrap'"
|
|
)
|
|
->and($local)->toContain(
|
|
"status_url='https://api.truckwash.io:4433/bird/control-plane/v1/status'"
|
|
)
|
|
->and($local)->toContain(bird_control_plane_bootstrap_sealer::KEY_FINGERPRINT)
|
|
->and($local)->toContain("destination=\"\$credential_dir/bird.gateway-token\"")
|
|
->and($local)->toContain('chmod 600 "$destination"')
|
|
->and($local)->toContain('openssl pkeyutl -decrypt')
|
|
->and($route)->toContain('/bird/control-plane/v1/bootstrap')
|
|
->and($route)->toContain("header('Cache-Control: no-store, max-age=0')")
|
|
->and($route)->toContain("\$response->error('Not found', 404)");
|
|
});
|