feat(v5): add server reconciliation and canvas APIs

Split V5 dashboard behavior into domain controllers and policies,
add agent token rotation/revocation, status reconciliation jobs,
ingress firewall syncing, and canvas connection APIs.

Add migrations for V5 status tracking, server capabilities, resource
connection aliases, and revoked agent tokens.
This commit is contained in:
Andras Bacsai committed 2026-07-06 17:40:37 +02:00
1 parent df854feee8
commit 6ae45684f9
147 files changed
+22444 -10407

No files matched your search

+151
View File
@@ -0,0 +1,151 @@
<?php
use App\Models\V5\Server as V5Server;
use App\Services\Flux\AgentTokenIssuer;
use Firebase\JWT\JWT;
use Firebase\JWT\Key;
use Illuminate\Support\Facades\Config;
use Illuminate\Support\Facades\Log;
use Tests\TestCase;
uses(TestCase::class);
/**
* @return array{0: string, 1: string}
*/
function createAgentTokenIssuerKeypair(): array
{
$directory = storage_path('framework/testing/agent-token-keys-'.bin2hex(random_bytes(4)));
mkdir($directory, 0777, true);
$privateKeyPath = $directory.'/jwt.priv';
$publicKeyPath = $directory.'/jwt.pub';
exec('openssl genpkey -algorithm EC -pkeyopt ec_paramgen_curve:P-256 -out '.escapeshellarg($privateKeyPath), $out, $code);
expect($code)->toBe(0);
chmod($privateKeyPath, 0600);
exec('openssl pkey -in '.escapeshellarg($privateKeyPath).' -pubout -out '.escapeshellarg($publicKeyPath), $out, $code);
expect($code)->toBe(0);
return [$privateKeyPath, $publicKeyPath];
}
function decodeAgentToken(string $token, string $publicKeyPath, ?stdClass &$headers = null): stdClass
{
// Firebase's JWT::decode only populates $headers when it is non-null on
// entry (it opts out of clobbering by default), so seed a placeholder.
if (func_num_args() >= 3) {
$headers = new stdClass;
}
return JWT::decode($token, new Key(file_get_contents($publicKeyPath), 'ES256'), $headers);
}
beforeEach(function () {
[$this->privateKeyPath, $this->publicKeyPath] = createAgentTokenIssuerKeypair();
Config::set('flux.jwt_private_key_path', $this->privateKeyPath);
});
it('mints the explicit advertised capability list by default, not the wildcard profile', function () {
$token = app(AgentTokenIssuer::class)->issue('100.64.0.10');
$claims = decodeAgentToken($token, $this->publicKeyPath);
expect((array) $claims->caps)->toBe(config('flux.host_capabilities'))
->and((array) $claims->caps)->toContain('containers.create')
->and((array) $claims->caps)->toContain('ingress.apply')
->and((array) $claims->caps)->toContain('host.jwt.set')
->and((array) $claims->caps)->not->toContain('host-agent:default')
->and((array) $claims->caps)->not->toContain('host-agent:dev')
->and((array) $claims->caps)->not->toContain('*');
});
it('mints the escape-hatch capability profile when configured', function () {
Config::set('flux.host_capability_profile', 'host-agent:default');
$token = app(AgentTokenIssuer::class)->issue('100.64.0.10');
$claims = decodeAgentToken($token, $this->publicKeyPath);
expect((array) $claims->caps)->toBe(['host-agent:default']);
});
it('includes a unique jti on every minted token', function () {
$issuer = app(AgentTokenIssuer::class);
$first = decodeAgentToken($issuer->issue('100.64.0.10'), $this->publicKeyPath);
$second = decodeAgentToken($issuer->issue('100.64.0.10'), $this->publicKeyPath);
expect($first->jti)->toBeString()->not->toBe('')
->and($second->jti)->toBeString()->not->toBe('')
->and($first->jti)->not->toBe($second->jti);
});
it('derives the token lifetime from config', function () {
Config::set('flux.host_token_ttl', 1234);
$before = time();
$claims = decodeAgentToken(app(AgentTokenIssuer::class)->issue('100.64.0.10'), $this->publicKeyPath);
expect($claims->exp - $claims->iat)->toBe(1234)
->and($claims->exp)->toBeGreaterThanOrEqual($before + 1234);
});
it('defaults host token lifetime to the flux max token lifetime', function () {
$claims = decodeAgentToken(app(AgentTokenIssuer::class)->issue('100.64.0.10'), $this->publicKeyPath);
expect($claims->exp - $claims->iat)->toBe(3600)
->and(config('flux.host_token_refresh_threshold'))->toBeLessThan(config('flux.host_token_ttl'));
});
it('clamps a below-floor ttl to 60 seconds', function () {
$claims = decodeAgentToken(app(AgentTokenIssuer::class)->issue('100.64.0.10', null, 5), $this->publicKeyPath);
expect($claims->exp - $claims->iat)->toBe(60);
});
it('sets the configured kid header for future key rotation', function () {
Config::set('flux.jwt_kid', 'flux-2026');
$headers = null;
decodeAgentToken(app(AgentTokenIssuer::class)->issue('100.64.0.10'), $this->publicKeyPath, $headers);
expect($headers->kid)->toBe('flux-2026')
->and($headers->alg)->toBe('ES256');
});
it('refuses to mint a host token before the server has a stable Flux host id', function () {
$server = new V5Server([
'node_address' => '203.0.113.10',
'wireguard_management_ip' => '100.64.0.10',
]);
expect(fn () => app(AgentTokenIssuer::class)->issueForServer($server))
->toThrow(RuntimeException::class, 'Server is missing a valid Flux host id.');
});
it('mints server host tokens for the stable server uuid, not the WireGuard management IP', function () {
$server = new V5Server([
'uuid' => 'server_abc123',
'team_id' => 12,
'cluster_id' => 34,
'node_address' => '203.0.113.10',
'wireguard_management_ip' => '100.64.0.10',
]);
$claims = decodeAgentToken(app(AgentTokenIssuer::class)->issueForServer($server), $this->publicKeyPath);
expect($claims->sub)->toBe('server_abc123')
->and($claims->server_id)->toBe('server_abc123')
->and($claims->wireguard_management_ip)->toBe('100.64.0.10');
});
it('warns when the private key file has insecure permissions', function () {
chmod($this->privateKeyPath, 0644);
Log::shouldReceive('warning')
->once()
->withArgs(fn (string $message, array $context) => str_contains($message, 'insecure permissions')
&& $context['expected'] === '0600');
app(AgentTokenIssuer::class)->issue('100.64.0.10');
});
+100
View File
@@ -0,0 +1,100 @@
<?php
use App\Events\V5CanvasResourceUpdated;
use App\Events\V5ClusterUpdated;
use App\Models\V5\Application as V5Application;
use App\Models\V5\Server as V5Server;
use App\Support\V5\CanvasResourceSerializer;
use Tests\TestCase;
uses(TestCase::class);
beforeEach(function () {
resetV5DashboardTestState();
createSharedUserAndTeamTables();
});
/**
* @param array<string, mixed> $attributes
*/
function createBroadcastPayloadServer(array $attributes = []): V5Server
{
[$user, $team] = createV5UserWithTeam();
return V5Server::query()->create([
'team_id' => $team->id,
'created_by_user_id' => $user->id,
'name' => 'edge-01',
'host' => '203.0.113.10',
'ssh_user' => 'root',
'ssh_port' => 22,
'status' => 'installed',
'wireguard_management_ip' => '100.64.0.10',
...$attributes,
]);
}
it('serializes a null cluster payload when the cluster is gone before the queued broadcast runs', function () {
[, $team] = createV5UserWithTeam();
$event = new V5ClusterUpdated($team->id, 999);
expect($event->broadcastWith())->toBe(['cluster' => null]);
});
it('omits the caddy ingress payload for a non-ingress server', function () {
$server = createBroadcastPayloadServer(['is_ingress' => false]);
$event = new V5CanvasResourceUpdated($server->team_id, null, $server->id);
expect($event->broadcastWith()['caddyIngress'])->toBeNull();
});
it('serializes the caddy ingress payload for an ingress server', function () {
$server = createBroadcastPayloadServer([
'is_ingress' => true,
'ingress_type' => 'caddy',
'ingress_status' => 'running',
]);
$payload = (new V5CanvasResourceUpdated($server->team_id, null, $server->id))->broadcastWith();
expect($payload['caddyIngress'])->toBe(
app(CanvasResourceSerializer::class)->serializeCaddyIngress($server->fresh()),
)->and($payload['caddyIngress']['id'])->toBe($server->uuid);
});
it('broadcasts the same application shape as the initial-load canvas serializer', function () {
$server = createBroadcastPayloadServer();
$team = $server->team;
[$project, $environment] = createV5ProjectWithEnvironment($team, 'Payload Parity', 'production');
$application = V5Application::query()->create([
'team_id' => $team->id,
'project_id' => $project->id,
'environment_id' => $environment->id,
'server_id' => $server->id,
'created_by_user_id' => $server->created_by_user_id,
'name' => 'nginx-test',
'image' => 'docker.io/library/nginx:alpine',
'container_name' => 'coolify-v5-nginx-parity',
'status' => 'running',
'status_message' => 'Container started.',
'runtime_container_id' => 'container-123',
'ingress_enabled' => true,
'internal_port' => 8080,
]);
$application->domains()->create(['domain' => 'parity.example.com']);
$payload = (new V5CanvasResourceUpdated($team->id, $application->id))->broadcastWith();
$expected = app(CanvasResourceSerializer::class)->serializeApplication(
V5Application::query()->with(['server', 'domains'])->findOrFail($application->id),
);
expect($payload['application'])->toBe($expected)
->and($payload['application']['id'])->toBe($application->uuid)
->and($payload['application']['domains'])->toBe(['parity.example.com'])
->and($payload['application']['projectUuid'])->toBe($project->uuid)
->and($payload['application']['environmentUuid'])->toBe($environment->uuid);
});
+230 -22
View File
@@ -9,6 +9,7 @@ use App\Models\V5\Server;
use App\Services\Flux\FluxClient;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\Config;
use Illuminate\Support\Facades\Log;
use Tests\TestCase;
uses(TestCase::class);
@@ -48,6 +49,143 @@ it('generates a caddy ingress compose file with health endpoint and application
->and($configuration['apps'][0]['caddyfile'])->toContain('reverse_proxy coolify-v5-nginx-test.default.coolify.internal:8080');
});
it('generates byte-identical caddy routes for valid domains', function () {
$application = new Application([
'name' => 'nginx-test',
'container_name' => 'coolify-v5-nginx-test',
'mesh_namespace' => 'default',
'ingress_enabled' => true,
'internal_port' => 8080,
]);
$application->setRelation('domains', new Collection([
new ApplicationDomain(['domain' => 'nginx.example.com']),
new ApplicationDomain(['domain' => 'www.nginx.example.com']),
]));
$configuration = GenerateCaddyIngressConfiguration::run(new Collection([$application]));
expect($configuration['apps'][0]['caddyfile'])->toBe(implode("\n", [
'http://nginx.example.com {',
' reverse_proxy coolify-v5-nginx-test.default.coolify.internal:8080',
'}',
'',
'http://www.nginx.example.com {',
' reverse_proxy coolify-v5-nginx-test.default.coolify.internal:8080',
'}',
]));
});
it('never emits domains with caddyfile injection payloads into the configuration', function () {
Log::spy();
$application = new Application([
'name' => 'nginx-test',
'container_name' => 'coolify-v5-nginx-test',
'mesh_namespace' => 'default',
'ingress_enabled' => true,
'internal_port' => 8080,
]);
$application->setRelation('domains', new Collection([
new ApplicationDomain(['domain' => "evil.com {\n} http://x"]),
new ApplicationDomain(['domain' => "evil\n.com"]),
new ApplicationDomain(['domain' => 'safe.example.com']),
]));
$configuration = GenerateCaddyIngressConfiguration::run(new Collection([$application]));
expect($configuration['apps'])->toHaveCount(1)
->and($configuration['apps'][0]['caddyfile'])->not->toContain('evil')
->and($configuration['apps'][0]['caddyfile'])->toBe(implode("\n", [
'http://safe.example.com {',
' reverse_proxy coolify-v5-nginx-test.default.coolify.internal:8080',
'}',
]));
Log::shouldHaveReceived('warning')
->with('Skipping a caddy ingress route with an unsafe domain.', Mockery::type('array'))
->twice();
});
it('skips whole applications when every domain is malicious', function () {
Log::spy();
$application = new Application([
'name' => 'nginx-test',
'container_name' => 'coolify-v5-nginx-test',
'mesh_namespace' => 'default',
'ingress_enabled' => true,
'internal_port' => 8080,
]);
$application->setRelation('domains', new Collection([
new ApplicationDomain(['domain' => 'evil.com { respond "pwned" }']),
]));
$configuration = GenerateCaddyIngressConfiguration::run(new Collection([$application]));
expect($configuration['apps'])->toBe([]);
Log::shouldHaveReceived('warning')
->with('Skipping a caddy ingress route with an unsafe domain.', Mockery::type('array'))
->once();
});
it('never emits unsafe container names or namespaces into the configuration', function () {
Log::spy();
$application = new Application([
'name' => 'nginx-test',
'container_name' => "coolify {\n} injected",
'mesh_namespace' => 'default',
'ingress_enabled' => true,
'internal_port' => 8080,
]);
$application->setRelation('domains', new Collection([
new ApplicationDomain(['domain' => 'app.example.com']),
]));
$namespaceApplication = new Application([
'name' => 'nginx-test-2',
'container_name' => 'coolify-v5-nginx-test',
'mesh_namespace' => "default {\n}",
'ingress_enabled' => true,
'internal_port' => 8080,
]);
$namespaceApplication->setRelation('domains', new Collection([
new ApplicationDomain(['domain' => 'other.example.com']),
]));
$configuration = GenerateCaddyIngressConfiguration::run(new Collection([$application, $namespaceApplication]));
expect($configuration['apps'])->toBe([]);
Log::shouldHaveReceived('warning')
->with('Skipping a caddy ingress route with an unsafe container name or namespace.', Mockery::type('array'))
->twice();
});
it('never emits out-of-range internal ports into the configuration', function () {
Log::spy();
$application = new Application([
'name' => 'nginx-test',
'container_name' => 'coolify-v5-nginx-test',
'mesh_namespace' => 'default',
'ingress_enabled' => true,
]);
$application->internal_port = 70000;
$application->setRelation('domains', new Collection([
new ApplicationDomain(['domain' => 'app.example.com']),
]));
$configuration = GenerateCaddyIngressConfiguration::run(new Collection([$application]));
expect($configuration['apps'])->toBe([]);
Log::shouldHaveReceived('warning')
->with('Skipping a caddy ingress route with an out-of-range internal port.', Mockery::type('array'))
->once();
});
it('does not generate app routes for applications without domains', function () {
$application = new Application([
'name' => 'private-app',
@@ -103,6 +241,7 @@ it('does not generate app routes when the internal port is missing', function ()
it('applies caddy ingress configuration through flux instead of ssh', function () {
$server = new Server([
'uuid' => 'test-server-uuid',
'wireguard_management_ip' => '100.64.0.10',
'node_address' => '10.0.0.10',
'capabilities' => ['ingress'],
@@ -113,7 +252,7 @@ it('applies caddy ingress configuration through flux instead of ssh', function (
->shouldReceive('applyIngress')
->once()
->with(
'100.64.0.10',
Mockery::type('string'),
'caddy',
Mockery::on(fn (string $caddyfile): bool => str_contains($caddyfile, 'respond /coolify-health 200')),
[]
@@ -122,7 +261,7 @@ it('applies caddy ingress configuration through flux instead of ssh', function (
$fluxClient
->shouldReceive('applyFirewallRule')
->once()
->with('100.64.0.10', [
->with(Mockery::type('string'), [
'id' => 'v5-caddy-ingress:80',
'namespace' => 'default',
'src' => '0.0.0.0/0',
@@ -132,17 +271,8 @@ it('applies caddy ingress configuration through flux instead of ssh', function (
])
->andReturn('Firewall rule applied.');
$fluxClient
->shouldReceive('applyFirewallRule')
->once()
->with('100.64.0.10', [
'id' => 'v5-caddy-ingress:443',
'namespace' => 'default',
'src' => '0.0.0.0/0',
'dst' => 'coolify-v5-caddy',
'proto' => 'tcp',
'port' => 443,
])
->andReturn('Firewall rule applied.');
->shouldNotReceive('applyFirewallRule')
->with(Mockery::type('string'), Mockery::on(fn (array $rule): bool => ($rule['port'] ?? null) === 443));
app()->instance(FluxClient::class, $fluxClient);
$result = StartCaddyIngress::run($server);
@@ -162,27 +292,26 @@ it('does not start caddy ingress for non-ingress servers', function () {
it('stops caddy ingress through flux instead of ssh', function () {
$server = new Server([
'uuid' => 'test-server-uuid',
'wireguard_management_ip' => '100.64.0.10',
'node_address' => '10.0.0.10',
'capabilities' => [],
'capabilities' => ['ingress'],
]);
$fluxClient = Mockery::mock(FluxClient::class);
$fluxClient
->shouldReceive('stopIngress')
->once()
->with('100.64.0.10', 'caddy')
->with(Mockery::type('string'), 'caddy')
->andReturn('Caddy ingress stopped.');
$fluxClient
->shouldReceive('revokeFirewallRule')
->once()
->with('100.64.0.10', 'v5-caddy-ingress:80')
->with(Mockery::type('string'), 'v5-caddy-ingress:80')
->andReturn('Firewall rule removed.');
$fluxClient
->shouldReceive('revokeFirewallRule')
->once()
->with('100.64.0.10', 'v5-caddy-ingress:443')
->andReturn('Firewall rule removed.');
->shouldNotReceive('revokeFirewallRule')
->with(Mockery::type('string'), 'v5-caddy-ingress:443');
app()->instance(FluxClient::class, $fluxClient);
$result = StopCaddyIngress::run($server);
@@ -190,6 +319,78 @@ it('stops caddy ingress through flux instead of ssh', function () {
expect($result)->toBe('Caddy ingress stopped.');
});
it('does not stop caddy ingress for servers that never had ingress', function () {
$server = new Server([
'uuid' => 'test-server-uuid',
'wireguard_management_ip' => '100.64.0.10',
'capabilities' => [],
]);
$fluxClient = Mockery::mock(FluxClient::class);
$fluxClient->shouldNotReceive('stopIngress');
$fluxClient->shouldNotReceive('revokeFirewallRule');
app()->instance(FluxClient::class, $fluxClient);
expect(StopCaddyIngress::run($server))->toBe('Server is not an ingress server.');
});
it('revokes the caddy firewall rule before stopping the ingress container', function () {
$server = new Server([
'uuid' => 'test-server-uuid',
'wireguard_management_ip' => '100.64.0.10',
'capabilities' => ['ingress'],
]);
$fluxClient = Mockery::mock(FluxClient::class);
$fluxClient
->shouldReceive('revokeFirewallRule')
->once()
->with(Mockery::type('string'), 'v5-caddy-ingress:80')
->ordered()
->andReturn('Firewall rule removed.');
$fluxClient
->shouldReceive('stopIngress')
->once()
->with(Mockery::type('string'), 'caddy')
->ordered()
->andThrow(new RuntimeException('podman rm failed'));
app()->instance(FluxClient::class, $fluxClient);
expect(fn () => StopCaddyIngress::run($server))->toThrow(RuntimeException::class, 'podman rm failed');
});
it('tolerates already-revoked caddy firewall rules when stopping ingress', function () {
$server = new Server([
'uuid' => 'test-server-uuid',
'wireguard_management_ip' => '100.64.0.10',
'capabilities' => ['ingress'],
]);
$fluxClient = Mockery::mock(FluxClient::class);
$fluxClient
->shouldReceive('revokeFirewallRule')
->once()
->andThrow(new RuntimeException('firewall rule not found'));
$fluxClient
->shouldReceive('stopIngress')
->once()
->andReturn('Caddy ingress stopped.');
app()->instance(FluxClient::class, $fluxClient);
expect(StopCaddyIngress::run($server))->toBe('Caddy ingress stopped.');
});
it('reports an unknown ingress status until coold confirms one', function () {
$installed = new Server(['capabilities' => ['ingress']]);
$installed->status = 'installed';
expect($installed->ingressStatus())->toBe('unknown');
$running = new Server(['capabilities' => ['ingress'], 'ingress_status' => 'running']);
expect($running->ingressStatus())->toBe('running');
});
it('includes flux error response details when dispatch returns a non success status', function () {
if (! function_exists('pcntl_fork')) {
$this->markTestSkipped('pcntl is required to fake a Flux Unix socket.');
@@ -315,6 +516,8 @@ it('dispatches image pull and container lifecycle primitives for v5 apps', funct
['request_id' => 'test-request', 'status' => 'ok', 'data' => ['id' => 'container-123']],
['request_id' => 'test-request', 'status' => 'ok', 'data' => ['output' => 'Container started.']],
['request_id' => 'test-request', 'status' => 'ok', 'data' => ['State' => ['Running' => true]]],
['request_id' => 'test-request', 'status' => 'ok', 'data' => ['output' => 'Container stopped.']],
['request_id' => 'test-request', 'status' => 'ok', 'data' => ['output' => 'Container removed.']],
];
$requestPath = storage_path('framework/testing/flux-request-'.bin2hex(random_bytes(8)).'.txt');
@@ -332,7 +535,9 @@ it('dispatches image pull and container lifecycle primitives for v5 apps', funct
'restart_policy' => 'unless-stopped',
]))->toBe('container-123')
->and($fluxClient->startContainer('100.64.0.10', 'container-123'))->toBe('Container started.')
->and($fluxClient->inspectContainer('100.64.0.10', 'container-123'))->toBe(['State' => ['Running' => true]]);
->and($fluxClient->inspectContainer('100.64.0.10', 'container-123'))->toBe(['State' => ['Running' => true]])
->and($fluxClient->stopContainer('100.64.0.10', 'container-123'))->toBe('Container stopped.')
->and($fluxClient->removeContainer('100.64.0.10', 'container-123', true))->toBe('Container removed.');
});
$request = file_get_contents($requestPath) ?: '';
@@ -343,7 +548,10 @@ it('dispatches image pull and container lifecycle primitives for v5 apps', funct
->toContain('"network_aliases":["coolify-v5-nginx-test"]')
->toContain('"dns_search":["default.coolify.internal"]')
->toContain('"type":"containers.start"')
->toContain('"type":"containers.inspect"');
->toContain('"type":"containers.inspect"')
->toContain('"type":"containers.stop"')
->toContain('"type":"containers.delete"')
->toContain('"force":true');
});
it('dispatches firewall allow through the firewall allow primitive', function () {
+314
View File
@@ -0,0 +1,314 @@
<?php
use App\Actions\V5\Proxy\StartCaddyIngress;
use App\Actions\V5\Proxy\StopCaddyIngress;
use App\Exceptions\V5\UnsupportedCooldVerb;
use App\Models\V5\Server;
use App\Services\Flux\FluxClient;
use App\Support\V5\ConnectionFirewallSync;
use Illuminate\Support\Facades\Config;
use Illuminate\Support\Facades\Event;
use Illuminate\Support\Facades\Schema;
use Tests\Support\V5TestSchema;
use Tests\TestCase;
uses(TestCase::class);
it('detects unsupported coold verbs from flux http 501 responses', function () {
if (! function_exists('pcntl_fork')) {
$this->markTestSkipped('pcntl is required to fake a Flux Unix socket.');
}
// Flux rejects verbs the node's coold did not advertise with HTTP 501 and
// "primitive <verb> is not supported by host" (coold repo:
// flux/src/routing.rs:50-53, flux/src/unix_bridge.rs:227-245).
$body = json_encode([
'request_id' => 'test-request',
'status' => 'error',
'code' => 501,
'message' => 'primitive firewall.allow is not supported by host',
], JSON_THROW_ON_ERROR);
$caught = null;
withCooldVerbContractFluxSocket(
"HTTP/1.1 501 Not Implemented\r\n".
"Content-Type: application/json\r\n".
'Content-Length: '.strlen($body)."\r\n".
"\r\n".
$body,
function () use (&$caught): void {
try {
(new FluxClient)->applyFirewallRule('100.64.0.10', [
'id' => 'rule-1',
'namespace' => 'default',
'src' => '0.0.0.0/0',
'dst' => 'coolify-v5-caddy',
'proto' => 'tcp',
'port' => 80,
]);
} catch (RuntimeException $exception) {
$caught = $exception;
}
}
);
expect($caught)->toBeInstanceOf(UnsupportedCooldVerb::class)
->and($caught->verb)->toBe('firewall.allow')
->and($caught->getMessage())->toBe('primitive firewall.allow is not supported by host');
});
it('detects unsupported coold verbs from the flux message even without a 501 status', function () {
if (! function_exists('pcntl_fork')) {
$this->markTestSkipped('pcntl is required to fake a Flux Unix socket.');
}
$body = json_encode([
'request_id' => 'test-request',
'status' => 'error',
'code' => 502,
'message' => 'primitive coold.logs is not supported by host',
], JSON_THROW_ON_ERROR);
$caught = null;
withCooldVerbContractFluxSocket(
"HTTP/1.1 502 Bad Gateway\r\n".
"Content-Type: application/json\r\n".
'Content-Length: '.strlen($body)."\r\n".
"\r\n".
$body,
function () use (&$caught): void {
try {
(new FluxClient)->cooldLogs('100.64.0.10');
} catch (RuntimeException $exception) {
$caught = $exception;
}
}
);
expect($caught)->toBeInstanceOf(UnsupportedCooldVerb::class)
->and($caught->verb)->toBe('coold.logs');
});
it('keeps generic flux dispatch failures as plain runtime exceptions', function () {
if (! function_exists('pcntl_fork')) {
$this->markTestSkipped('pcntl is required to fake a Flux Unix socket.');
}
$body = json_encode([
'request_id' => 'test-request',
'status' => 'error',
'code' => 500,
'message' => 'apply firewall rule: nft exited with status 1',
], JSON_THROW_ON_ERROR);
$caught = null;
withCooldVerbContractFluxSocket(
"HTTP/1.1 500 Internal Server Error\r\n".
"Content-Type: application/json\r\n".
'Content-Length: '.strlen($body)."\r\n".
"\r\n".
$body,
function () use (&$caught): void {
try {
(new FluxClient)->applyFirewallRule('100.64.0.10', [
'id' => 'rule-1',
'namespace' => 'default',
'src' => '0.0.0.0/0',
'dst' => 'coolify-v5-caddy',
'proto' => 'tcp',
'port' => 80,
]);
} catch (RuntimeException $exception) {
$caught = $exception;
}
}
);
expect($caught)->toBeInstanceOf(RuntimeException::class)
->not->toBeInstanceOf(UnsupportedCooldVerb::class)
->and($caught->getMessage())->toBe('apply firewall rule: nft exited with status 1');
});
it('marks caddy ingress running with a warning when the node coold lacks firewall support', function () {
Event::fake();
Schema::dropIfExists('v5_applications');
Schema::dropIfExists('v5_servers');
V5TestSchema::createServersTable();
V5TestSchema::createApplicationsTable();
$server = Server::query()->create([
'uuid' => 'cooldverb-ingress-server-uuid',
'team_id' => 1,
'created_by_user_id' => 1,
'name' => 'ingress-01',
'host' => '203.0.113.10',
'ssh_user' => 'root',
'ssh_port' => 22,
'capabilities' => ['ingress'],
'uuid' => 'test-server-uuid',
'wireguard_management_ip' => '100.64.0.10',
]);
$fluxClient = Mockery::mock(FluxClient::class);
$fluxClient
->shouldReceive('applyIngress')
->once()
->andReturn('Caddy ingress applied.');
$fluxClient
->shouldReceive('applyFirewallRule')
->once()
->andThrow(new UnsupportedCooldVerb('firewall.allow', 'primitive firewall.allow is not supported by host'));
app()->instance(FluxClient::class, $fluxClient);
$result = StartCaddyIngress::run($server);
$server->refresh();
expect($result)->toBe('Caddy ingress applied.')
->and($server->ingress_type)->toBe('caddy')
->and($server->ingress_status)->toBe('running')
->and($server->last_status_check)->toBe('flux')
->and($server->last_status_output)->toContain('does not support firewall.allow');
Schema::dropIfExists('v5_applications');
Schema::dropIfExists('v5_servers');
});
it('still surfaces supported-but-failed firewall errors when starting caddy ingress', function () {
$server = new Server([
'uuid' => 'test-server-uuid',
'wireguard_management_ip' => '100.64.0.10',
'capabilities' => ['ingress'],
]);
$fluxClient = Mockery::mock(FluxClient::class);
$fluxClient
->shouldReceive('applyIngress')
->once()
->andReturn('Caddy ingress applied.');
$fluxClient
->shouldReceive('applyFirewallRule')
->once()
->andThrow(new RuntimeException('apply firewall rule: nft exited with status 1'));
app()->instance(FluxClient::class, $fluxClient);
expect(fn () => StartCaddyIngress::run($server))
->toThrow(RuntimeException::class, 'apply firewall rule: nft exited with status 1');
});
it('stops caddy ingress even when the node coold lacks firewall support', function () {
$server = new Server([
'uuid' => 'test-server-uuid',
'wireguard_management_ip' => '100.64.0.10',
'capabilities' => ['ingress'],
]);
$fluxClient = Mockery::mock(FluxClient::class);
$fluxClient
->shouldReceive('revokeFirewallRule')
->once()
->andThrow(new UnsupportedCooldVerb('firewall.revoke', 'primitive firewall.revoke is not supported by host'));
$fluxClient
->shouldReceive('stopIngress')
->once()
->with(Mockery::type('string'), 'caddy')
->andReturn('Caddy ingress stopped.');
app()->instance(FluxClient::class, $fluxClient);
expect(StopCaddyIngress::run($server))->toBe('Caddy ingress stopped.');
});
it('skips resource connection firewall sync rules when the node coold lacks firewall support', function () {
$sync = new ConnectionFirewallSync;
$oldRule = [
'id' => 'v5-resource-connection:1:1:2:tcp:5432',
'hostId' => '100.64.0.11',
'rule' => [
'id' => 'v5-resource-connection:1:1:2:tcp:5432',
'namespace' => 'default',
'src' => 'coolify-v5-api',
'dst' => 'coolify-v5-postgres',
'proto' => 'tcp',
'port' => 5432,
],
];
$newRule = [
'id' => 'v5-resource-connection:1:1:2:tcp:6379',
'hostId' => '100.64.0.10',
'rule' => [
'id' => 'v5-resource-connection:1:1:2:tcp:6379',
'namespace' => 'default',
'src' => 'coolify-v5-api',
'dst' => 'coolify-v5-redis',
'proto' => 'tcp',
'port' => 6379,
],
];
$fluxClient = Mockery::mock(FluxClient::class);
$fluxClient
->shouldReceive('revokeFirewallRule')
->once()
->with(Mockery::type('string'), $oldRule['id'])
->andThrow(new UnsupportedCooldVerb('firewall.revoke', 'primitive firewall.revoke is not supported by host'));
$fluxClient
->shouldReceive('applyFirewallRule')
->once()
->with(Mockery::type('string'), $newRule['rule'])
->andThrow(new UnsupportedCooldVerb('firewall.allow', 'primitive firewall.allow is not supported by host'));
$sync->sync($fluxClient, collect([$oldRule]), collect([$newRule]));
expect(true)->toBeTrue();
});
function withCooldVerbContractFluxSocket(string $response, Closure $callback): void
{
$directory = storage_path('framework/testing');
if (! is_dir($directory)) {
mkdir($directory, 0777, true);
}
$socketPath = $directory.'/flux-'.bin2hex(random_bytes(8)).'.sock';
$socketServer = stream_socket_server("unix://{$socketPath}", $errorCode, $errorMessage);
expect($socketServer)->not->toBeFalse("Could not create fake Flux socket: {$errorMessage} ({$errorCode})");
$pid = pcntl_fork();
if ($pid === 0) {
$connection = stream_socket_accept($socketServer, 5);
if ($connection !== false) {
$request = '';
while (! str_contains($request, "\r\n\r\n") && ! feof($connection)) {
$request .= fread($connection, 8192);
}
fwrite($connection, $response);
fclose($connection);
}
fclose($socketServer);
exit(0);
}
fclose($socketServer);
Config::set('flux.unix_socket_path', $socketPath);
Config::set('flux.connection_timeout_seconds', 1.0);
Config::set('flux.dispatch_timeout_seconds', 1.0);
try {
$callback();
} finally {
pcntl_waitpid($pid, $status);
@unlink($socketPath);
}
}
@@ -2,7 +2,12 @@
use App\Actions\V5\Application\DeployNginxApplication;
use App\Models\V5\Application;
use App\Models\V5\Server;
use App\Services\Flux\FluxClient;
use Illuminate\Support\Facades\Event;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Facades\Schema;
use Tests\Support\V5TestSchema;
use Tests\TestCase;
uses(TestCase::class);
@@ -33,3 +38,205 @@ it('builds an nginx container spec for the coold mesh runtime', function () {
->not->toHaveKey('command')
->not->toHaveKey('privileged');
});
it('stops and removes the created container when starting it fails', function () {
Event::fake();
$application = createNginxDeploymentApplication();
$fluxClient = Mockery::mock(FluxClient::class);
$fluxClient->shouldReceive('pullImage')->once()->andReturn('Image pulled.');
$fluxClient->shouldReceive('createContainer')->once()->andReturn('container-123');
$fluxClient
->shouldReceive('startContainer')
->once()
->with(Mockery::type('string'), 'container-123')
->andThrow(new RuntimeException('podman start exited with status 125'));
$fluxClient
->shouldReceive('stopContainer')
->once()
->with(Mockery::type('string'), 'container-123')
->andReturn('Container stopped.');
$fluxClient
->shouldReceive('removeContainer')
->once()
->with(Mockery::type('string'), 'container-123', true)
->andReturn('Container removed.');
$result = (new DeployNginxApplication($fluxClient))->handle($application);
expect($result->status)->toBe('failed')
->and($result->status_message)->toBe('podman start exited with status 125')
->and($result->runtime_container_id)->toBeNull();
});
it('does not clean up any container when creating the container fails', function () {
Event::fake();
$application = createNginxDeploymentApplication();
$fluxClient = Mockery::mock(FluxClient::class);
$fluxClient->shouldReceive('pullImage')->once()->andReturn('Image pulled.');
$fluxClient
->shouldReceive('createContainer')
->once()
->andThrow(new RuntimeException('Flux did not return a container id.'));
$fluxClient->shouldNotReceive('startContainer');
$fluxClient->shouldNotReceive('stopContainer');
$fluxClient->shouldNotReceive('removeContainer');
$result = (new DeployNginxApplication($fluxClient))->handle($application);
expect($result->status)->toBe('failed')
->and($result->status_message)->toBe('Flux did not return a container id.')
->and($result->runtime_container_id)->toBeNull();
});
it('keeps the original deploy failure and logs a warning when cleanup itself fails', function () {
Event::fake();
Log::spy();
$application = createNginxDeploymentApplication();
$fluxClient = Mockery::mock(FluxClient::class);
$fluxClient->shouldReceive('pullImage')->once()->andReturn('Image pulled.');
$fluxClient->shouldReceive('createContainer')->once()->andReturn('container-123');
$fluxClient
->shouldReceive('startContainer')
->once()
->andThrow(new RuntimeException('original deploy failure'));
$fluxClient
->shouldReceive('stopContainer')
->once()
->andThrow(new RuntimeException('stop failed'));
$fluxClient
->shouldReceive('removeContainer')
->once()
->andThrow(new RuntimeException('remove failed'));
$result = (new DeployNginxApplication($fluxClient))->handle($application);
expect($result->status)->toBe('failed')
->and($result->status_message)->toBe('original deploy failure')
->and($result->runtime_container_id)->toBeNull();
Log::shouldHaveReceived('warning')
->with('Could not stop the container created by a failed v5 deploy.', Mockery::type('array'))
->once();
Log::shouldHaveReceived('warning')
->with('Could not remove the container created by a failed v5 deploy.', Mockery::type('array'))
->once();
});
it('cleans up the container when it does not stay running after start', function () {
Event::fake();
$application = createNginxDeploymentApplication();
$fluxClient = Mockery::mock(FluxClient::class);
$fluxClient->shouldReceive('pullImage')->once()->andReturn('Image pulled.');
$fluxClient->shouldReceive('createContainer')->once()->andReturn('container-123');
$fluxClient->shouldReceive('startContainer')->once()->andReturn('Container started.');
$fluxClient
->shouldReceive('inspectContainer')
->once()
->andReturn(['State' => ['Running' => false]]);
$fluxClient
->shouldReceive('stopContainer')
->once()
->with(Mockery::type('string'), 'container-123')
->andReturn('Container stopped.');
$fluxClient
->shouldReceive('removeContainer')
->once()
->with(Mockery::type('string'), 'container-123', true)
->andReturn('Container removed.');
$result = (new DeployNginxApplication($fluxClient))->handle($application);
expect($result->status)->toBe('failed')
->and($result->status_message)->toBe('Container did not stay running.')
->and($result->runtime_container_id)->toBeNull();
});
it('persists the container id only for a successful deploy', function () {
Event::fake();
$application = createNginxDeploymentApplication();
$fluxClient = Mockery::mock(FluxClient::class);
$fluxClient->shouldReceive('pullImage')->once()->andReturn('Image pulled.');
$fluxClient->shouldReceive('createContainer')->once()->andReturn('container-123');
$fluxClient->shouldReceive('startContainer')->once()->andReturn('Container started.');
$fluxClient
->shouldReceive('inspectContainer')
->once()
->andReturn(['State' => ['Running' => true]]);
$fluxClient->shouldNotReceive('stopContainer');
$fluxClient->shouldNotReceive('removeContainer');
$result = (new DeployNginxApplication($fluxClient))->handle($application);
expect($result->status)->toBe('running')
->and($result->runtime_container_id)->toBe('container-123');
});
it('persists the runtime container id immediately after create, before start', function () {
Event::fake();
$application = createNginxDeploymentApplication();
$fluxClient = Mockery::mock(FluxClient::class);
$fluxClient->shouldReceive('pullImage')->once()->andReturn('Image pulled.');
$fluxClient->shouldReceive('createContainer')->once()->andReturn('container-123');
$persistedBeforeStart = null;
$fluxClient
->shouldReceive('startContainer')
->once()
->andReturnUsing(function () use ($application, &$persistedBeforeStart) {
// Read the row straight from the database to prove the id is durable
// before start runs, not just held in the in-memory model.
$persistedBeforeStart = Application::query()->find($application->getKey())?->runtime_container_id;
throw new RuntimeException('podman start exited with status 125');
});
$fluxClient->shouldReceive('stopContainer')->once()->andReturn('Container stopped.');
$fluxClient->shouldReceive('removeContainer')->once()->andReturn('Container removed.');
$result = (new DeployNginxApplication($fluxClient))->handle($application);
expect($persistedBeforeStart)->toBe('container-123')
->and($result->status)->toBe('failed')
->and($result->runtime_container_id)->toBeNull();
});
function createNginxDeploymentApplication(): Application
{
Schema::dropIfExists('v5_applications');
Schema::dropIfExists('v5_servers');
V5TestSchema::createServersTable();
V5TestSchema::createApplicationsTable();
$server = Server::query()->forceCreate([
'uuid' => 'nginx-deployment-server-uuid',
'team_id' => 1,
'created_by_user_id' => 1,
'name' => 'node-01',
'host' => '203.0.113.10',
'ssh_user' => 'root',
'ssh_port' => 22,
'status' => 'installed',
'wireguard_management_ip' => '100.64.0.10',
'last_bootstrapped_at' => now(),
]);
return Application::query()->forceCreate([
'uuid' => 'nginx-deployment-application-uuid',
'team_id' => 1,
'project_id' => 1,
'environment_id' => 1,
'created_by_user_id' => 1,
'server_id' => $server->id,
'name' => 'nginx-test',
'image' => 'docker.io/library/nginx:alpine',
'container_name' => 'coolify-v5-nginx-test',
'status' => 'creating',
'mesh_namespace' => 'default',
]);
}
+253
View File
@@ -0,0 +1,253 @@
<?php
use App\Models\Team;
use App\Models\User;
use App\Models\V5\Application as V5Application;
use App\Models\V5\Cluster;
use App\Models\V5\ResourceConnection;
use App\Models\V5\Server as V5Server;
use App\Policies\V5\ApplicationPolicy;
use App\Policies\V5\ClusterPolicy;
use App\Policies\V5\ResourceConnectionPolicy;
use App\Policies\V5\ServerPolicy;
use Illuminate\Database\Eloquent\Relations\Pivot;
use Tests\TestCase;
uses(TestCase::class);
/**
* Build a user whose team membership carries the given role, so the policies'
* $user->isAdminOfTeam() role gate can be exercised without touching the DB.
*/
function v5PolicyUser(string $role = 'owner', int $teamId = 10): User
{
$user = (new User)->forceFill(['id' => 1]);
$team = (new Team)->forceFill(['id' => $teamId]);
$pivot = new Pivot;
$pivot->role = $role;
$team->setRelation('pivot', $pivot);
$user->setRelation('teams', collect([$team]));
return $user;
}
function v5PolicyMemberUser(int $teamId = 10): User
{
return v5PolicyUser('member', $teamId);
}
function v5PolicyTeam(int $id = 10): Team
{
return (new Team)->forceFill(['id' => $id]);
}
it('allows adding a server to a cluster of the current team', function () {
$team = v5PolicyTeam();
$cluster = (new Cluster)->forceFill(['id' => 5, 'team_id' => $team->id]);
$response = (new ServerPolicy)->create(v5PolicyUser(), $team, $cluster);
expect($response->allowed())->toBeTrue();
});
it('denies adding a server to another teams cluster as forbidden instead of not found', function () {
$team = v5PolicyTeam(10);
$foreignCluster = (new Cluster)->forceFill(['id' => 5, 'team_id' => 99]);
$response = (new ServerPolicy)->create(v5PolicyUser(), $team, $foreignCluster);
// A plain deny carries no status override, so the framework renders the
// historical 403 instead of a 404.
expect($response->denied())->toBeTrue()
->and($response->status())->toBeNull();
});
it('denies server update and delete outside the current team as not found', function () {
$team = v5PolicyTeam(10);
$foreignCluster = (new Cluster)->forceFill(['id' => 5, 'team_id' => 99]);
$foreignServer = (new V5Server)->forceFill(['id' => 7, 'team_id' => 99, 'cluster_id' => 5]);
$policy = new ServerPolicy;
$update = $policy->update(v5PolicyUser(), $foreignServer, $team, $foreignCluster);
$delete = $policy->delete(v5PolicyUser(), $foreignServer, $team, $foreignCluster);
expect($update->denied())->toBeTrue()
->and($update->status())->toBe(404)
->and($delete->denied())->toBeTrue()
->and($delete->status())->toBe(404);
});
it('denies server update for a server outside the addressed cluster as not found', function () {
$team = v5PolicyTeam(10);
$cluster = (new Cluster)->forceFill(['id' => 5, 'team_id' => $team->id]);
$serverInOtherCluster = (new V5Server)->forceFill(['id' => 7, 'team_id' => $team->id, 'cluster_id' => 6]);
$response = (new ServerPolicy)->update(v5PolicyUser(), $serverInOtherCluster, $team, $cluster);
expect($response->denied())->toBeTrue()
->and($response->status())->toBe(404);
});
it('allows moving the caddy ingress canvas card only for ingress servers of the current team', function () {
$team = v5PolicyTeam(10);
$policy = new ServerPolicy;
$ingressServer = (new V5Server)->forceFill(['id' => 7, 'team_id' => $team->id, 'is_ingress' => true]);
expect($policy->updateCanvasPosition(v5PolicyUser(), $ingressServer, $team)->allowed())->toBeTrue();
});
it('denies moving the canvas card of a non-ingress server as not found', function () {
$team = v5PolicyTeam(10);
$nonIngressServer = (new V5Server)->forceFill(['id' => 7, 'team_id' => $team->id, 'is_ingress' => false]);
$response = (new ServerPolicy)->updateCanvasPosition(v5PolicyUser(), $nonIngressServer, $team);
expect($response->denied())->toBeTrue()
->and($response->status())->toBe(404);
});
it('denies moving another teams ingress canvas card as not found', function () {
$team = v5PolicyTeam(10);
$foreignIngressServer = (new V5Server)->forceFill(['id' => 7, 'team_id' => 99, 'is_ingress' => true]);
$response = (new ServerPolicy)->updateCanvasPosition(v5PolicyUser(), $foreignIngressServer, $team);
expect($response->denied())->toBeTrue()
->and($response->status())->toBe(404);
});
it('scopes cluster viewing and deletion to the current team with not-found denials', function () {
$team = v5PolicyTeam(10);
$policy = new ClusterPolicy;
$ownCluster = (new Cluster)->forceFill(['id' => 5, 'team_id' => $team->id]);
$foreignCluster = (new Cluster)->forceFill(['id' => 6, 'team_id' => 99]);
expect($policy->view(v5PolicyUser(), $ownCluster, $team)->allowed())->toBeTrue()
->and($policy->delete(v5PolicyUser(), $ownCluster, $team)->allowed())->toBeTrue();
$view = $policy->view(v5PolicyUser(), $foreignCluster, $team);
$delete = $policy->delete(v5PolicyUser(), $foreignCluster, $team);
expect($view->denied())->toBeTrue()
->and($view->status())->toBe(404)
->and($delete->denied())->toBeTrue()
->and($delete->status())->toBe(404);
});
it('scopes application mutations to the current team with not-found denials', function () {
$team = v5PolicyTeam(10);
$policy = new ApplicationPolicy;
$ownApplication = (new V5Application)->forceFill(['id' => 3, 'team_id' => $team->id]);
$foreignApplication = (new V5Application)->forceFill(['id' => 4, 'team_id' => 99]);
expect($policy->update(v5PolicyUser(), $ownApplication, $team)->allowed())->toBeTrue()
->and($policy->updateIngress(v5PolicyUser(), $ownApplication, $team)->allowed())->toBeTrue()
->and($policy->delete(v5PolicyUser(), $ownApplication, $team)->allowed())->toBeTrue();
foreach (['update', 'updateIngress', 'delete'] as $ability) {
$response = $policy->{$ability}(v5PolicyUser(), $foreignApplication, $team);
expect($response->denied())->toBeTrue()
->and($response->status())->toBe(404);
}
});
it('scopes resource connection mutations to the current team with not-found denials', function () {
$team = v5PolicyTeam(10);
$policy = new ResourceConnectionPolicy;
$ownConnection = (new ResourceConnection)->forceFill(['id' => 3, 'team_id' => $team->id]);
$foreignConnection = (new ResourceConnection)->forceFill(['id' => 4, 'team_id' => 99]);
expect($policy->update(v5PolicyUser(), $ownConnection, $team)->allowed())->toBeTrue()
->and($policy->delete(v5PolicyUser(), $ownConnection, $team)->allowed())->toBeTrue();
foreach (['update', 'delete'] as $ability) {
$response = $policy->{$ability}(v5PolicyUser(), $foreignConnection, $team);
expect($response->denied())->toBeTrue()
->and($response->status())->toBe(404);
}
});
it('denies server mutations for a member of the current team as forbidden not not-found', function () {
$team = v5PolicyTeam(10);
$cluster = (new Cluster)->forceFill(['id' => 5, 'team_id' => $team->id]);
$server = (new V5Server)->forceFill(['id' => 7, 'team_id' => $team->id, 'cluster_id' => 5, 'is_ingress' => true]);
$member = v5PolicyMemberUser();
$policy = new ServerPolicy;
expect($policy->create($member, $team, $cluster)->denied())->toBeTrue()
->and($policy->create($member, $team, $cluster)->status())->toBeNull();
foreach (['update', 'delete', 'check', 'bootstrap'] as $ability) {
$response = $policy->{$ability}($member, $server, $team, $cluster);
expect($response->denied())->toBeTrue()
->and($response->status())->toBeNull();
}
expect($policy->updateCanvasPosition($member, $server, $team)->denied())->toBeTrue()
->and($policy->updateCanvasPosition($member, $server, $team)->status())->toBeNull();
});
it('still lets a member read server diagnostics of the current team', function () {
$team = v5PolicyTeam(10);
$cluster = (new Cluster)->forceFill(['id' => 5, 'team_id' => $team->id]);
$server = (new V5Server)->forceFill(['id' => 7, 'team_id' => $team->id, 'cluster_id' => 5]);
expect((new ServerPolicy)->viewDiagnostics(v5PolicyMemberUser(), $server, $team, $cluster)->allowed())->toBeTrue();
});
it('denies cluster create and delete for a member of the current team as forbidden', function () {
$team = v5PolicyTeam(10);
$cluster = (new Cluster)->forceFill(['id' => 5, 'team_id' => $team->id]);
$member = v5PolicyMemberUser();
$policy = new ClusterPolicy;
expect($policy->create($member, $team)->denied())->toBeTrue()
->and($policy->create($member, $team)->status())->toBeNull()
->and($policy->delete($member, $cluster, $team)->denied())->toBeTrue()
->and($policy->delete($member, $cluster, $team)->status())->toBeNull();
// Members may still read.
expect($policy->view($member, $cluster, $team)->allowed())->toBeTrue();
});
it('allows an admin to create a cluster in the current team', function () {
$team = v5PolicyTeam(10);
expect((new ClusterPolicy)->create(v5PolicyUser('admin'), $team)->allowed())->toBeTrue();
});
it('denies application mutations for a member of the current team as forbidden', function () {
$team = v5PolicyTeam(10);
$application = (new V5Application)->forceFill(['id' => 3, 'team_id' => $team->id]);
$member = v5PolicyMemberUser();
$policy = new ApplicationPolicy;
foreach (['update', 'updateIngress', 'delete'] as $ability) {
$response = $policy->{$ability}($member, $application, $team);
expect($response->denied())->toBeTrue()
->and($response->status())->toBeNull();
}
});
it('denies resource connection mutations for a member of the current team as forbidden', function () {
$team = v5PolicyTeam(10);
$connection = (new ResourceConnection)->forceFill(['id' => 3, 'team_id' => $team->id]);
$member = v5PolicyMemberUser();
$policy = new ResourceConnectionPolicy;
foreach (['update', 'delete'] as $ability) {
$response = $policy->{$ability}($member, $connection, $team);
expect($response->denied())->toBeTrue()
->and($response->status())->toBeNull();
}
});
+73
View File
@@ -0,0 +1,73 @@
<?php
use App\Events\V5CanvasResourceUpdated;
use App\Events\V5ClusterUpdated;
use App\Events\V5RealtimeTestEvent;
use App\Jobs\V5BootstrapServerJob;
use App\Jobs\V5DeployApplicationJob;
use Illuminate\Contracts\Broadcasting\ShouldBroadcast;
use Illuminate\Contracts\Broadcasting\ShouldBroadcastNow;
use Illuminate\Contracts\Queue\ShouldBeUnique;
use Illuminate\Support\Facades\Config;
use Illuminate\Support\Facades\Queue;
use Tests\TestCase;
uses(TestCase::class);
it('locks v5 deploy jobs per application for the job timeout plus a margin', function () {
$job = new V5DeployApplicationJob(42);
expect($job)->toBeInstanceOf(ShouldBeUnique::class)
->and($job->uniqueId())->toBe('42')
->and($job->uniqueFor)->toBe($job->timeout + 60);
});
it('locks v5 bootstrap jobs per server aligned with the controller bootstrap claim window', function () {
$job = new V5BootstrapServerJob(7, 13);
expect($job)->toBeInstanceOf(ShouldBeUnique::class)
->and($job->uniqueId())->toBe('13')
->and($job->uniqueFor)->toBe(V5BootstrapServerJob::TIMEOUT_SECONDS + 300);
});
it('does not queue a second v5 deploy job while one is pending for the same application', function () {
Config::set('cache.default', 'array');
Queue::fake();
V5DeployApplicationJob::dispatch(101);
V5DeployApplicationJob::dispatch(101);
V5DeployApplicationJob::dispatch(202);
Queue::assertPushed(V5DeployApplicationJob::class, 2);
Queue::assertPushed(V5DeployApplicationJob::class, fn (V5DeployApplicationJob $job): bool => $job->applicationId === 101);
Queue::assertPushed(V5DeployApplicationJob::class, fn (V5DeployApplicationJob $job): bool => $job->applicationId === 202);
});
it('does not queue a second v5 bootstrap job while one is pending for the same server', function () {
Config::set('cache.default', 'array');
Queue::fake();
V5BootstrapServerJob::dispatch(1, 55);
V5BootstrapServerJob::dispatch(2, 55);
V5BootstrapServerJob::dispatch(1, 66);
Queue::assertPushed(V5BootstrapServerJob::class, 2);
Queue::assertPushed(V5BootstrapServerJob::class, fn (V5BootstrapServerJob $job): bool => $job->serverId === 55);
Queue::assertPushed(V5BootstrapServerJob::class, fn (V5BootstrapServerJob $job): bool => $job->serverId === 66);
});
it('queues v5 canvas and cluster broadcasts after commit instead of pushing on the request thread', function () {
$canvasEvent = new V5CanvasResourceUpdated(1, 2);
$clusterEvent = new V5ClusterUpdated(1, 2);
expect($canvasEvent)->toBeInstanceOf(ShouldBroadcast::class)
->not->toBeInstanceOf(ShouldBroadcastNow::class)
->and($canvasEvent->afterCommit)->toBeTrue()
->and($clusterEvent)->toBeInstanceOf(ShouldBroadcast::class)
->not->toBeInstanceOf(ShouldBroadcastNow::class)
->and($clusterEvent->afterCommit)->toBeTrue();
});
it('keeps the manual v5 realtime test ping broadcasting immediately', function () {
expect(new V5RealtimeTestEvent(1, 'ping'))->toBeInstanceOf(ShouldBroadcastNow::class);
});
+5
View File
@@ -50,6 +50,11 @@ it('rejects invalid RFC 1123 hostnames', function (string $hostname, string $exp
'special characters' => ['my@server', 'letters (a-z, A-Z), numbers (0-9), hyphens (-), and dots (.)'],
'space' => ['my server', 'letters (a-z, A-Z), numbers (0-9), hyphens (-), and dots (.)'],
'shell metacharacters' => ['my;server', 'letters (a-z, A-Z), numbers (0-9), hyphens (-), and dots (.)'],
'newline before dot' => ["evil\n.com", 'letters (a-z, A-Z), numbers (0-9), hyphens (-), and dots (.)'],
'newline inside label' => ["evil\ncom", 'letters (a-z, A-Z), numbers (0-9), hyphens (-), and dots (.)'],
'carriage return' => ["evil\r.com", 'letters (a-z, A-Z), numbers (0-9), hyphens (-), and dots (.)'],
'tab character' => ["evil\t.com", 'letters (a-z, A-Z), numbers (0-9), hyphens (-), and dots (.)'],
'caddy block injection' => ["evil.com {\n} http://x", 'letters (a-z, A-Z), numbers (0-9), hyphens (-), and dots (.)'],
]);
it('accepts empty hostname', function () {