mirror of
https://github.com/tiennm99/coolify.git
synced 2026-04-17 15:20:40 +00:00
Merge branch 'next' into feat/copy-resource-logs-with-sanitization
This commit is contained in:
152
tests/Unit/HetznerServiceTest.php
Normal file
152
tests/Unit/HetznerServiceTest.php
Normal file
@@ -0,0 +1,152 @@
|
||||
<?php
|
||||
|
||||
use App\Services\HetznerService;
|
||||
use Illuminate\Support\Facades\Http;
|
||||
|
||||
beforeEach(function () {
|
||||
Http::preventStrayRequests();
|
||||
});
|
||||
|
||||
it('getServers returns list of servers from Hetzner API', function () {
|
||||
Http::fake([
|
||||
'api.hetzner.cloud/v1/servers*' => Http::response([
|
||||
'servers' => [
|
||||
[
|
||||
'id' => 12345,
|
||||
'name' => 'test-server-1',
|
||||
'status' => 'running',
|
||||
'public_net' => [
|
||||
'ipv4' => ['ip' => '123.45.67.89'],
|
||||
'ipv6' => ['ip' => '2a01:4f8::/64'],
|
||||
],
|
||||
],
|
||||
[
|
||||
'id' => 67890,
|
||||
'name' => 'test-server-2',
|
||||
'status' => 'off',
|
||||
'public_net' => [
|
||||
'ipv4' => ['ip' => '98.76.54.32'],
|
||||
'ipv6' => ['ip' => '2a01:4f9::/64'],
|
||||
],
|
||||
],
|
||||
],
|
||||
'meta' => ['pagination' => ['next_page' => null]],
|
||||
], 200),
|
||||
]);
|
||||
|
||||
$service = new HetznerService('fake-token');
|
||||
$servers = $service->getServers();
|
||||
|
||||
expect($servers)->toBeArray()
|
||||
->and(count($servers))->toBe(2)
|
||||
->and($servers[0]['id'])->toBe(12345)
|
||||
->and($servers[1]['id'])->toBe(67890);
|
||||
});
|
||||
|
||||
it('findServerByIp returns matching server by IPv4', function () {
|
||||
Http::fake([
|
||||
'api.hetzner.cloud/v1/servers*' => Http::response([
|
||||
'servers' => [
|
||||
[
|
||||
'id' => 12345,
|
||||
'name' => 'test-server',
|
||||
'status' => 'running',
|
||||
'public_net' => [
|
||||
'ipv4' => ['ip' => '123.45.67.89'],
|
||||
'ipv6' => ['ip' => '2a01:4f8::/64'],
|
||||
],
|
||||
],
|
||||
],
|
||||
'meta' => ['pagination' => ['next_page' => null]],
|
||||
], 200),
|
||||
]);
|
||||
|
||||
$service = new HetznerService('fake-token');
|
||||
$result = $service->findServerByIp('123.45.67.89');
|
||||
|
||||
expect($result)->not->toBeNull()
|
||||
->and($result['id'])->toBe(12345)
|
||||
->and($result['name'])->toBe('test-server');
|
||||
});
|
||||
|
||||
it('findServerByIp returns null when no match', function () {
|
||||
Http::fake([
|
||||
'api.hetzner.cloud/v1/servers*' => Http::response([
|
||||
'servers' => [
|
||||
[
|
||||
'id' => 12345,
|
||||
'name' => 'test-server',
|
||||
'status' => 'running',
|
||||
'public_net' => [
|
||||
'ipv4' => ['ip' => '123.45.67.89'],
|
||||
'ipv6' => ['ip' => '2a01:4f8::/64'],
|
||||
],
|
||||
],
|
||||
],
|
||||
'meta' => ['pagination' => ['next_page' => null]],
|
||||
], 200),
|
||||
]);
|
||||
|
||||
$service = new HetznerService('fake-token');
|
||||
$result = $service->findServerByIp('1.2.3.4');
|
||||
|
||||
expect($result)->toBeNull();
|
||||
});
|
||||
|
||||
it('findServerByIp returns null when server list is empty', function () {
|
||||
Http::fake([
|
||||
'api.hetzner.cloud/v1/servers*' => Http::response([
|
||||
'servers' => [],
|
||||
'meta' => ['pagination' => ['next_page' => null]],
|
||||
], 200),
|
||||
]);
|
||||
|
||||
$service = new HetznerService('fake-token');
|
||||
$result = $service->findServerByIp('123.45.67.89');
|
||||
|
||||
expect($result)->toBeNull();
|
||||
});
|
||||
|
||||
it('findServerByIp matches correct server among multiple', function () {
|
||||
Http::fake([
|
||||
'api.hetzner.cloud/v1/servers*' => Http::response([
|
||||
'servers' => [
|
||||
[
|
||||
'id' => 11111,
|
||||
'name' => 'server-a',
|
||||
'status' => 'running',
|
||||
'public_net' => [
|
||||
'ipv4' => ['ip' => '10.0.0.1'],
|
||||
'ipv6' => ['ip' => '2a01:4f8::/64'],
|
||||
],
|
||||
],
|
||||
[
|
||||
'id' => 22222,
|
||||
'name' => 'server-b',
|
||||
'status' => 'running',
|
||||
'public_net' => [
|
||||
'ipv4' => ['ip' => '10.0.0.2'],
|
||||
'ipv6' => ['ip' => '2a01:4f9::/64'],
|
||||
],
|
||||
],
|
||||
[
|
||||
'id' => 33333,
|
||||
'name' => 'server-c',
|
||||
'status' => 'off',
|
||||
'public_net' => [
|
||||
'ipv4' => ['ip' => '10.0.0.3'],
|
||||
'ipv6' => ['ip' => '2a01:4fa::/64'],
|
||||
],
|
||||
],
|
||||
],
|
||||
'meta' => ['pagination' => ['next_page' => null]],
|
||||
], 200),
|
||||
]);
|
||||
|
||||
$service = new HetznerService('fake-token');
|
||||
$result = $service->findServerByIp('10.0.0.2');
|
||||
|
||||
expect($result)->not->toBeNull()
|
||||
->and($result['id'])->toBe(22222)
|
||||
->and($result['name'])->toBe('server-b');
|
||||
});
|
||||
219
tests/Unit/LocalFileVolumeReadOnlyTest.php
Normal file
219
tests/Unit/LocalFileVolumeReadOnlyTest.php
Normal file
@@ -0,0 +1,219 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* Unit tests to verify LocalFileVolume::isReadOnlyVolume() correctly detects
|
||||
* read-only volumes in both short-form and long-form Docker Compose syntax.
|
||||
*
|
||||
* Related Issue: Volumes with read_only: true in long-form syntax were not
|
||||
* being detected as read-only, allowing UI edits on files that should be protected.
|
||||
*
|
||||
* Related Files:
|
||||
* - app/Models/LocalFileVolume.php
|
||||
* - app/Livewire/Project/Service/FileStorage.php
|
||||
*/
|
||||
|
||||
use Symfony\Component\Yaml\Yaml;
|
||||
|
||||
/**
|
||||
* Helper function to parse volumes and detect read-only status.
|
||||
* This mirrors the logic in LocalFileVolume::isReadOnlyVolume()
|
||||
*
|
||||
* Note: We match on mount_path (container path) only, since fs_path gets transformed
|
||||
* from relative (./file) to absolute (/data/coolify/services/uuid/file) during parsing
|
||||
*/
|
||||
function isVolumeReadOnly(string $dockerComposeRaw, string $serviceName, string $mountPath): bool
|
||||
{
|
||||
$compose = Yaml::parse($dockerComposeRaw);
|
||||
|
||||
if (! isset($compose['services'][$serviceName]['volumes'])) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$volumes = $compose['services'][$serviceName]['volumes'];
|
||||
|
||||
foreach ($volumes as $volume) {
|
||||
// Volume can be string like "host:container:ro" or "host:container"
|
||||
if (is_string($volume)) {
|
||||
$parts = explode(':', $volume);
|
||||
|
||||
if (count($parts) >= 2) {
|
||||
$containerPath = $parts[1];
|
||||
$options = $parts[2] ?? null;
|
||||
|
||||
if ($containerPath === $mountPath) {
|
||||
return $options === 'ro';
|
||||
}
|
||||
}
|
||||
} elseif (is_array($volume)) {
|
||||
// Long-form syntax: { type: bind, source: ..., target: ..., read_only: true }
|
||||
$containerPath = data_get($volume, 'target');
|
||||
$readOnly = data_get($volume, 'read_only', false);
|
||||
|
||||
if ($containerPath === $mountPath) {
|
||||
return $readOnly === true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
test('detects read-only with short-form syntax using :ro', function () {
|
||||
$compose = <<<'YAML'
|
||||
services:
|
||||
garage:
|
||||
image: example/image
|
||||
volumes:
|
||||
- ./config.toml:/etc/config.toml:ro
|
||||
YAML;
|
||||
|
||||
expect(isVolumeReadOnly($compose, 'garage', '/etc/config.toml'))->toBeTrue();
|
||||
});
|
||||
|
||||
test('detects writable with short-form syntax without :ro', function () {
|
||||
$compose = <<<'YAML'
|
||||
services:
|
||||
garage:
|
||||
image: example/image
|
||||
volumes:
|
||||
- ./config.toml:/etc/config.toml
|
||||
YAML;
|
||||
|
||||
expect(isVolumeReadOnly($compose, 'garage', '/etc/config.toml'))->toBeFalse();
|
||||
});
|
||||
|
||||
test('detects read-only with long-form syntax and read_only: true', function () {
|
||||
$compose = <<<'YAML'
|
||||
services:
|
||||
garage:
|
||||
image: example/image
|
||||
volumes:
|
||||
- type: bind
|
||||
source: ./garage.toml
|
||||
target: /etc/garage.toml
|
||||
read_only: true
|
||||
YAML;
|
||||
|
||||
expect(isVolumeReadOnly($compose, 'garage', '/etc/garage.toml'))->toBeTrue();
|
||||
});
|
||||
|
||||
test('detects writable with long-form syntax and read_only: false', function () {
|
||||
$compose = <<<'YAML'
|
||||
services:
|
||||
garage:
|
||||
image: example/image
|
||||
volumes:
|
||||
- type: bind
|
||||
source: ./garage.toml
|
||||
target: /etc/garage.toml
|
||||
read_only: false
|
||||
YAML;
|
||||
|
||||
expect(isVolumeReadOnly($compose, 'garage', '/etc/garage.toml'))->toBeFalse();
|
||||
});
|
||||
|
||||
test('detects writable with long-form syntax without read_only key', function () {
|
||||
$compose = <<<'YAML'
|
||||
services:
|
||||
garage:
|
||||
image: example/image
|
||||
volumes:
|
||||
- type: bind
|
||||
source: ./garage.toml
|
||||
target: /etc/garage.toml
|
||||
YAML;
|
||||
|
||||
expect(isVolumeReadOnly($compose, 'garage', '/etc/garage.toml'))->toBeFalse();
|
||||
});
|
||||
|
||||
test('handles mixed short-form and long-form volumes in same service', function () {
|
||||
$compose = <<<'YAML'
|
||||
services:
|
||||
garage:
|
||||
image: example/image
|
||||
volumes:
|
||||
- ./data:/var/data
|
||||
- type: bind
|
||||
source: ./config.toml
|
||||
target: /etc/config.toml
|
||||
read_only: true
|
||||
YAML;
|
||||
|
||||
expect(isVolumeReadOnly($compose, 'garage', '/var/data'))->toBeFalse();
|
||||
expect(isVolumeReadOnly($compose, 'garage', '/etc/config.toml'))->toBeTrue();
|
||||
});
|
||||
|
||||
test('handles same file mounted in multiple services with different read_only settings', function () {
|
||||
$compose = <<<'YAML'
|
||||
services:
|
||||
garage:
|
||||
image: example/garage
|
||||
volumes:
|
||||
- type: bind
|
||||
source: ./garage.toml
|
||||
target: /etc/garage.toml
|
||||
garage-webui:
|
||||
image: example/webui
|
||||
volumes:
|
||||
- type: bind
|
||||
source: ./garage.toml
|
||||
target: /etc/garage.toml
|
||||
read_only: true
|
||||
YAML;
|
||||
|
||||
// Same file, different services, different read_only status
|
||||
expect(isVolumeReadOnly($compose, 'garage', '/etc/garage.toml'))->toBeFalse();
|
||||
expect(isVolumeReadOnly($compose, 'garage-webui', '/etc/garage.toml'))->toBeTrue();
|
||||
});
|
||||
|
||||
test('handles volume mount type', function () {
|
||||
$compose = <<<'YAML'
|
||||
services:
|
||||
app:
|
||||
image: example/app
|
||||
volumes:
|
||||
- type: volume
|
||||
source: mydata
|
||||
target: /data
|
||||
read_only: true
|
||||
YAML;
|
||||
|
||||
expect(isVolumeReadOnly($compose, 'app', '/data'))->toBeTrue();
|
||||
});
|
||||
|
||||
test('returns false when service has no volumes', function () {
|
||||
$compose = <<<'YAML'
|
||||
services:
|
||||
garage:
|
||||
image: example/image
|
||||
YAML;
|
||||
|
||||
expect(isVolumeReadOnly($compose, 'garage', '/etc/config.toml'))->toBeFalse();
|
||||
});
|
||||
|
||||
test('returns false when service does not exist', function () {
|
||||
$compose = <<<'YAML'
|
||||
services:
|
||||
garage:
|
||||
image: example/image
|
||||
volumes:
|
||||
- ./config.toml:/etc/config.toml:ro
|
||||
YAML;
|
||||
|
||||
expect(isVolumeReadOnly($compose, 'nonexistent', '/etc/config.toml'))->toBeFalse();
|
||||
});
|
||||
|
||||
test('returns false when mount path does not match', function () {
|
||||
$compose = <<<'YAML'
|
||||
services:
|
||||
garage:
|
||||
image: example/image
|
||||
volumes:
|
||||
- type: bind
|
||||
source: ./other.toml
|
||||
target: /etc/other.toml
|
||||
read_only: true
|
||||
YAML;
|
||||
|
||||
expect(isVolumeReadOnly($compose, 'garage', '/etc/config.toml'))->toBeFalse();
|
||||
});
|
||||
Reference in New Issue
Block a user