Files
api/services/nginx/app/tests/Unit/Bird/BirdControlPlaneActivationTest.php
T
Jeppe B 4587bdfb06 Restore API startup by isolating Bird activation (#334)
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.
2026-07-29 22:41:19 +02:00

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)");
});