mirror of
https://github.com/tiennm99/coolify.git
synced 2026-10-14 20:17:05 +00:00
Merge remote-tracking branch 'origin/next' into env-var-descriptions
This commit is contained in:
commit
d9e39ba211
404 files changed
+29406
-26356
No files matched your search
@@ -0,0 +1,11 @@
|
||||
<?php
|
||||
|
||||
use App\Models\Application;
|
||||
|
||||
it('treats zero private key id as deploy key', function () {
|
||||
$application = new Application();
|
||||
$application->private_key_id = 0;
|
||||
$application->source = null;
|
||||
|
||||
expect($application->deploymentType())->toBe('deploy_key');
|
||||
});
|
||||
@@ -3,7 +3,7 @@
|
||||
use App\Models\StandaloneClickhouse;
|
||||
|
||||
test('clickhouse uses clickhouse_db field in internal connection string', function () {
|
||||
$clickhouse = new StandaloneClickhouse();
|
||||
$clickhouse = new StandaloneClickhouse;
|
||||
$clickhouse->clickhouse_admin_user = 'testuser';
|
||||
$clickhouse->clickhouse_admin_password = 'testpass';
|
||||
$clickhouse->clickhouse_db = 'mydb';
|
||||
@@ -18,7 +18,7 @@ test('clickhouse uses clickhouse_db field in internal connection string', functi
|
||||
});
|
||||
|
||||
test('clickhouse defaults to default database when clickhouse_db is null', function () {
|
||||
$clickhouse = new StandaloneClickhouse();
|
||||
$clickhouse = new StandaloneClickhouse;
|
||||
$clickhouse->clickhouse_admin_user = 'testuser';
|
||||
$clickhouse->clickhouse_admin_password = 'testpass';
|
||||
$clickhouse->clickhouse_db = null;
|
||||
@@ -30,7 +30,7 @@ test('clickhouse defaults to default database when clickhouse_db is null', funct
|
||||
});
|
||||
|
||||
test('clickhouse external url uses correct database', function () {
|
||||
$clickhouse = new StandaloneClickhouse();
|
||||
$clickhouse = new StandaloneClickhouse;
|
||||
$clickhouse->clickhouse_admin_user = 'admin';
|
||||
$clickhouse->clickhouse_admin_password = 'secret';
|
||||
$clickhouse->clickhouse_db = 'production';
|
||||
@@ -38,12 +38,19 @@ test('clickhouse external url uses correct database', function () {
|
||||
$clickhouse->is_public = true;
|
||||
$clickhouse->public_port = 8123;
|
||||
|
||||
$clickhouse->destination = new class {
|
||||
$clickhouse->destination = new class
|
||||
{
|
||||
public $server;
|
||||
public function __construct() {
|
||||
$this->server = new class {
|
||||
public function __get($name) {
|
||||
if ($name === 'getIp') return '1.2.3.4';
|
||||
|
||||
public function __construct()
|
||||
{
|
||||
$this->server = new class
|
||||
{
|
||||
public function __get($name)
|
||||
{
|
||||
if ($name === 'getIp') {
|
||||
return '1.2.3.4';
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
@@ -0,0 +1,107 @@
|
||||
<?php
|
||||
|
||||
use App\Models\EnvironmentVariable;
|
||||
|
||||
/**
|
||||
* Tests for GitHub Issue #6160: COMPOSER_AUTH environment variable escaping.
|
||||
*
|
||||
* PR #6146 moved escaping into the EnvironmentVariable::realValue() accessor,
|
||||
* causing double-escaping for build-time vars and broken JSON for runtime vars.
|
||||
*
|
||||
* Fix: JSON objects/arrays detected in realValue() skip escaping entirely.
|
||||
*/
|
||||
const COMPOSER_AUTH_JSON = '{"http-basic":{"backpackforlaravel.com":{"username":"ourusername","password":"ourpassword"}}}';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Test 1: realValue accessor returns raw JSON for non-literal env vars
|
||||
// ---------------------------------------------------------------------------
|
||||
it('realValue accessor returns raw JSON without escaping quotes', function () {
|
||||
$env = Mockery::mock(EnvironmentVariable::class)->makePartial();
|
||||
$env->shouldReceive('relationLoaded')->with('resourceable')->andReturn(true);
|
||||
$env->shouldReceive('getAttribute')->with('resourceable')->andReturn(new stdClass);
|
||||
$env->shouldReceive('getAttribute')->with('value')->andReturn(COMPOSER_AUTH_JSON);
|
||||
$env->shouldReceive('getAttribute')->with('is_literal')->andReturn(false);
|
||||
$env->shouldReceive('getAttribute')->with('is_multiline')->andReturn(false);
|
||||
|
||||
$realValue = $env->real_value;
|
||||
|
||||
// JSON should pass through without escaping
|
||||
expect($realValue)->toBe(COMPOSER_AUTH_JSON);
|
||||
expect($realValue)->not->toContain('\\"');
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Test 2: realValue for a literal JSON env also returns raw JSON
|
||||
// (JSON check fires before the literal single-quote wrapping)
|
||||
// ---------------------------------------------------------------------------
|
||||
it('realValue accessor for literal JSON env returns raw value without wrapping', function () {
|
||||
$env = Mockery::mock(EnvironmentVariable::class)->makePartial();
|
||||
$env->shouldReceive('relationLoaded')->with('resourceable')->andReturn(true);
|
||||
$env->shouldReceive('getAttribute')->with('resourceable')->andReturn(new stdClass);
|
||||
$env->shouldReceive('getAttribute')->with('value')->andReturn(COMPOSER_AUTH_JSON);
|
||||
$env->shouldReceive('getAttribute')->with('is_literal')->andReturn(true);
|
||||
$env->shouldReceive('getAttribute')->with('is_multiline')->andReturn(false);
|
||||
|
||||
$realValue = $env->real_value;
|
||||
|
||||
// JSON check should fire first, returning raw JSON without single-quote wrapping
|
||||
expect($realValue)->toBe(COMPOSER_AUTH_JSON);
|
||||
expect($realValue)->not->toStartWith("'");
|
||||
expect($realValue)->not->toEndWith("'");
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Test 3: Non-JSON values still get normal escaping (regression check)
|
||||
// ---------------------------------------------------------------------------
|
||||
it('realValue accessor still escapes non-JSON values with quotes', function () {
|
||||
$env = Mockery::mock(EnvironmentVariable::class)->makePartial();
|
||||
$env->shouldReceive('relationLoaded')->with('resourceable')->andReturn(true);
|
||||
$env->shouldReceive('getAttribute')->with('resourceable')->andReturn(new stdClass);
|
||||
$env->shouldReceive('getAttribute')->with('value')->andReturn('hello "world"');
|
||||
$env->shouldReceive('getAttribute')->with('is_literal')->andReturn(false);
|
||||
$env->shouldReceive('getAttribute')->with('is_multiline')->andReturn(false);
|
||||
|
||||
$realValue = $env->real_value;
|
||||
|
||||
// Non-JSON should still be escaped by escapeEnvVariables
|
||||
expect($realValue)->toContain('\\"');
|
||||
expect($realValue)->toBe('hello \\"world\\"');
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Test 4: JSON array values also skip escaping
|
||||
// ---------------------------------------------------------------------------
|
||||
it('realValue accessor returns raw JSON array without escaping', function () {
|
||||
$jsonArray = '[{"host":"example.com","token":"abc123"}]';
|
||||
|
||||
$env = Mockery::mock(EnvironmentVariable::class)->makePartial();
|
||||
$env->shouldReceive('relationLoaded')->with('resourceable')->andReturn(true);
|
||||
$env->shouldReceive('getAttribute')->with('resourceable')->andReturn(new stdClass);
|
||||
$env->shouldReceive('getAttribute')->with('value')->andReturn($jsonArray);
|
||||
$env->shouldReceive('getAttribute')->with('is_literal')->andReturn(false);
|
||||
$env->shouldReceive('getAttribute')->with('is_multiline')->andReturn(false);
|
||||
|
||||
$realValue = $env->real_value;
|
||||
|
||||
expect($realValue)->toBe($jsonArray);
|
||||
expect($realValue)->not->toContain('\\"');
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Test 5: Buildtime escaping of raw JSON produces recoverable value
|
||||
// ---------------------------------------------------------------------------
|
||||
it('escapeBashDoubleQuoted on raw JSON produces value recoverable as valid JSON', function () {
|
||||
$escaped = escapeBashDoubleQuoted(COMPOSER_AUTH_JSON);
|
||||
|
||||
// Should be double-quoted
|
||||
expect($escaped)->toStartWith('"');
|
||||
expect($escaped)->toEndWith('"');
|
||||
|
||||
// After bash unescaping (strip outer quotes, unescape \")
|
||||
$inner = substr($escaped, 1, -1);
|
||||
$inner = str_replace('\\"', '"', $inner);
|
||||
$decoded = json_decode($inner, true);
|
||||
|
||||
expect($decoded)->not->toBeNull("Expected valid JSON after bash unescaping, got: {$inner}");
|
||||
expect($decoded)->toHaveKey('http-basic');
|
||||
});
|
||||
@@ -0,0 +1,35 @@
|
||||
<?php
|
||||
|
||||
it('passes a simple command through correctly', function () {
|
||||
$result = executeInDocker('test-container', 'ls -la /app');
|
||||
|
||||
expect($result)->toBe("docker exec test-container bash -c 'ls -la /app'");
|
||||
});
|
||||
|
||||
it('escapes single quotes in command', function () {
|
||||
$result = executeInDocker('test-container', "echo 'hello world'");
|
||||
|
||||
expect($result)->toBe("docker exec test-container bash -c 'echo '\\''hello world'\\'''");
|
||||
});
|
||||
|
||||
it('prevents command injection via single quote breakout', function () {
|
||||
$malicious = "cd /dir && docker compose build'; id; #";
|
||||
$result = executeInDocker('test-container', $malicious);
|
||||
|
||||
// The single quote in the malicious command should be escaped so it cannot break out of bash -c
|
||||
// The raw unescaped pattern "build'; id;" must not appear — the quote must be escaped
|
||||
expect($result)->not->toContain("build'; id;");
|
||||
expect($result)->toBe("docker exec test-container bash -c 'cd /dir && docker compose build'\\''; id; #'");
|
||||
});
|
||||
|
||||
it('handles empty command', function () {
|
||||
$result = executeInDocker('test-container', '');
|
||||
|
||||
expect($result)->toBe("docker exec test-container bash -c ''");
|
||||
});
|
||||
|
||||
it('handles command with multiple single quotes', function () {
|
||||
$result = executeInDocker('test-container', "echo 'a' && echo 'b'");
|
||||
|
||||
expect($result)->toBe("docker exec test-container bash -c 'echo '\\''a'\\'' && echo '\\''b'\\'''");
|
||||
});
|
||||
@@ -0,0 +1,270 @@
|
||||
<?php
|
||||
|
||||
use App\Jobs\ApplicationDeploymentJob;
|
||||
use App\Models\Application;
|
||||
use App\Models\ApplicationDeploymentQueue;
|
||||
use App\Models\ApplicationSetting;
|
||||
use Illuminate\Support\Facades\Validator;
|
||||
use Mockery;
|
||||
|
||||
beforeEach(function () {
|
||||
Mockery::close();
|
||||
});
|
||||
|
||||
afterEach(function () {
|
||||
Mockery::close();
|
||||
});
|
||||
|
||||
it('sanitizes health_check_host to prevent command injection', function () {
|
||||
$result = callGenerateHealthcheckCommands([
|
||||
'health_check_host' => 'localhost; id > /tmp/pwned #',
|
||||
]);
|
||||
|
||||
// Should fall back to 'localhost' because input contains shell metacharacters
|
||||
expect($result)->not->toContain('; id')
|
||||
->and($result)->not->toContain('/tmp/pwned')
|
||||
->and($result)->toContain('localhost');
|
||||
});
|
||||
|
||||
it('sanitizes health_check_method to prevent command injection', function () {
|
||||
$result = callGenerateHealthcheckCommands([
|
||||
'health_check_method' => 'GET; curl http://evil.com #',
|
||||
]);
|
||||
|
||||
expect($result)->not->toContain('evil.com')
|
||||
->and($result)->not->toContain('; curl');
|
||||
});
|
||||
|
||||
it('sanitizes health_check_path to prevent command injection', function () {
|
||||
$result = callGenerateHealthcheckCommands([
|
||||
'health_check_path' => '/health; rm -rf / #',
|
||||
]);
|
||||
|
||||
expect($result)->not->toContain('rm -rf')
|
||||
->and($result)->not->toContain('; rm');
|
||||
});
|
||||
|
||||
it('sanitizes health_check_scheme to prevent command injection', function () {
|
||||
$result = callGenerateHealthcheckCommands([
|
||||
'health_check_scheme' => 'http; cat /etc/passwd #',
|
||||
]);
|
||||
|
||||
expect($result)->not->toContain('/etc/passwd')
|
||||
->and($result)->not->toContain('; cat');
|
||||
});
|
||||
|
||||
it('casts health_check_port to integer to prevent injection', function () {
|
||||
$result = callGenerateHealthcheckCommands([
|
||||
'health_check_port' => '8080; whoami',
|
||||
]);
|
||||
|
||||
// (int) cast on non-numeric after digits yields 8080
|
||||
expect($result)->not->toContain('whoami')
|
||||
->and($result)->toContain('8080');
|
||||
});
|
||||
|
||||
it('generates valid healthcheck command with safe inputs', function () {
|
||||
$result = callGenerateHealthcheckCommands([
|
||||
'health_check_method' => 'GET',
|
||||
'health_check_scheme' => 'http',
|
||||
'health_check_host' => 'localhost',
|
||||
'health_check_port' => '8080',
|
||||
'health_check_path' => '/health',
|
||||
]);
|
||||
|
||||
expect($result)->toContain('curl -s -X')
|
||||
->and($result)->toContain('http://localhost:8080/health')
|
||||
->and($result)->toContain('wget -q -O-');
|
||||
});
|
||||
|
||||
it('uses escapeshellarg on the constructed URL', function () {
|
||||
$result = callGenerateHealthcheckCommands([
|
||||
'health_check_host' => 'my-app.local',
|
||||
'health_check_path' => '/api/health',
|
||||
]);
|
||||
|
||||
// escapeshellarg wraps in single quotes
|
||||
expect($result)->toContain("'http://my-app.local:80/api/health'");
|
||||
});
|
||||
|
||||
it('validates health_check_host rejects shell metacharacters via API rules', function () {
|
||||
$rules = sharedDataApplications();
|
||||
|
||||
$validator = Validator::make(
|
||||
['health_check_host' => 'localhost; id #'],
|
||||
['health_check_host' => $rules['health_check_host']]
|
||||
);
|
||||
|
||||
expect($validator->fails())->toBeTrue();
|
||||
});
|
||||
|
||||
it('validates health_check_method rejects invalid methods via API rules', function () {
|
||||
$rules = sharedDataApplications();
|
||||
|
||||
$validator = Validator::make(
|
||||
['health_check_method' => 'GET; curl evil.com'],
|
||||
['health_check_method' => $rules['health_check_method']]
|
||||
);
|
||||
|
||||
expect($validator->fails())->toBeTrue();
|
||||
});
|
||||
|
||||
it('validates health_check_scheme rejects invalid schemes via API rules', function () {
|
||||
$rules = sharedDataApplications();
|
||||
|
||||
$validator = Validator::make(
|
||||
['health_check_scheme' => 'http; whoami'],
|
||||
['health_check_scheme' => $rules['health_check_scheme']]
|
||||
);
|
||||
|
||||
expect($validator->fails())->toBeTrue();
|
||||
});
|
||||
|
||||
it('validates health_check_path rejects shell metacharacters via API rules', function () {
|
||||
$rules = sharedDataApplications();
|
||||
|
||||
$validator = Validator::make(
|
||||
['health_check_path' => '/health; rm -rf /'],
|
||||
['health_check_path' => $rules['health_check_path']]
|
||||
);
|
||||
|
||||
expect($validator->fails())->toBeTrue();
|
||||
});
|
||||
|
||||
it('validates health_check_port rejects non-numeric values via API rules', function () {
|
||||
$rules = sharedDataApplications();
|
||||
|
||||
$validator = Validator::make(
|
||||
['health_check_port' => '8080; whoami'],
|
||||
['health_check_port' => $rules['health_check_port']]
|
||||
);
|
||||
|
||||
expect($validator->fails())->toBeTrue();
|
||||
});
|
||||
|
||||
it('allows valid health check values via API rules', function () {
|
||||
$rules = sharedDataApplications();
|
||||
|
||||
$validator = Validator::make(
|
||||
[
|
||||
'health_check_host' => 'my-app.localhost',
|
||||
'health_check_method' => 'GET',
|
||||
'health_check_scheme' => 'https',
|
||||
'health_check_path' => '/api/v1/health',
|
||||
'health_check_port' => 8080,
|
||||
],
|
||||
[
|
||||
'health_check_host' => $rules['health_check_host'],
|
||||
'health_check_method' => $rules['health_check_method'],
|
||||
'health_check_scheme' => $rules['health_check_scheme'],
|
||||
'health_check_path' => $rules['health_check_path'],
|
||||
'health_check_port' => $rules['health_check_port'],
|
||||
]
|
||||
);
|
||||
|
||||
expect($validator->fails())->toBeFalse();
|
||||
});
|
||||
|
||||
it('generates CMD healthcheck command directly', function () {
|
||||
$result = callGenerateHealthcheckCommands([
|
||||
'health_check_type' => 'cmd',
|
||||
'health_check_command' => 'pg_isready -U postgres',
|
||||
]);
|
||||
|
||||
expect($result)->toBe('pg_isready -U postgres');
|
||||
});
|
||||
|
||||
it('strips newlines from CMD healthcheck command', function () {
|
||||
$result = callGenerateHealthcheckCommands([
|
||||
'health_check_type' => 'cmd',
|
||||
'health_check_command' => "redis-cli ping\n&& echo pwned",
|
||||
]);
|
||||
|
||||
expect($result)->not->toContain("\n")
|
||||
->and($result)->toBe('redis-cli ping && echo pwned');
|
||||
});
|
||||
|
||||
it('falls back to HTTP healthcheck when CMD type has empty command', function () {
|
||||
$result = callGenerateHealthcheckCommands([
|
||||
'health_check_type' => 'cmd',
|
||||
'health_check_command' => '',
|
||||
]);
|
||||
|
||||
// Should fall through to HTTP path
|
||||
expect($result)->toContain('curl -s -X');
|
||||
});
|
||||
|
||||
it('validates healthCheckCommand rejects strings over 1000 characters', function () {
|
||||
$rules = [
|
||||
'healthCheckCommand' => 'nullable|string|max:1000',
|
||||
];
|
||||
|
||||
$validator = Validator::make(
|
||||
['healthCheckCommand' => str_repeat('a', 1001)],
|
||||
$rules
|
||||
);
|
||||
|
||||
expect($validator->fails())->toBeTrue();
|
||||
});
|
||||
|
||||
it('validates healthCheckCommand accepts strings under 1000 characters', function () {
|
||||
$rules = [
|
||||
'healthCheckCommand' => 'nullable|string|max:1000',
|
||||
];
|
||||
|
||||
$validator = Validator::make(
|
||||
['healthCheckCommand' => 'pg_isready -U postgres'],
|
||||
$rules
|
||||
);
|
||||
|
||||
expect($validator->fails())->toBeFalse();
|
||||
});
|
||||
|
||||
/**
|
||||
* Helper: Invokes the private generate_healthcheck_commands() method via reflection.
|
||||
*/
|
||||
function callGenerateHealthcheckCommands(array $overrides = []): string
|
||||
{
|
||||
$defaults = [
|
||||
'health_check_type' => 'http',
|
||||
'health_check_command' => null,
|
||||
'health_check_method' => 'GET',
|
||||
'health_check_scheme' => 'http',
|
||||
'health_check_host' => 'localhost',
|
||||
'health_check_port' => null,
|
||||
'health_check_path' => '/',
|
||||
'ports_exposes' => '80',
|
||||
];
|
||||
|
||||
$values = array_merge($defaults, $overrides);
|
||||
|
||||
$application = Mockery::mock(Application::class)->makePartial();
|
||||
$application->shouldReceive('getAttribute')->with('health_check_type')->andReturn($values['health_check_type']);
|
||||
$application->shouldReceive('getAttribute')->with('health_check_command')->andReturn($values['health_check_command']);
|
||||
$application->shouldReceive('getAttribute')->with('health_check_method')->andReturn($values['health_check_method']);
|
||||
$application->shouldReceive('getAttribute')->with('health_check_scheme')->andReturn($values['health_check_scheme']);
|
||||
$application->shouldReceive('getAttribute')->with('health_check_host')->andReturn($values['health_check_host']);
|
||||
$application->shouldReceive('getAttribute')->with('health_check_port')->andReturn($values['health_check_port']);
|
||||
$application->shouldReceive('getAttribute')->with('health_check_path')->andReturn($values['health_check_path']);
|
||||
$application->shouldReceive('getAttribute')->with('ports_exposes_array')->andReturn(explode(',', $values['ports_exposes']));
|
||||
$application->shouldReceive('getAttribute')->with('build_pack')->andReturn('nixpacks');
|
||||
|
||||
$settings = Mockery::mock(ApplicationSetting::class)->makePartial();
|
||||
$settings->shouldReceive('getAttribute')->with('is_static')->andReturn(false);
|
||||
$application->shouldReceive('getAttribute')->with('settings')->andReturn($settings);
|
||||
|
||||
$deploymentQueue = Mockery::mock(ApplicationDeploymentQueue::class)->makePartial();
|
||||
|
||||
$job = Mockery::mock(ApplicationDeploymentJob::class)->makePartial();
|
||||
|
||||
$reflection = new ReflectionClass($job);
|
||||
|
||||
$appProp = $reflection->getProperty('application');
|
||||
$appProp->setAccessible(true);
|
||||
$appProp->setValue($job, $application);
|
||||
|
||||
$method = $reflection->getMethod('generate_healthcheck_commands');
|
||||
$method->setAccessible(true);
|
||||
|
||||
return $method->invoke($job);
|
||||
}
|
||||
@@ -0,0 +1,121 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* Tests for the downsampleLTTB helper function used for metrics downsampling.
|
||||
* This function implements the Largest-Triangle-Three-Buckets algorithm.
|
||||
*/
|
||||
it('returns data unchanged when below threshold', function () {
|
||||
$data = [
|
||||
[1000, 10.5],
|
||||
[2000, 20.3],
|
||||
[3000, 15.7],
|
||||
];
|
||||
|
||||
$result = downsampleLTTB($data, 1000);
|
||||
|
||||
expect($result)->toBe($data);
|
||||
});
|
||||
|
||||
it('returns data unchanged when threshold is 2 or less', function () {
|
||||
$data = [
|
||||
[1000, 10.5],
|
||||
[2000, 20.3],
|
||||
[3000, 15.7],
|
||||
[4000, 25.0],
|
||||
[5000, 12.0],
|
||||
];
|
||||
|
||||
$result = downsampleLTTB($data, 2);
|
||||
expect($result)->toBe($data);
|
||||
|
||||
$result = downsampleLTTB($data, 1);
|
||||
expect($result)->toBe($data);
|
||||
});
|
||||
|
||||
it('downsamples to target threshold count', function () {
|
||||
// Seed for reproducibility
|
||||
mt_srand(42);
|
||||
|
||||
// Generate 100 data points
|
||||
$data = [];
|
||||
for ($i = 0; $i < 100; $i++) {
|
||||
$data[] = [$i * 1000, mt_rand(0, 100) / 10];
|
||||
}
|
||||
|
||||
$result = downsampleLTTB($data, 10);
|
||||
|
||||
expect(count($result))->toBe(10);
|
||||
});
|
||||
|
||||
it('preserves first and last data points', function () {
|
||||
$data = [];
|
||||
for ($i = 0; $i < 100; $i++) {
|
||||
$data[] = [$i * 1000, $i * 1.5];
|
||||
}
|
||||
|
||||
$result = downsampleLTTB($data, 20);
|
||||
|
||||
// First point should be preserved
|
||||
expect($result[0])->toBe($data[0]);
|
||||
|
||||
// Last point should be preserved
|
||||
expect(end($result))->toBe(end($data));
|
||||
});
|
||||
|
||||
it('maintains chronological order', function () {
|
||||
$data = [];
|
||||
for ($i = 0; $i < 500; $i++) {
|
||||
$data[] = [$i * 60000, sin($i / 10) * 50 + 50]; // Sine wave pattern
|
||||
}
|
||||
|
||||
$result = downsampleLTTB($data, 50);
|
||||
|
||||
// Verify all timestamps are in non-decreasing order
|
||||
$previousTimestamp = -1;
|
||||
foreach ($result as $point) {
|
||||
expect($point[0])->toBeGreaterThanOrEqual($previousTimestamp);
|
||||
$previousTimestamp = $point[0];
|
||||
}
|
||||
});
|
||||
|
||||
it('handles large datasets efficiently', function () {
|
||||
// Seed for reproducibility
|
||||
mt_srand(123);
|
||||
|
||||
// Simulate 30 days of data at 5-second intervals (518,400 points)
|
||||
// For test purposes, use 10,000 points
|
||||
$data = [];
|
||||
for ($i = 0; $i < 10000; $i++) {
|
||||
$data[] = [$i * 5000, mt_rand(0, 100)];
|
||||
}
|
||||
|
||||
$startTime = microtime(true);
|
||||
$result = downsampleLTTB($data, 1000);
|
||||
$executionTime = microtime(true) - $startTime;
|
||||
|
||||
expect(count($result))->toBe(1000);
|
||||
expect($executionTime)->toBeLessThan(1.0); // Should complete in under 1 second
|
||||
});
|
||||
|
||||
it('preserves peaks and valleys in data', function () {
|
||||
// Create data with clear peaks and valleys
|
||||
$data = [];
|
||||
for ($i = 0; $i < 100; $i++) {
|
||||
if ($i === 25) {
|
||||
$value = 100; // Peak
|
||||
} elseif ($i === 75) {
|
||||
$value = 0; // Valley
|
||||
} else {
|
||||
$value = 50;
|
||||
}
|
||||
$data[] = [$i * 1000, $value];
|
||||
}
|
||||
|
||||
$result = downsampleLTTB($data, 20);
|
||||
|
||||
// The peak (100) and valley (0) should be preserved due to LTTB algorithm
|
||||
$values = array_column($result, 1);
|
||||
|
||||
expect(in_array(100, $values))->toBeTrue();
|
||||
expect(in_array(0, $values))->toBeTrue();
|
||||
});
|
||||
@@ -0,0 +1,227 @@
|
||||
<?php
|
||||
|
||||
use App\Models\User;
|
||||
use App\Policies\GithubAppPolicy;
|
||||
|
||||
it('allows any user to view any github apps', function () {
|
||||
$user = Mockery::mock(User::class)->makePartial();
|
||||
|
||||
$policy = new GithubAppPolicy;
|
||||
expect($policy->viewAny($user))->toBeTrue();
|
||||
});
|
||||
|
||||
it('allows any user to view system-wide github app', function () {
|
||||
$user = Mockery::mock(User::class)->makePartial();
|
||||
|
||||
$model = new class
|
||||
{
|
||||
public $team_id = 1;
|
||||
|
||||
public $is_system_wide = true;
|
||||
};
|
||||
|
||||
$policy = new GithubAppPolicy;
|
||||
expect($policy->view($user, $model))->toBeTrue();
|
||||
});
|
||||
|
||||
it('allows team member to view non-system-wide github app', function () {
|
||||
$teams = collect([
|
||||
(object) ['id' => 1, 'pivot' => (object) ['role' => 'member']],
|
||||
]);
|
||||
|
||||
$user = Mockery::mock(User::class)->makePartial();
|
||||
$user->shouldReceive('getAttribute')->with('teams')->andReturn($teams);
|
||||
|
||||
$model = new class
|
||||
{
|
||||
public $team_id = 1;
|
||||
|
||||
public $is_system_wide = false;
|
||||
};
|
||||
|
||||
$policy = new GithubAppPolicy;
|
||||
expect($policy->view($user, $model))->toBeTrue();
|
||||
});
|
||||
|
||||
it('denies non-team member to view non-system-wide github app', function () {
|
||||
$teams = collect([
|
||||
(object) ['id' => 2, 'pivot' => (object) ['role' => 'member']],
|
||||
]);
|
||||
|
||||
$user = Mockery::mock(User::class)->makePartial();
|
||||
$user->shouldReceive('getAttribute')->with('teams')->andReturn($teams);
|
||||
|
||||
$model = new class
|
||||
{
|
||||
public $team_id = 1;
|
||||
|
||||
public $is_system_wide = false;
|
||||
};
|
||||
|
||||
$policy = new GithubAppPolicy;
|
||||
expect($policy->view($user, $model))->toBeFalse();
|
||||
});
|
||||
|
||||
it('allows admin to create github app', function () {
|
||||
$user = Mockery::mock(User::class)->makePartial();
|
||||
$user->shouldReceive('isAdmin')->andReturn(true);
|
||||
|
||||
$policy = new GithubAppPolicy;
|
||||
expect($policy->create($user))->toBeTrue();
|
||||
});
|
||||
|
||||
it('denies non-admin to create github app', function () {
|
||||
$user = Mockery::mock(User::class)->makePartial();
|
||||
$user->shouldReceive('isAdmin')->andReturn(false);
|
||||
|
||||
$policy = new GithubAppPolicy;
|
||||
expect($policy->create($user))->toBeFalse();
|
||||
});
|
||||
|
||||
it('allows user with system access to update system-wide github app', function () {
|
||||
$user = Mockery::mock(User::class)->makePartial();
|
||||
$user->shouldReceive('canAccessSystemResources')->andReturn(true);
|
||||
|
||||
$model = new class
|
||||
{
|
||||
public $team_id = 1;
|
||||
|
||||
public $is_system_wide = true;
|
||||
};
|
||||
|
||||
$policy = new GithubAppPolicy;
|
||||
expect($policy->update($user, $model))->toBeTrue();
|
||||
});
|
||||
|
||||
it('denies user without system access to update system-wide github app', function () {
|
||||
$user = Mockery::mock(User::class)->makePartial();
|
||||
$user->shouldReceive('canAccessSystemResources')->andReturn(false);
|
||||
|
||||
$model = new class
|
||||
{
|
||||
public $team_id = 1;
|
||||
|
||||
public $is_system_wide = true;
|
||||
};
|
||||
|
||||
$policy = new GithubAppPolicy;
|
||||
expect($policy->update($user, $model))->toBeFalse();
|
||||
});
|
||||
|
||||
it('allows team admin to update non-system-wide github app', function () {
|
||||
$user = Mockery::mock(User::class)->makePartial();
|
||||
$user->shouldReceive('isAdminOfTeam')->with(1)->andReturn(true);
|
||||
|
||||
$model = new class
|
||||
{
|
||||
public $team_id = 1;
|
||||
|
||||
public $is_system_wide = false;
|
||||
};
|
||||
|
||||
$policy = new GithubAppPolicy;
|
||||
expect($policy->update($user, $model))->toBeTrue();
|
||||
});
|
||||
|
||||
it('denies team member to update non-system-wide github app', function () {
|
||||
$user = Mockery::mock(User::class)->makePartial();
|
||||
$user->shouldReceive('isAdminOfTeam')->with(1)->andReturn(false);
|
||||
|
||||
$model = new class
|
||||
{
|
||||
public $team_id = 1;
|
||||
|
||||
public $is_system_wide = false;
|
||||
};
|
||||
|
||||
$policy = new GithubAppPolicy;
|
||||
expect($policy->update($user, $model))->toBeFalse();
|
||||
});
|
||||
|
||||
it('allows user with system access to delete system-wide github app', function () {
|
||||
$user = Mockery::mock(User::class)->makePartial();
|
||||
$user->shouldReceive('canAccessSystemResources')->andReturn(true);
|
||||
|
||||
$model = new class
|
||||
{
|
||||
public $team_id = 1;
|
||||
|
||||
public $is_system_wide = true;
|
||||
};
|
||||
|
||||
$policy = new GithubAppPolicy;
|
||||
expect($policy->delete($user, $model))->toBeTrue();
|
||||
});
|
||||
|
||||
it('denies user without system access to delete system-wide github app', function () {
|
||||
$user = Mockery::mock(User::class)->makePartial();
|
||||
$user->shouldReceive('canAccessSystemResources')->andReturn(false);
|
||||
|
||||
$model = new class
|
||||
{
|
||||
public $team_id = 1;
|
||||
|
||||
public $is_system_wide = true;
|
||||
};
|
||||
|
||||
$policy = new GithubAppPolicy;
|
||||
expect($policy->delete($user, $model))->toBeFalse();
|
||||
});
|
||||
|
||||
it('allows team admin to delete non-system-wide github app', function () {
|
||||
$user = Mockery::mock(User::class)->makePartial();
|
||||
$user->shouldReceive('isAdminOfTeam')->with(1)->andReturn(true);
|
||||
|
||||
$model = new class
|
||||
{
|
||||
public $team_id = 1;
|
||||
|
||||
public $is_system_wide = false;
|
||||
};
|
||||
|
||||
$policy = new GithubAppPolicy;
|
||||
expect($policy->delete($user, $model))->toBeTrue();
|
||||
});
|
||||
|
||||
it('denies team member to delete non-system-wide github app', function () {
|
||||
$user = Mockery::mock(User::class)->makePartial();
|
||||
$user->shouldReceive('isAdminOfTeam')->with(1)->andReturn(false);
|
||||
|
||||
$model = new class
|
||||
{
|
||||
public $team_id = 1;
|
||||
|
||||
public $is_system_wide = false;
|
||||
};
|
||||
|
||||
$policy = new GithubAppPolicy;
|
||||
expect($policy->delete($user, $model))->toBeFalse();
|
||||
});
|
||||
|
||||
it('denies restore of github app', function () {
|
||||
$user = Mockery::mock(User::class)->makePartial();
|
||||
|
||||
$model = new class
|
||||
{
|
||||
public $team_id = 1;
|
||||
|
||||
public $is_system_wide = false;
|
||||
};
|
||||
|
||||
$policy = new GithubAppPolicy;
|
||||
expect($policy->restore($user, $model))->toBeFalse();
|
||||
});
|
||||
|
||||
it('denies force delete of github app', function () {
|
||||
$user = Mockery::mock(User::class)->makePartial();
|
||||
|
||||
$model = new class
|
||||
{
|
||||
public $team_id = 1;
|
||||
|
||||
public $is_system_wide = false;
|
||||
};
|
||||
|
||||
$policy = new GithubAppPolicy;
|
||||
expect($policy->forceDelete($user, $model))->toBeFalse();
|
||||
});
|
||||
@@ -0,0 +1,163 @@
|
||||
<?php
|
||||
|
||||
use App\Models\User;
|
||||
use App\Policies\SharedEnvironmentVariablePolicy;
|
||||
|
||||
it('allows any user to view any shared environment variables', function () {
|
||||
$user = Mockery::mock(User::class)->makePartial();
|
||||
|
||||
$policy = new SharedEnvironmentVariablePolicy;
|
||||
expect($policy->viewAny($user))->toBeTrue();
|
||||
});
|
||||
|
||||
it('allows team member to view their team shared environment variable', function () {
|
||||
$teams = collect([
|
||||
(object) ['id' => 1, 'pivot' => (object) ['role' => 'member']],
|
||||
]);
|
||||
|
||||
$user = Mockery::mock(User::class)->makePartial();
|
||||
$user->shouldReceive('getAttribute')->with('teams')->andReturn($teams);
|
||||
|
||||
$model = new class
|
||||
{
|
||||
public $team_id = 1;
|
||||
};
|
||||
|
||||
$policy = new SharedEnvironmentVariablePolicy;
|
||||
expect($policy->view($user, $model))->toBeTrue();
|
||||
});
|
||||
|
||||
it('denies non-team member to view shared environment variable', function () {
|
||||
$teams = collect([
|
||||
(object) ['id' => 1, 'pivot' => (object) ['role' => 'member']],
|
||||
]);
|
||||
|
||||
$user = Mockery::mock(User::class)->makePartial();
|
||||
$user->shouldReceive('getAttribute')->with('teams')->andReturn($teams);
|
||||
|
||||
$model = new class
|
||||
{
|
||||
public $team_id = 2;
|
||||
};
|
||||
|
||||
$policy = new SharedEnvironmentVariablePolicy;
|
||||
expect($policy->view($user, $model))->toBeFalse();
|
||||
});
|
||||
|
||||
it('allows admin to create shared environment variable', function () {
|
||||
$user = Mockery::mock(User::class)->makePartial();
|
||||
$user->shouldReceive('isAdmin')->andReturn(true);
|
||||
|
||||
$policy = new SharedEnvironmentVariablePolicy;
|
||||
expect($policy->create($user))->toBeTrue();
|
||||
});
|
||||
|
||||
it('denies non-admin to create shared environment variable', function () {
|
||||
$user = Mockery::mock(User::class)->makePartial();
|
||||
$user->shouldReceive('isAdmin')->andReturn(false);
|
||||
|
||||
$policy = new SharedEnvironmentVariablePolicy;
|
||||
expect($policy->create($user))->toBeFalse();
|
||||
});
|
||||
|
||||
it('allows team admin to update shared environment variable', function () {
|
||||
$user = Mockery::mock(User::class)->makePartial();
|
||||
$user->shouldReceive('isAdminOfTeam')->with(1)->andReturn(true);
|
||||
|
||||
$model = new class
|
||||
{
|
||||
public $team_id = 1;
|
||||
};
|
||||
|
||||
$policy = new SharedEnvironmentVariablePolicy;
|
||||
expect($policy->update($user, $model))->toBeTrue();
|
||||
});
|
||||
|
||||
it('denies team member to update shared environment variable', function () {
|
||||
$user = Mockery::mock(User::class)->makePartial();
|
||||
$user->shouldReceive('isAdminOfTeam')->with(1)->andReturn(false);
|
||||
|
||||
$model = new class
|
||||
{
|
||||
public $team_id = 1;
|
||||
};
|
||||
|
||||
$policy = new SharedEnvironmentVariablePolicy;
|
||||
expect($policy->update($user, $model))->toBeFalse();
|
||||
});
|
||||
|
||||
it('allows team admin to delete shared environment variable', function () {
|
||||
$user = Mockery::mock(User::class)->makePartial();
|
||||
$user->shouldReceive('isAdminOfTeam')->with(1)->andReturn(true);
|
||||
|
||||
$model = new class
|
||||
{
|
||||
public $team_id = 1;
|
||||
};
|
||||
|
||||
$policy = new SharedEnvironmentVariablePolicy;
|
||||
expect($policy->delete($user, $model))->toBeTrue();
|
||||
});
|
||||
|
||||
it('denies team member to delete shared environment variable', function () {
|
||||
$user = Mockery::mock(User::class)->makePartial();
|
||||
$user->shouldReceive('isAdminOfTeam')->with(1)->andReturn(false);
|
||||
|
||||
$model = new class
|
||||
{
|
||||
public $team_id = 1;
|
||||
};
|
||||
|
||||
$policy = new SharedEnvironmentVariablePolicy;
|
||||
expect($policy->delete($user, $model))->toBeFalse();
|
||||
});
|
||||
|
||||
it('denies restore of shared environment variable', function () {
|
||||
$user = Mockery::mock(User::class)->makePartial();
|
||||
|
||||
$model = new class
|
||||
{
|
||||
public $team_id = 1;
|
||||
};
|
||||
|
||||
$policy = new SharedEnvironmentVariablePolicy;
|
||||
expect($policy->restore($user, $model))->toBeFalse();
|
||||
});
|
||||
|
||||
it('denies force delete of shared environment variable', function () {
|
||||
$user = Mockery::mock(User::class)->makePartial();
|
||||
|
||||
$model = new class
|
||||
{
|
||||
public $team_id = 1;
|
||||
};
|
||||
|
||||
$policy = new SharedEnvironmentVariablePolicy;
|
||||
expect($policy->forceDelete($user, $model))->toBeFalse();
|
||||
});
|
||||
|
||||
it('allows team admin to manage environment', function () {
|
||||
$user = Mockery::mock(User::class)->makePartial();
|
||||
$user->shouldReceive('isAdminOfTeam')->with(1)->andReturn(true);
|
||||
|
||||
$model = new class
|
||||
{
|
||||
public $team_id = 1;
|
||||
};
|
||||
|
||||
$policy = new SharedEnvironmentVariablePolicy;
|
||||
expect($policy->manageEnvironment($user, $model))->toBeTrue();
|
||||
});
|
||||
|
||||
it('denies team member to manage environment', function () {
|
||||
$user = Mockery::mock(User::class)->makePartial();
|
||||
$user->shouldReceive('isAdminOfTeam')->with(1)->andReturn(false);
|
||||
|
||||
$model = new class
|
||||
{
|
||||
public $team_id = 1;
|
||||
};
|
||||
|
||||
$policy = new SharedEnvironmentVariablePolicy;
|
||||
expect($policy->manageEnvironment($user, $model))->toBeFalse();
|
||||
});
|
||||
@@ -112,7 +112,7 @@ uZx9iFkCELtxrh31QJ68AAAAEXNhaWxANzZmZjY2ZDJlMmRkAQIDBA==
|
||||
);
|
||||
|
||||
$this->expectException(\Exception::class);
|
||||
$this->expectExceptionMessage('SSH keys storage directory is not writable');
|
||||
$this->expectExceptionMessage('SSH keys storage directory is not writable. Run on the host: sudo chown -R 9999 /data/coolify/ssh && sudo chmod -R 700 /data/coolify/ssh && docker restart coolify');
|
||||
|
||||
PrivateKey::createAndStore([
|
||||
'name' => 'Test Key',
|
||||
|
||||
@@ -24,7 +24,7 @@ it('uses WithoutOverlapping middleware with expireAfter to prevent stale locks',
|
||||
$expiresAfterProperty->setAccessible(true);
|
||||
$expiresAfter = $expiresAfterProperty->getValue($overlappingMiddleware);
|
||||
|
||||
expect($expiresAfter)->toBe(60)
|
||||
expect($expiresAfter)->toBe(90)
|
||||
->and($expiresAfter)->toBeGreaterThan(0, 'expireAfter must be set to prevent stale locks');
|
||||
|
||||
// Check releaseAfter is NOT set (we use dontRelease)
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
<?php
|
||||
|
||||
use App\Actions\Database\StartKeydb;
|
||||
use App\Models\StandaloneKeydb;
|
||||
|
||||
test('keydb config chown command is added when keydb_conf is set', function () {
|
||||
$action = new StartKeydb;
|
||||
$action->configuration_dir = '/data/coolify/databases/test-uuid';
|
||||
$action->commands = [];
|
||||
|
||||
$database = Mockery::mock(StandaloneKeydb::class)->makePartial();
|
||||
$database->shouldReceive('getAttribute')->with('keydb_conf')->andReturn('maxmemory 2gb');
|
||||
$action->database = $database;
|
||||
|
||||
if (! is_null($action->database->keydb_conf) && ! empty($action->database->keydb_conf)) {
|
||||
$action->commands[] = "chown 999:999 {$action->configuration_dir}/keydb.conf";
|
||||
}
|
||||
|
||||
expect($action->commands)->toContain('chown 999:999 /data/coolify/databases/test-uuid/keydb.conf');
|
||||
});
|
||||
|
||||
test('keydb config chown command is not added when keydb_conf is null', function () {
|
||||
$action = new StartKeydb;
|
||||
$action->configuration_dir = '/data/coolify/databases/test-uuid';
|
||||
$action->commands = [];
|
||||
|
||||
$database = Mockery::mock(StandaloneKeydb::class)->makePartial();
|
||||
$database->shouldReceive('getAttribute')->with('keydb_conf')->andReturn(null);
|
||||
$action->database = $database;
|
||||
|
||||
if (! is_null($action->database->keydb_conf) && ! empty($action->database->keydb_conf)) {
|
||||
$action->commands[] = "chown 999:999 {$action->configuration_dir}/keydb.conf";
|
||||
}
|
||||
|
||||
expect($action->commands)->toBeEmpty();
|
||||
});
|
||||
|
||||
test('keydb config chown command is not added when keydb_conf is empty', function () {
|
||||
$action = new StartKeydb;
|
||||
$action->configuration_dir = '/data/coolify/databases/test-uuid';
|
||||
$action->commands = [];
|
||||
|
||||
$database = Mockery::mock(StandaloneKeydb::class)->makePartial();
|
||||
$database->shouldReceive('getAttribute')->with('keydb_conf')->andReturn('');
|
||||
$action->database = $database;
|
||||
|
||||
if (! is_null($action->database->keydb_conf) && ! empty($action->database->keydb_conf)) {
|
||||
$action->commands[] = "chown 999:999 {$action->configuration_dir}/keydb.conf";
|
||||
}
|
||||
|
||||
expect($action->commands)->toBeEmpty();
|
||||
});
|
||||
@@ -0,0 +1,53 @@
|
||||
<?php
|
||||
|
||||
use App\Actions\Database\StartRedis;
|
||||
use App\Models\StandaloneRedis;
|
||||
|
||||
test('redis config chown command is added when redis_conf is set', function () {
|
||||
$action = new StartRedis;
|
||||
$action->configuration_dir = '/data/coolify/databases/test-uuid';
|
||||
$action->commands = [];
|
||||
|
||||
$database = Mockery::mock(StandaloneRedis::class)->makePartial();
|
||||
$database->shouldReceive('getAttribute')->with('redis_conf')->andReturn('maxmemory 2gb');
|
||||
$action->database = $database;
|
||||
|
||||
// Simulate the chown logic from handle()
|
||||
if (! is_null($action->database->redis_conf) && ! empty($action->database->redis_conf)) {
|
||||
$action->commands[] = "chown 999:999 {$action->configuration_dir}/redis.conf";
|
||||
}
|
||||
|
||||
expect($action->commands)->toContain('chown 999:999 /data/coolify/databases/test-uuid/redis.conf');
|
||||
});
|
||||
|
||||
test('redis config chown command is not added when redis_conf is null', function () {
|
||||
$action = new StartRedis;
|
||||
$action->configuration_dir = '/data/coolify/databases/test-uuid';
|
||||
$action->commands = [];
|
||||
|
||||
$database = Mockery::mock(StandaloneRedis::class)->makePartial();
|
||||
$database->shouldReceive('getAttribute')->with('redis_conf')->andReturn(null);
|
||||
$action->database = $database;
|
||||
|
||||
if (! is_null($action->database->redis_conf) && ! empty($action->database->redis_conf)) {
|
||||
$action->commands[] = "chown 999:999 {$action->configuration_dir}/redis.conf";
|
||||
}
|
||||
|
||||
expect($action->commands)->toBeEmpty();
|
||||
});
|
||||
|
||||
test('redis config chown command is not added when redis_conf is empty', function () {
|
||||
$action = new StartRedis;
|
||||
$action->configuration_dir = '/data/coolify/databases/test-uuid';
|
||||
$action->commands = [];
|
||||
|
||||
$database = Mockery::mock(StandaloneRedis::class)->makePartial();
|
||||
$database->shouldReceive('getAttribute')->with('redis_conf')->andReturn('');
|
||||
$action->database = $database;
|
||||
|
||||
if (! is_null($action->database->redis_conf) && ! empty($action->database->redis_conf)) {
|
||||
$action->commands[] = "chown 999:999 {$action->configuration_dir}/redis.conf";
|
||||
}
|
||||
|
||||
expect($action->commands)->toBeEmpty();
|
||||
});
|
||||
Reference in new issue
Block a user