Merge remote-tracking branch 'origin/next' into volume-backups-server-s3

This commit is contained in:
Andras Bacsai committed 2026-07-16 13:47:15 +02:00
commit ddbed9f8a6
38 files changed
+1634 -134

No files matched your search

+14
View File
@@ -18,6 +18,20 @@ it('normalizes requested log line counts', function () {
->and(normalizeLogLines('50000'))->toBe(10000);
});
it('normalizes service resource log line counts before invoking Docker', function (string $controller, mixed $lines, int $expectedLines) {
$source = file_get_contents(__DIR__."/../../../app/Http/Controllers/Api/{$controller}.php");
expect($source)->toContain('$lines = normalizeLogLines($request->query(\'lines\'));')
->and(normalizeLogLines($lines))->toBe($expectedLines);
})->with([
'application invalid value' => ['ServiceApplicationsController', 'invalid', 100],
'application negative value' => ['ServiceApplicationsController', '-5', 100],
'application value above limit' => ['ServiceApplicationsController', '50000', 10000],
'database invalid value' => ['ServiceDatabasesController', 'invalid', 100],
'database negative value' => ['ServiceDatabasesController', '-5', 100],
'database value above limit' => ['ServiceDatabasesController', '50000', 10000],
]);
it('parses show_timestamps query values as booleans', function () {
if (! function_exists('parseLogTimestampFlag')) {
expect(function_exists('parseLogTimestampFlag'))->toBeTrue();
@@ -0,0 +1,36 @@
<?php
use App\Models\StandaloneClickhouse;
use App\Support\ClickhouseBackupCommand;
test('clickhouse exposes scheduled backups', function () {
expect((new StandaloneClickhouse)->isBackupSolutionAvailable())->toBeTrue();
});
test('builds a native clickhouse backup command using container credentials', function () {
$commands = ClickhouseBackupCommand::make(
containerName: 'clickhouse-uuid',
database: 'analytics',
archiveName: 'backup-uuid.zip',
backupDirectory: '/data/coolify/backups/clickhouse',
);
expect($commands)->toHaveCount(3)
->and($commands[0])->toBe("mkdir -p '/data/coolify/backups/clickhouse'")
->and($commands[1])->toContain("docker exec 'clickhouse-uuid' clickhouse-client")
->and($commands[1])->not->toContain('--password')
->and($commands[1])->toContain("BACKUP DATABASE `analytics` TO File('")
->and($commands[1])->toContain('backup-uuid.zip')
->and($commands[2])->toBe("docker cp 'clickhouse-uuid:/var/lib/clickhouse/backups/backup-uuid.zip' '/data/coolify/backups/clickhouse/backup-uuid.zip'")
->and(ClickhouseBackupCommand::cleanup('clickhouse-uuid', 'backup-uuid.zip'))
->toBe("docker exec 'clickhouse-uuid' rm -f '/var/lib/clickhouse/backups/backup-uuid.zip'");
});
test('rejects unsafe clickhouse database names', function () {
expect(fn () => ClickhouseBackupCommand::make(
containerName: 'clickhouse-uuid',
database: 'analytics`; DROP DATABASE default; --',
archiveName: 'backup-uuid.zip',
backupDirectory: '/data/coolify/backups/clickhouse',
))->toThrow(Exception::class);
});
+48
View File
@@ -1,11 +1,59 @@
<?php
use App\Exceptions\RateLimitException;
use App\Services\DigitalOceanService;
use Illuminate\Http\Client\RequestException;
use Illuminate\Support\Facades\Http;
use Tests\TestCase;
uses(TestCase::class);
it('maps final DigitalOcean API error responses', function (int $status, string $message) {
Http::fake([
'https://api.digitalocean.com/v2/droplets' => Http::response(['message' => $message], $status),
]);
$exception = null;
try {
(new DigitalOceanService('test-token'))->createDroplet([]);
} catch (Throwable $caught) {
$exception = $caught;
}
expect($exception)->toBeInstanceOf(Exception::class)
->and($exception)->not->toBeInstanceOf(RequestException::class)
->and($exception->getMessage())->toBe('DigitalOcean API error: '.$message)
->and($exception->getCode())->toBe($status);
Http::assertSentCount(3);
})->with([
'unauthorized' => [401, 'Unable to authenticate you'],
'unprocessable entity' => [422, 'Droplet name is invalid'],
]);
it('maps a final DigitalOcean rate-limit response with Retry-After', function () {
Http::fake([
'https://api.digitalocean.com/v2/droplets' => Http::sequence()
->push(['message' => 'Temporary provider error'], 500)
->push(['message' => 'Temporary provider error'], 500)
->push(['message' => 'Too many requests'], 429, ['Retry-After' => '45']),
]);
try {
(new DigitalOceanService('test-token'))->createDroplet([]);
} catch (RateLimitException $exception) {
expect($exception->getMessage())->toBe('Rate limit exceeded. Please try again later.')
->and($exception->retryAfter)->toBe(45);
Http::assertSentCount(3);
return;
}
test()->fail('Expected a RateLimitException to be thrown.');
});
it('fetches paginated regions from DigitalOcean', function () {
Http::fake([
'https://api.digitalocean.com/v2/regions?page=1&per_page=50' => Http::response([
+6 -2
View File
@@ -1,8 +1,12 @@
<?php
it('normalizes plus and dotted email identity variants', function () {
expect(normalize_email_identity('Ke.VinMcFadden+one@BTInternet.com'))->toBe('kevinmcfadden@btinternet.com');
expect(normalize_email_identity('k.e.v.i.n.m.c.f.a.d.d.e.n+two@btinternet.com'))->toBe('kevinmcfadden@btinternet.com');
expect(normalize_email_identity('Ke.VinMcFadden+one@Gmail.com'))->toBe('kevinmcfadden@gmail.com');
expect(normalize_email_identity('k.e.v.i.n.m.c.f.a.d.d.e.n+two@googlemail.com'))->toBe('kevinmcfadden@googlemail.com');
});
it('preserves dots and plus suffixes for ordinary email providers', function () {
expect(normalize_email_identity(' John.Smith+alerts@Example.com '))->toBe('john.smith+alerts@example.com');
});
it('returns null for blank or malformed email identities', function (?string $email) {
+48
View File
@@ -1,6 +1,8 @@
<?php
use App\Exceptions\RateLimitException;
use App\Services\VultrService;
use Illuminate\Http\Client\RequestException;
use Illuminate\Support\Facades\Http;
use Tests\TestCase;
@@ -10,6 +12,52 @@ beforeEach(function () {
Http::preventStrayRequests();
});
it('maps final Vultr API error responses', function (int $status, string $message) {
Http::fake([
'https://api.vultr.com/v2/instances' => Http::response(['error' => $message], $status),
]);
$exception = null;
try {
(new VultrService('fake-token'))->createInstance([]);
} catch (Throwable $caught) {
$exception = $caught;
}
expect($exception)->toBeInstanceOf(Exception::class)
->and($exception)->not->toBeInstanceOf(RequestException::class)
->and($exception->getMessage())->toBe('Vultr API error: '.$message)
->and($exception->getCode())->toBe($status);
Http::assertSentCount(3);
})->with([
'unauthorized' => [401, 'Unauthorized'],
'unprocessable entity' => [422, 'Invalid instance parameters'],
]);
it('maps a final Vultr rate-limit response with Retry-After', function () {
Http::fake([
'https://api.vultr.com/v2/instances' => Http::sequence()
->push(['error' => 'Temporary provider error'], 500)
->push(['error' => 'Temporary provider error'], 500)
->push(['error' => 'Too many requests'], 429, ['Retry-After' => '30']),
]);
try {
(new VultrService('fake-token'))->createInstance([]);
} catch (RateLimitException $exception) {
expect($exception->getMessage())->toBe('Rate limit exceeded. Please try again later.')
->and($exception->retryAfter)->toBe(30);
Http::assertSentCount(3);
return;
}
test()->fail('Expected a RateLimitException to be thrown.');
});
it('gets instances from Vultr API', function () {
Http::fake([
'https://api.vultr.com/v2/instances*' => Http::response([