mirror of
https://github.com/tiennm99/coolify.git
synced 2026-10-19 12:16:21 +00:00
Merge remote-tracking branch 'origin/next' into feat/railpack
This commit is contained in:
commit
9717d9ff5a
47 files changed
+2015
-83
No files matched your search
@@ -0,0 +1,445 @@
|
||||
<?php
|
||||
|
||||
use App\Models\Application;
|
||||
use App\Models\Environment;
|
||||
use App\Models\Project;
|
||||
use App\Models\Server;
|
||||
use App\Models\Team;
|
||||
use App\Models\User;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
|
||||
uses(RefreshDatabase::class);
|
||||
|
||||
function makeAuditTeamUser(): array
|
||||
{
|
||||
$team = Team::factory()->create();
|
||||
$user = User::factory()->create();
|
||||
$team->members()->attach($user->id, ['role' => 'owner']);
|
||||
session(['currentTeam' => $team]);
|
||||
test()->actingAs($user);
|
||||
|
||||
return [$team, $user];
|
||||
}
|
||||
|
||||
function makeAuditApiToken(User $user, Team $team, array $abilities = ['root']): string
|
||||
{
|
||||
$token = $user->createToken('audit-test', $abilities);
|
||||
DB::table('personal_access_tokens')->where('id', $token->accessToken->id)->update([
|
||||
'team_id' => $team->id,
|
||||
]);
|
||||
|
||||
return $token->plainTextToken;
|
||||
}
|
||||
|
||||
function makeAuditApplication(string $repo = 'test-org/test-repo'): Application
|
||||
{
|
||||
$team = Team::factory()->create();
|
||||
$project = Project::factory()->create(['team_id' => $team->id]);
|
||||
$environment = Environment::factory()->create(['project_id' => $project->id]);
|
||||
$server = Server::factory()->create(['team_id' => $team->id]);
|
||||
$destination = $server->standaloneDockers()->firstOrFail();
|
||||
|
||||
return Application::create([
|
||||
'name' => 'audit-test-app',
|
||||
'git_repository' => "https://github.com/{$repo}",
|
||||
'git_branch' => 'main',
|
||||
'build_pack' => 'nixpacks',
|
||||
'ports_exposes' => '3000',
|
||||
'environment_id' => $environment->id,
|
||||
'destination_id' => $destination->id,
|
||||
'destination_type' => $destination->getMorphClass(),
|
||||
]);
|
||||
}
|
||||
|
||||
describe('audit channel helper', function () {
|
||||
test('auditLog writes structured payload to audit channel', function () {
|
||||
$auditChannel = Mockery::mock();
|
||||
$auditChannel->shouldReceive('info')
|
||||
->once()
|
||||
->with('test.event', Mockery::on(function ($context) {
|
||||
return $context['event'] === 'test.event'
|
||||
&& $context['custom_field'] === 'value'
|
||||
&& array_key_exists('ip', $context)
|
||||
&& array_key_exists('user_id', $context);
|
||||
}));
|
||||
|
||||
Log::shouldReceive('channel')->with('audit')->andReturn($auditChannel);
|
||||
|
||||
auditLog('test.event', ['custom_field' => 'value']);
|
||||
});
|
||||
|
||||
test('auditLog warning level routes correctly', function () {
|
||||
$auditChannel = Mockery::mock();
|
||||
$auditChannel->shouldReceive('warning')->once()->with('test.failed', Mockery::any());
|
||||
|
||||
Log::shouldReceive('channel')->with('audit')->andReturn($auditChannel);
|
||||
|
||||
auditLog('test.failed', [], 'warning');
|
||||
});
|
||||
|
||||
test('auditLogWebhookFailure logs warning with provider tag', function () {
|
||||
$auditChannel = Mockery::mock();
|
||||
$auditChannel->shouldReceive('warning')
|
||||
->once()
|
||||
->with('webhook.github.signature_failed', Mockery::on(function ($context) {
|
||||
return $context['reason'] === 'invalid_signature'
|
||||
&& $context['event'] === 'webhook.github.signature_failed'
|
||||
&& array_key_exists('ip', $context);
|
||||
}));
|
||||
|
||||
Log::shouldReceive('channel')->with('audit')->andReturn($auditChannel);
|
||||
|
||||
auditLogWebhookFailure('github', 'invalid_signature', ['extra' => 'context']);
|
||||
});
|
||||
|
||||
test('auditLog never includes raw secret keys in context', function () {
|
||||
$captured = null;
|
||||
$auditChannel = Mockery::mock();
|
||||
$auditChannel->shouldReceive('info')
|
||||
->once()
|
||||
->with(Mockery::any(), Mockery::on(function ($context) use (&$captured) {
|
||||
$captured = $context;
|
||||
|
||||
return true;
|
||||
}));
|
||||
|
||||
Log::shouldReceive('channel')->with('audit')->andReturn($auditChannel);
|
||||
|
||||
auditLog('test.private_key.created', [
|
||||
'team_id' => '1',
|
||||
'private_key_uuid' => 'abc',
|
||||
'fingerprint' => 'SHA256:xyz',
|
||||
]);
|
||||
|
||||
expect($captured)->toBeArray();
|
||||
// Helper itself never injects secret-bearing keys.
|
||||
$disallowed = ['private_key', 'password', 'token', 'webhook_secret', 'signature', 'client_secret'];
|
||||
foreach (array_keys($captured) as $key) {
|
||||
expect(in_array(strtolower($key), $disallowed, true))->toBeFalse();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('webhook signature failure logging', function () {
|
||||
test('GitHub manual webhook with bad signature logs to audit channel', function () {
|
||||
$app = makeAuditApplication();
|
||||
|
||||
$auditChannel = Mockery::mock();
|
||||
$auditChannel->shouldReceive('warning')
|
||||
->atLeast()
|
||||
->once()
|
||||
->with('webhook.github.signature_failed', Mockery::any());
|
||||
|
||||
Log::shouldReceive('channel')->with('audit')->andReturn($auditChannel);
|
||||
Log::shouldReceive('warning')->andReturnNull();
|
||||
Log::shouldReceive('info')->andReturnNull();
|
||||
Log::shouldReceive('error')->andReturnNull();
|
||||
|
||||
$payload = json_encode([
|
||||
'ref' => 'refs/heads/main',
|
||||
'repository' => ['full_name' => 'test-org/test-repo'],
|
||||
'after' => 'abc123',
|
||||
'commits' => [],
|
||||
]);
|
||||
|
||||
$response = $this->call('POST', '/webhooks/source/github/events/manual', [], [], [], [
|
||||
'HTTP_X-GitHub-Event' => 'push',
|
||||
'HTTP_X-Hub-Signature-256' => 'sha256=forgedhashvalue',
|
||||
'CONTENT_TYPE' => 'application/json',
|
||||
], $payload);
|
||||
|
||||
$response->assertOk();
|
||||
expect($response->getContent())->toContain('Invalid signature');
|
||||
});
|
||||
|
||||
test('GitLab manual webhook with bad token logs to audit channel', function () {
|
||||
$app = makeAuditApplication();
|
||||
|
||||
$auditChannel = Mockery::mock();
|
||||
$auditChannel->shouldReceive('warning')
|
||||
->atLeast()
|
||||
->once()
|
||||
->with('webhook.gitlab.signature_failed', Mockery::any());
|
||||
|
||||
Log::shouldReceive('channel')->with('audit')->andReturn($auditChannel);
|
||||
Log::shouldReceive('warning')->andReturnNull();
|
||||
Log::shouldReceive('info')->andReturnNull();
|
||||
Log::shouldReceive('error')->andReturnNull();
|
||||
|
||||
$response = $this->postJson('/webhooks/source/gitlab/events/manual', [
|
||||
'object_kind' => 'push',
|
||||
'ref' => 'refs/heads/main',
|
||||
'project' => ['path_with_namespace' => 'test-org/test-repo'],
|
||||
'after' => 'abc123',
|
||||
'commits' => [],
|
||||
], [
|
||||
'X-Gitlab-Token' => 'wrong-token',
|
||||
]);
|
||||
|
||||
$response->assertOk();
|
||||
expect($response->getContent())->toContain('Invalid signature');
|
||||
});
|
||||
|
||||
test('Bitbucket manual webhook with malformed signature logs to audit channel', function () {
|
||||
$app = makeAuditApplication();
|
||||
|
||||
$auditChannel = Mockery::mock();
|
||||
$auditChannel->shouldReceive('warning')
|
||||
->atLeast()
|
||||
->once()
|
||||
->with('webhook.bitbucket.signature_failed', Mockery::any());
|
||||
|
||||
Log::shouldReceive('channel')->with('audit')->andReturn($auditChannel);
|
||||
Log::shouldReceive('warning')->andReturnNull();
|
||||
Log::shouldReceive('info')->andReturnNull();
|
||||
Log::shouldReceive('error')->andReturnNull();
|
||||
|
||||
$payload = json_encode([
|
||||
'push' => ['changes' => [['new' => ['name' => 'main', 'target' => ['hash' => 'abc123']]]]],
|
||||
'repository' => ['full_name' => 'test-org/test-repo'],
|
||||
]);
|
||||
|
||||
$response = $this->call('POST', '/webhooks/source/bitbucket/events/manual', [], [], [], [
|
||||
'HTTP_X-Event-Key' => 'repo:push',
|
||||
'HTTP_X-Hub-Signature' => 'sha1=anyvalue',
|
||||
'CONTENT_TYPE' => 'application/json',
|
||||
], $payload);
|
||||
|
||||
$response->assertOk();
|
||||
expect($response->getContent())->toContain('Invalid signature');
|
||||
});
|
||||
|
||||
test('Gitea manual webhook with bad signature logs to audit channel', function () {
|
||||
$app = makeAuditApplication();
|
||||
|
||||
$auditChannel = Mockery::mock();
|
||||
$auditChannel->shouldReceive('warning')
|
||||
->atLeast()
|
||||
->once()
|
||||
->with('webhook.gitea.signature_failed', Mockery::any());
|
||||
|
||||
Log::shouldReceive('channel')->with('audit')->andReturn($auditChannel);
|
||||
Log::shouldReceive('warning')->andReturnNull();
|
||||
Log::shouldReceive('info')->andReturnNull();
|
||||
Log::shouldReceive('error')->andReturnNull();
|
||||
|
||||
$payload = json_encode([
|
||||
'ref' => 'refs/heads/main',
|
||||
'repository' => ['full_name' => 'test-org/test-repo'],
|
||||
'after' => 'abc123',
|
||||
'commits' => [],
|
||||
]);
|
||||
|
||||
$response = $this->call('POST', '/webhooks/source/gitea/events/manual', [], [], [], [
|
||||
'HTTP_X-Gitea-Event' => 'push',
|
||||
'HTTP_X-Hub-Signature-256' => 'sha256=forgedhashvalue',
|
||||
'CONTENT_TYPE' => 'application/json',
|
||||
], $payload);
|
||||
|
||||
$response->assertOk();
|
||||
expect($response->getContent())->toContain('Invalid signature');
|
||||
});
|
||||
});
|
||||
|
||||
describe('API mutation audit logging', function () {
|
||||
test('private key creation emits api.private_key.created audit event', function () {
|
||||
[$team, $user] = makeAuditTeamUser();
|
||||
$token = makeAuditApiToken($user, $team);
|
||||
|
||||
$auditChannel = Mockery::mock();
|
||||
$auditChannel->shouldReceive('info')
|
||||
->atLeast()
|
||||
->once()
|
||||
->with('api.private_key.created', Mockery::on(function ($context) {
|
||||
return $context['event'] === 'api.private_key.created'
|
||||
&& ! array_key_exists('private_key', $context);
|
||||
}));
|
||||
|
||||
Log::shouldReceive('channel')->with('audit')->andReturn($auditChannel);
|
||||
Log::shouldReceive('warning')->andReturnNull();
|
||||
Log::shouldReceive('info')->andReturnNull();
|
||||
Log::shouldReceive('error')->andReturnNull();
|
||||
|
||||
// Generate a valid OpenSSH-format private key for the test.
|
||||
$opensshKey = "-----BEGIN OPENSSH PRIVATE KEY-----\n".
|
||||
base64_encode(str_repeat('a', 256)).
|
||||
"\n-----END OPENSSH PRIVATE KEY-----";
|
||||
|
||||
$response = $this->withHeaders([
|
||||
'Authorization' => 'Bearer '.$token,
|
||||
'Content-Type' => 'application/json',
|
||||
])->postJson('/api/v1/security/keys', [
|
||||
'name' => 'test-key',
|
||||
'description' => 'audit test',
|
||||
'private_key' => $opensshKey,
|
||||
]);
|
||||
|
||||
// Either 201 or 422 acceptable depending on validation; the assertion above verifies log if 201.
|
||||
expect($response->status())->toBeIn([201, 422]);
|
||||
});
|
||||
|
||||
test('enable_api denial for non-root team emits warning audit event', function () {
|
||||
[$team, $user] = makeAuditTeamUser();
|
||||
$token = makeAuditApiToken($user, $team);
|
||||
|
||||
$auditChannel = Mockery::mock();
|
||||
$auditChannel->shouldReceive('warning')
|
||||
->atLeast()
|
||||
->once()
|
||||
->with('api.instance.enable_denied', Mockery::any());
|
||||
|
||||
Log::shouldReceive('channel')->with('audit')->andReturn($auditChannel);
|
||||
Log::shouldReceive('warning')->andReturnNull();
|
||||
Log::shouldReceive('info')->andReturnNull();
|
||||
Log::shouldReceive('error')->andReturnNull();
|
||||
|
||||
$response = $this->withHeaders([
|
||||
'Authorization' => 'Bearer '.$token,
|
||||
])->getJson('/api/v1/enable');
|
||||
|
||||
$response->assertStatus(403);
|
||||
});
|
||||
|
||||
test('project creation emits api.project.created audit event', function () {
|
||||
[$team, $user] = makeAuditTeamUser();
|
||||
$token = makeAuditApiToken($user, $team);
|
||||
|
||||
$auditChannel = Mockery::mock();
|
||||
$auditChannel->shouldReceive('info')
|
||||
->atLeast()
|
||||
->once()
|
||||
->with('api.project.created', Mockery::on(function ($context) {
|
||||
return $context['event'] === 'api.project.created'
|
||||
&& ! empty($context['project_uuid'])
|
||||
&& $context['project_name'] === 'audit-project';
|
||||
}));
|
||||
|
||||
Log::shouldReceive('channel')->with('audit')->andReturn($auditChannel);
|
||||
Log::shouldReceive('warning')->andReturnNull();
|
||||
Log::shouldReceive('info')->andReturnNull();
|
||||
Log::shouldReceive('error')->andReturnNull();
|
||||
|
||||
$response = $this->withHeaders([
|
||||
'Authorization' => 'Bearer '.$token,
|
||||
'Content-Type' => 'application/json',
|
||||
])->postJson('/api/v1/projects', [
|
||||
'name' => 'audit-project',
|
||||
'description' => 'audit',
|
||||
]);
|
||||
|
||||
$response->assertStatus(201);
|
||||
});
|
||||
});
|
||||
|
||||
describe('threat-detection audit logging (Phase 2)', function () {
|
||||
test('missing bearer token logs api.auth.unauthenticated', function () {
|
||||
$auditChannel = Mockery::mock();
|
||||
$auditChannel->shouldReceive('warning')
|
||||
->atLeast()
|
||||
->once()
|
||||
->with('api.auth.unauthenticated', Mockery::any());
|
||||
|
||||
Log::shouldReceive('channel')->with('audit')->andReturn($auditChannel);
|
||||
Log::shouldReceive('warning')->andReturnNull();
|
||||
Log::shouldReceive('info')->andReturnNull();
|
||||
Log::shouldReceive('error')->andReturnNull();
|
||||
|
||||
$response = $this->getJson('/api/v1/projects');
|
||||
|
||||
$response->assertStatus(401);
|
||||
});
|
||||
|
||||
test('expired bearer token logs api.auth.unauthenticated', function () {
|
||||
[$team, $user] = makeAuditTeamUser();
|
||||
$token = $user->createToken('expired-audit', ['read'], now()->subDay());
|
||||
DB::table('personal_access_tokens')->where('id', $token->accessToken->id)->update([
|
||||
'team_id' => $team->id,
|
||||
]);
|
||||
|
||||
$auditChannel = Mockery::mock();
|
||||
$auditChannel->shouldReceive('warning')
|
||||
->atLeast()
|
||||
->once()
|
||||
->with('api.auth.unauthenticated', Mockery::any());
|
||||
|
||||
Log::shouldReceive('channel')->with('audit')->andReturn($auditChannel);
|
||||
Log::shouldReceive('warning')->andReturnNull();
|
||||
Log::shouldReceive('info')->andReturnNull();
|
||||
Log::shouldReceive('error')->andReturnNull();
|
||||
|
||||
$response = $this->withHeaders([
|
||||
'Authorization' => 'Bearer '.$token->plainTextToken,
|
||||
])->getJson('/api/v1/projects');
|
||||
|
||||
$response->assertStatus(401);
|
||||
});
|
||||
|
||||
test('read-only token hitting write endpoint logs api.auth.ability_denied', function () {
|
||||
[$team, $user] = makeAuditTeamUser();
|
||||
$readToken = makeAuditApiToken($user, $team, ['read']);
|
||||
|
||||
$auditChannel = Mockery::mock();
|
||||
$auditChannel->shouldReceive('warning')
|
||||
->atLeast()
|
||||
->once()
|
||||
->with('api.auth.ability_denied', Mockery::on(function ($ctx) {
|
||||
return in_array('write', $ctx['required_abilities'] ?? [], true);
|
||||
}));
|
||||
|
||||
Log::shouldReceive('channel')->with('audit')->andReturn($auditChannel);
|
||||
Log::shouldReceive('warning')->andReturnNull();
|
||||
Log::shouldReceive('info')->andReturnNull();
|
||||
Log::shouldReceive('error')->andReturnNull();
|
||||
|
||||
$response = $this->withHeaders([
|
||||
'Authorization' => 'Bearer '.$readToken,
|
||||
'Content-Type' => 'application/json',
|
||||
])->postJson('/api/v1/projects', [
|
||||
'name' => 'should-fail',
|
||||
]);
|
||||
|
||||
$response->assertStatus(403);
|
||||
});
|
||||
|
||||
test('sentinel push without Authorization logs token_missing', function () {
|
||||
$auditChannel = Mockery::mock();
|
||||
$auditChannel->shouldReceive('warning')
|
||||
->atLeast()
|
||||
->once()
|
||||
->with('webhook.sentinel.signature_failed', Mockery::on(function ($ctx) {
|
||||
return $ctx['reason'] === 'token_missing';
|
||||
}));
|
||||
|
||||
Log::shouldReceive('channel')->with('audit')->andReturn($auditChannel);
|
||||
Log::shouldReceive('warning')->andReturnNull();
|
||||
Log::shouldReceive('info')->andReturnNull();
|
||||
Log::shouldReceive('error')->andReturnNull();
|
||||
|
||||
$response = $this->postJson('/api/v1/sentinel/push', []);
|
||||
|
||||
$response->assertStatus(401);
|
||||
});
|
||||
|
||||
test('sentinel push with un-decryptable bearer logs decrypt_failed', function () {
|
||||
$auditChannel = Mockery::mock();
|
||||
$auditChannel->shouldReceive('warning')
|
||||
->atLeast()
|
||||
->once()
|
||||
->with('webhook.sentinel.signature_failed', Mockery::on(function ($ctx) {
|
||||
return $ctx['reason'] === 'decrypt_failed';
|
||||
}));
|
||||
|
||||
Log::shouldReceive('channel')->with('audit')->andReturn($auditChannel);
|
||||
Log::shouldReceive('warning')->andReturnNull();
|
||||
Log::shouldReceive('info')->andReturnNull();
|
||||
Log::shouldReceive('error')->andReturnNull();
|
||||
|
||||
$response = $this->withHeaders([
|
||||
'Authorization' => 'Bearer not-a-valid-encrypted-payload',
|
||||
])->postJson('/api/v1/sentinel/push', []);
|
||||
|
||||
$response->assertStatus(401);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,74 @@
|
||||
<?php
|
||||
|
||||
use App\Models\InstanceSettings;
|
||||
use App\Models\Server;
|
||||
use App\Models\Team;
|
||||
use App\Models\User;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
|
||||
uses(RefreshDatabase::class);
|
||||
|
||||
beforeEach(function () {
|
||||
InstanceSettings::forceCreate(['id' => 0, 'is_api_enabled' => true]);
|
||||
|
||||
$this->team = Team::factory()->create();
|
||||
$this->user = User::factory()->create();
|
||||
$this->team->members()->attach($this->user->id, ['role' => 'owner']);
|
||||
session(['currentTeam' => $this->team]);
|
||||
|
||||
$this->server = Server::factory()->create([
|
||||
'team_id' => $this->team->id,
|
||||
]);
|
||||
|
||||
$newToken = $this->user->createToken('write-token', ['write']);
|
||||
$newToken->accessToken->forceFill(['team_id' => $this->team->id])->save();
|
||||
$this->token = $newToken->plainTextToken;
|
||||
});
|
||||
|
||||
it('PATCH updates connection_timeout via API', function () {
|
||||
$response = $this->withHeaders([
|
||||
'Authorization' => 'Bearer '.$this->token,
|
||||
'Content-Type' => 'application/json',
|
||||
])->patchJson('/api/v1/servers/'.$this->server->uuid, [
|
||||
'connection_timeout' => 45,
|
||||
]);
|
||||
|
||||
$response->assertStatus(201);
|
||||
expect($this->server->settings->fresh()->connection_timeout)->toBe(45);
|
||||
});
|
||||
|
||||
it('PATCH rejects connection_timeout out of range', function () {
|
||||
$response = $this->withHeaders([
|
||||
'Authorization' => 'Bearer '.$this->token,
|
||||
'Content-Type' => 'application/json',
|
||||
])->patchJson('/api/v1/servers/'.$this->server->uuid, [
|
||||
'connection_timeout' => 0,
|
||||
]);
|
||||
|
||||
$response->assertStatus(422);
|
||||
$response->assertJsonStructure(['errors' => ['connection_timeout']]);
|
||||
});
|
||||
|
||||
it('PATCH rejects connection_timeout above max', function () {
|
||||
$response = $this->withHeaders([
|
||||
'Authorization' => 'Bearer '.$this->token,
|
||||
'Content-Type' => 'application/json',
|
||||
])->patchJson('/api/v1/servers/'.$this->server->uuid, [
|
||||
'connection_timeout' => 999,
|
||||
]);
|
||||
|
||||
$response->assertStatus(422);
|
||||
$response->assertJsonStructure(['errors' => ['connection_timeout']]);
|
||||
});
|
||||
|
||||
it('PATCH rejects non-integer connection_timeout', function () {
|
||||
$response = $this->withHeaders([
|
||||
'Authorization' => 'Bearer '.$this->token,
|
||||
'Content-Type' => 'application/json',
|
||||
])->patchJson('/api/v1/servers/'.$this->server->uuid, [
|
||||
'connection_timeout' => 'fast',
|
||||
]);
|
||||
|
||||
$response->assertStatus(422);
|
||||
$response->assertJsonStructure(['errors' => ['connection_timeout']]);
|
||||
});
|
||||
@@ -0,0 +1,43 @@
|
||||
<?php
|
||||
|
||||
use App\Helpers\SshMultiplexingHelper;
|
||||
use App\Models\Server;
|
||||
use App\Models\User;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
|
||||
uses(RefreshDatabase::class);
|
||||
|
||||
beforeEach(function () {
|
||||
$user = User::factory()->create();
|
||||
$this->team = $user->teams()->first();
|
||||
$this->server = Server::factory()->create([
|
||||
'team_id' => $this->team->id,
|
||||
]);
|
||||
});
|
||||
|
||||
it('defaults connection_timeout to 10 seconds for new servers', function () {
|
||||
expect($this->server->settings->connection_timeout)->toBe(10);
|
||||
});
|
||||
|
||||
it('persists a custom connection_timeout value', function () {
|
||||
$this->server->settings->connection_timeout = 30;
|
||||
$this->server->settings->save();
|
||||
|
||||
expect($this->server->settings->fresh()->connection_timeout)->toBe(30);
|
||||
});
|
||||
|
||||
it('returns the per-server connection_timeout from getConnectionTimeout', function () {
|
||||
$this->server->settings->connection_timeout = 45;
|
||||
$this->server->settings->save();
|
||||
|
||||
expect(SshMultiplexingHelper::getConnectionTimeout($this->server->fresh()))->toBe(45);
|
||||
});
|
||||
|
||||
it('falls back to config default when connection_timeout is invalid', function () {
|
||||
$this->server->settings->connection_timeout = 0;
|
||||
$this->server->settings->saveQuietly();
|
||||
|
||||
$expected = (int) config('constants.ssh.connection_timeout');
|
||||
|
||||
expect(SshMultiplexingHelper::getConnectionTimeout($this->server->fresh()))->toBe($expected);
|
||||
});
|
||||
@@ -0,0 +1,105 @@
|
||||
<?php
|
||||
|
||||
use App\Events\ServerReachabilityChanged;
|
||||
use App\Models\Server;
|
||||
use App\Models\Team;
|
||||
use App\Notifications\Channels\EmailChannel;
|
||||
use App\Notifications\Server\Reachable;
|
||||
use App\Notifications\Server\Unreachable;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Support\Facades\Notification;
|
||||
|
||||
uses(RefreshDatabase::class);
|
||||
|
||||
beforeEach(function () {
|
||||
$this->team = Team::factory()->create();
|
||||
$this->team->emailNotificationSettings()->update([
|
||||
'use_instance_email_settings' => true,
|
||||
'server_unreachable_email_notifications' => true,
|
||||
'server_reachable_email_notifications' => true,
|
||||
]);
|
||||
|
||||
$this->server = Server::factory()->create(['team_id' => $this->team->id]);
|
||||
|
||||
Notification::fake();
|
||||
});
|
||||
|
||||
it('sends Unreachable notification when threshold reached and not yet notified', function () {
|
||||
$this->server->settings()->update(['is_reachable' => false]);
|
||||
$this->server->forceFill([
|
||||
'unreachable_count' => 2,
|
||||
'unreachable_notification_sent' => false,
|
||||
])->save();
|
||||
|
||||
ServerReachabilityChanged::dispatch($this->server->fresh());
|
||||
|
||||
Notification::assertSentTo($this->team, Unreachable::class);
|
||||
expect($this->server->fresh()->unreachable_notification_sent)->toBeTrue();
|
||||
});
|
||||
|
||||
it('does not send Unreachable on first transient failure (count=1)', function () {
|
||||
$this->server->settings()->update(['is_reachable' => false]);
|
||||
$this->server->forceFill([
|
||||
'unreachable_count' => 1,
|
||||
'unreachable_notification_sent' => false,
|
||||
])->save();
|
||||
|
||||
ServerReachabilityChanged::dispatch($this->server->fresh());
|
||||
|
||||
Notification::assertNothingSent();
|
||||
});
|
||||
|
||||
it('does not send Unreachable when already notified', function () {
|
||||
$this->server->settings()->update(['is_reachable' => false]);
|
||||
$this->server->forceFill([
|
||||
'unreachable_count' => 5,
|
||||
'unreachable_notification_sent' => true,
|
||||
])->save();
|
||||
|
||||
ServerReachabilityChanged::dispatch($this->server->fresh());
|
||||
|
||||
Notification::assertNothingSent();
|
||||
});
|
||||
|
||||
it('sends Reachable notification on recovery when previously notified', function () {
|
||||
$this->server->settings()->update(['is_reachable' => true]);
|
||||
$this->server->forceFill([
|
||||
'unreachable_count' => 0,
|
||||
'unreachable_notification_sent' => true,
|
||||
])->save();
|
||||
|
||||
$fresh = $this->server->fresh();
|
||||
expect($fresh->unreachable_notification_sent)->toBeTrue();
|
||||
expect((bool) $fresh->settings->is_reachable)->toBeTrue();
|
||||
|
||||
ServerReachabilityChanged::dispatch($fresh);
|
||||
|
||||
Notification::assertSentTo($this->team, Reachable::class);
|
||||
expect($this->server->fresh()->unreachable_notification_sent)->toBeFalse();
|
||||
});
|
||||
|
||||
it('does not send Reachable when never notified', function () {
|
||||
$this->server->settings()->update(['is_reachable' => true]);
|
||||
$this->server->forceFill([
|
||||
'unreachable_count' => 0,
|
||||
'unreachable_notification_sent' => false,
|
||||
])->save();
|
||||
|
||||
ServerReachabilityChanged::dispatch($this->server->fresh());
|
||||
|
||||
Notification::assertNothingSent();
|
||||
});
|
||||
|
||||
it('routes Unreachable notification through EmailChannel when email toggle is on', function () {
|
||||
$this->server->settings()->update(['is_reachable' => false]);
|
||||
$this->server->forceFill([
|
||||
'unreachable_count' => 2,
|
||||
'unreachable_notification_sent' => false,
|
||||
])->save();
|
||||
|
||||
ServerReachabilityChanged::dispatch($this->server->fresh());
|
||||
|
||||
Notification::assertSentTo($this->team, Unreachable::class, function ($notification, $channels) {
|
||||
return in_array(EmailChannel::class, $channels);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,61 @@
|
||||
<?php
|
||||
|
||||
use App\Models\Server;
|
||||
|
||||
afterEach(function () {
|
||||
Mockery::close();
|
||||
});
|
||||
|
||||
function makeServerForReachabilityTest(bool $isReachable, bool $notificationSent, int $unreachableCount): Server
|
||||
{
|
||||
$settings = Mockery::mock();
|
||||
$settings->is_reachable = $isReachable;
|
||||
|
||||
$server = Mockery::mock(Server::class)->makePartial()->shouldAllowMockingProtectedMethods();
|
||||
$server->shouldReceive('refresh')->andReturnSelf();
|
||||
$server->shouldReceive('getAttribute')->with('settings')->andReturn($settings);
|
||||
$server->shouldReceive('getAttribute')->with('unreachable_notification_sent')->andReturn($notificationSent);
|
||||
$server->shouldReceive('getAttribute')->with('unreachable_count')->andReturn($unreachableCount);
|
||||
|
||||
return $server;
|
||||
}
|
||||
|
||||
it('sends Reachable notification when reachable and notification was previously sent', function () {
|
||||
$server = makeServerForReachabilityTest(isReachable: true, notificationSent: true, unreachableCount: 0);
|
||||
$server->shouldReceive('sendReachableNotification')->once();
|
||||
$server->shouldNotReceive('sendUnreachableNotification');
|
||||
|
||||
$server->isReachableChanged();
|
||||
});
|
||||
|
||||
it('does not send any notification when reachable and notification was never sent', function () {
|
||||
$server = makeServerForReachabilityTest(isReachable: true, notificationSent: false, unreachableCount: 0);
|
||||
$server->shouldNotReceive('sendReachableNotification');
|
||||
$server->shouldNotReceive('sendUnreachableNotification');
|
||||
|
||||
$server->isReachableChanged();
|
||||
});
|
||||
|
||||
it('sends Unreachable notification when count >= 2 and not yet notified', function () {
|
||||
$server = makeServerForReachabilityTest(isReachable: false, notificationSent: false, unreachableCount: 2);
|
||||
$server->shouldReceive('sendUnreachableNotification')->once();
|
||||
$server->shouldNotReceive('sendReachableNotification');
|
||||
|
||||
$server->isReachableChanged();
|
||||
});
|
||||
|
||||
it('does not send Unreachable notification on first transient failure (count=1)', function () {
|
||||
$server = makeServerForReachabilityTest(isReachable: false, notificationSent: false, unreachableCount: 1);
|
||||
$server->shouldNotReceive('sendUnreachableNotification');
|
||||
$server->shouldNotReceive('sendReachableNotification');
|
||||
|
||||
$server->isReachableChanged();
|
||||
});
|
||||
|
||||
it('does not double-send Unreachable when already notified', function () {
|
||||
$server = makeServerForReachabilityTest(isReachable: false, notificationSent: true, unreachableCount: 5);
|
||||
$server->shouldNotReceive('sendUnreachableNotification');
|
||||
$server->shouldNotReceive('sendReachableNotification');
|
||||
|
||||
$server->isReachableChanged();
|
||||
});
|
||||
@@ -0,0 +1,66 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* Unit tests for LocalFileVolume content size handling.
|
||||
*
|
||||
* Related Issue: #4701 - Storages page becomes unusable when Docker volumes
|
||||
* mount large host files. Coolify previously stored full file content in the
|
||||
* encrypted `content` mediumText column, then serialized it to the Livewire
|
||||
* payload, crashing the browser.
|
||||
*/
|
||||
|
||||
use App\Models\LocalFileVolume;
|
||||
use Tests\TestCase;
|
||||
|
||||
uses(TestCase::class);
|
||||
|
||||
it('exposes a 5 MiB content size limit', function () {
|
||||
expect(LocalFileVolume::MAX_CONTENT_SIZE)->toBe(5_242_880);
|
||||
});
|
||||
|
||||
it('exposes binary and too-large placeholder constants', function () {
|
||||
expect(LocalFileVolume::BINARY_PLACEHOLDER)->toBe('[binary file]');
|
||||
expect(LocalFileVolume::TOO_LARGE_PLACEHOLDER)->toBe('[file too large to display]');
|
||||
});
|
||||
|
||||
it('flags is_too_large when content matches the placeholder', function () {
|
||||
$volume = new LocalFileVolume;
|
||||
$volume->content = LocalFileVolume::TOO_LARGE_PLACEHOLDER;
|
||||
|
||||
expect($volume->is_too_large)->toBeTrue();
|
||||
expect($volume->is_binary)->toBeFalse();
|
||||
});
|
||||
|
||||
it('flags is_binary when content matches the placeholder', function () {
|
||||
$volume = new LocalFileVolume;
|
||||
$volume->content = LocalFileVolume::BINARY_PLACEHOLDER;
|
||||
|
||||
expect($volume->is_binary)->toBeTrue();
|
||||
expect($volume->is_too_large)->toBeFalse();
|
||||
});
|
||||
|
||||
it('does not flag normal content as binary or too large', function () {
|
||||
$volume = new LocalFileVolume;
|
||||
$volume->content = "hello\nworld\n";
|
||||
|
||||
expect($volume->is_binary)->toBeFalse();
|
||||
expect($volume->is_too_large)->toBeFalse();
|
||||
});
|
||||
|
||||
it('does not flag empty content as binary or too large', function () {
|
||||
$volume = new LocalFileVolume;
|
||||
$volume->content = null;
|
||||
|
||||
expect($volume->is_binary)->toBeFalse();
|
||||
expect($volume->is_too_large)->toBeFalse();
|
||||
});
|
||||
|
||||
it('exposes the too-large flag via toArray for Livewire serialization', function () {
|
||||
$volume = new LocalFileVolume;
|
||||
$volume->content = LocalFileVolume::TOO_LARGE_PLACEHOLDER;
|
||||
|
||||
$array = $volume->toArray();
|
||||
|
||||
expect($array)->toHaveKey('is_too_large');
|
||||
expect($array['is_too_large'])->toBeTrue();
|
||||
});
|
||||
@@ -1,11 +1,13 @@
|
||||
<?php
|
||||
|
||||
use App\Events\ServerReachabilityChanged;
|
||||
use App\Jobs\ServerCheckJob;
|
||||
use App\Jobs\ServerConnectionCheckJob;
|
||||
use App\Jobs\ServerManagerJob;
|
||||
use App\Models\Server;
|
||||
use Illuminate\Queue\TimeoutExceededException;
|
||||
use Illuminate\Support\Carbon;
|
||||
use Illuminate\Support\Facades\Event;
|
||||
use Tests\TestCase;
|
||||
|
||||
uses(TestCase::class);
|
||||
@@ -126,16 +128,21 @@ describe('shouldSkipDueToBackoff', function () {
|
||||
|
||||
describe('ServerConnectionCheckJob unreachable_count', function () {
|
||||
it('increments unreachable_count on timeout', function () {
|
||||
Event::fake([ServerReachabilityChanged::class]);
|
||||
|
||||
$settings = Mockery::mock();
|
||||
$settings->is_reachable = true;
|
||||
$settings->shouldReceive('update')
|
||||
->with(['is_reachable' => false, 'is_usable' => false])
|
||||
->once();
|
||||
|
||||
$server = Mockery::mock(Server::class)->makePartial()->shouldAllowMockingProtectedMethods();
|
||||
$server->shouldReceive('getAttribute')->with('settings')->andReturn($settings);
|
||||
$server->shouldReceive('getAttribute')->with('unreachable_notification_sent')->andReturn(false);
|
||||
$server->shouldReceive('increment')->with('unreachable_count')->once();
|
||||
$server->id = 1;
|
||||
$server->name = 'test-server';
|
||||
$server->unreachable_count = 1; // Will become 2 after increment in real code; mock keeps value as-is
|
||||
|
||||
$job = new ServerConnectionCheckJob($server);
|
||||
$job->failed(new TimeoutExceededException);
|
||||
@@ -152,6 +159,50 @@ describe('ServerConnectionCheckJob unreachable_count', function () {
|
||||
});
|
||||
});
|
||||
|
||||
describe('ServerConnectionCheckJob ServerReachabilityChanged dispatch', function () {
|
||||
// ServerReachabilityChanged's constructor calls $server->isReachableChanged() — verifying that
|
||||
// call is a clean proxy for "the event was dispatched", and avoids serializing a Mockery proxy
|
||||
// through the event dispatcher (which trips Eloquent static method lookups on the proxy class).
|
||||
$invoke = function (bool $wasReachable, bool $wasNotified, bool $isReachable, int $unreachableCount, bool $expectDispatch) {
|
||||
$server = Mockery::mock(Server::class)->makePartial()->shouldAllowMockingProtectedMethods();
|
||||
$server->shouldReceive('getAttribute')->with('unreachable_count')->andReturn($unreachableCount);
|
||||
$server->shouldReceive('getAttribute')->with('id')->andReturn(1);
|
||||
if ($expectDispatch) {
|
||||
$server->shouldReceive('isReachableChanged')->once()->andReturnNull();
|
||||
} else {
|
||||
$server->shouldNotReceive('isReachableChanged');
|
||||
}
|
||||
|
||||
$job = new ServerConnectionCheckJob($server);
|
||||
$method = new ReflectionMethod($job, 'dispatchReachabilityChangedIfNeeded');
|
||||
$method->invoke($job, $wasReachable, $wasNotified, $isReachable);
|
||||
};
|
||||
|
||||
it('dispatches event when count crosses unreachable threshold', function () use ($invoke) {
|
||||
$invoke(true, false, false, 2, true);
|
||||
});
|
||||
|
||||
it('does not dispatch on first transient failure (count=1)', function () use ($invoke) {
|
||||
$invoke(true, false, false, 1, false);
|
||||
});
|
||||
|
||||
it('does not dispatch when already notified and still unreachable', function () use ($invoke) {
|
||||
$invoke(false, true, false, 5, false);
|
||||
});
|
||||
|
||||
it('dispatches recovery event when previously unreachable', function () use ($invoke) {
|
||||
$invoke(false, false, true, 0, true);
|
||||
});
|
||||
|
||||
it('dispatches recovery event when previously notified', function () use ($invoke) {
|
||||
$invoke(true, true, true, 0, true);
|
||||
});
|
||||
|
||||
it('does not dispatch when consistently reachable and never notified', function () use ($invoke) {
|
||||
$invoke(true, false, true, 0, false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('ServerCheckJob unreachable_count', function () {
|
||||
it('increments unreachable_count on timeout', function () {
|
||||
$server = Mockery::mock(Server::class)->makePartial()->shouldAllowMockingProtectedMethods();
|
||||
|
||||
Reference in new issue
Block a user