Merge remote-tracking branch 'origin/next' into audit-policies

This commit is contained in:
Andras Bacsai committed 2026-03-03 19:41:05 +01:00
commit 4fa048f931
67 files changed
+3715 -973

No files matched your search

+26 -20
View File
@@ -2,6 +2,7 @@
use App\Models\Application;
use App\Models\Environment;
use App\Models\InstanceSettings;
use App\Models\Project;
use App\Models\ScheduledTask;
use App\Models\ScheduledTaskExecution;
@@ -15,6 +16,9 @@ use Illuminate\Foundation\Testing\RefreshDatabase;
uses(RefreshDatabase::class);
beforeEach(function () {
// ApiAllowed middleware requires InstanceSettings with id=0
InstanceSettings::create(['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']);
@@ -25,12 +29,14 @@ beforeEach(function () {
$this->bearerToken = $this->token->plainTextToken;
$this->server = Server::factory()->create(['team_id' => $this->team->id]);
$this->destination = StandaloneDocker::factory()->create(['server_id' => $this->server->id]);
// Server::booted() auto-creates a StandaloneDocker, reuse it
$this->destination = StandaloneDocker::where('server_id', $this->server->id)->first();
// Project::booted() auto-creates a 'production' Environment, reuse it
$this->project = Project::factory()->create(['team_id' => $this->team->id]);
$this->environment = Environment::factory()->create(['project_id' => $this->project->id]);
$this->environment = $this->project->environments()->first();
});
function authHeaders($bearerToken): array
function scheduledTaskAuthHeaders($bearerToken): array
{
return [
'Authorization' => 'Bearer '.$bearerToken,
@@ -46,7 +52,7 @@ describe('GET /api/v1/applications/{uuid}/scheduled-tasks', function () {
'destination_type' => $this->destination->getMorphClass(),
]);
$response = $this->withHeaders(authHeaders($this->bearerToken))
$response = $this->withHeaders(scheduledTaskAuthHeaders($this->bearerToken))
->getJson("/api/v1/applications/{$application->uuid}/scheduled-tasks");
$response->assertStatus(200);
@@ -66,7 +72,7 @@ describe('GET /api/v1/applications/{uuid}/scheduled-tasks', function () {
'name' => 'Test Task',
]);
$response = $this->withHeaders(authHeaders($this->bearerToken))
$response = $this->withHeaders(scheduledTaskAuthHeaders($this->bearerToken))
->getJson("/api/v1/applications/{$application->uuid}/scheduled-tasks");
$response->assertStatus(200);
@@ -75,7 +81,7 @@ describe('GET /api/v1/applications/{uuid}/scheduled-tasks', function () {
});
test('returns 404 for unknown application uuid', function () {
$response = $this->withHeaders(authHeaders($this->bearerToken))
$response = $this->withHeaders(scheduledTaskAuthHeaders($this->bearerToken))
->getJson('/api/v1/applications/nonexistent-uuid/scheduled-tasks');
$response->assertStatus(404);
@@ -90,7 +96,7 @@ describe('POST /api/v1/applications/{uuid}/scheduled-tasks', function () {
'destination_type' => $this->destination->getMorphClass(),
]);
$response = $this->withHeaders(authHeaders($this->bearerToken))
$response = $this->withHeaders(scheduledTaskAuthHeaders($this->bearerToken))
->postJson("/api/v1/applications/{$application->uuid}/scheduled-tasks", [
'name' => 'Backup',
'command' => 'php artisan backup',
@@ -116,7 +122,7 @@ describe('POST /api/v1/applications/{uuid}/scheduled-tasks', function () {
'destination_type' => $this->destination->getMorphClass(),
]);
$response = $this->withHeaders(authHeaders($this->bearerToken))
$response = $this->withHeaders(scheduledTaskAuthHeaders($this->bearerToken))
->postJson("/api/v1/applications/{$application->uuid}/scheduled-tasks", [
'command' => 'echo test',
'frequency' => '* * * * *',
@@ -132,7 +138,7 @@ describe('POST /api/v1/applications/{uuid}/scheduled-tasks', function () {
'destination_type' => $this->destination->getMorphClass(),
]);
$response = $this->withHeaders(authHeaders($this->bearerToken))
$response = $this->withHeaders(scheduledTaskAuthHeaders($this->bearerToken))
->postJson("/api/v1/applications/{$application->uuid}/scheduled-tasks", [
'name' => 'Test',
'command' => 'echo test',
@@ -150,7 +156,7 @@ describe('POST /api/v1/applications/{uuid}/scheduled-tasks', function () {
'destination_type' => $this->destination->getMorphClass(),
]);
$response = $this->withHeaders(authHeaders($this->bearerToken))
$response = $this->withHeaders(scheduledTaskAuthHeaders($this->bearerToken))
->postJson("/api/v1/applications/{$application->uuid}/scheduled-tasks", [
'name' => 'Test',
'command' => 'echo test',
@@ -168,7 +174,7 @@ describe('POST /api/v1/applications/{uuid}/scheduled-tasks', function () {
'destination_type' => $this->destination->getMorphClass(),
]);
$response = $this->withHeaders(authHeaders($this->bearerToken))
$response = $this->withHeaders(scheduledTaskAuthHeaders($this->bearerToken))
->postJson("/api/v1/applications/{$application->uuid}/scheduled-tasks", [
'name' => 'Test',
'command' => 'echo test',
@@ -199,7 +205,7 @@ describe('PATCH /api/v1/applications/{uuid}/scheduled-tasks/{task_uuid}', functi
'name' => 'Old Name',
]);
$response = $this->withHeaders(authHeaders($this->bearerToken))
$response = $this->withHeaders(scheduledTaskAuthHeaders($this->bearerToken))
->patchJson("/api/v1/applications/{$application->uuid}/scheduled-tasks/{$task->uuid}", [
'name' => 'New Name',
]);
@@ -215,7 +221,7 @@ describe('PATCH /api/v1/applications/{uuid}/scheduled-tasks/{task_uuid}', functi
'destination_type' => $this->destination->getMorphClass(),
]);
$response = $this->withHeaders(authHeaders($this->bearerToken))
$response = $this->withHeaders(scheduledTaskAuthHeaders($this->bearerToken))
->patchJson("/api/v1/applications/{$application->uuid}/scheduled-tasks/nonexistent", [
'name' => 'Test',
]);
@@ -237,7 +243,7 @@ describe('DELETE /api/v1/applications/{uuid}/scheduled-tasks/{task_uuid}', funct
'team_id' => $this->team->id,
]);
$response = $this->withHeaders(authHeaders($this->bearerToken))
$response = $this->withHeaders(scheduledTaskAuthHeaders($this->bearerToken))
->deleteJson("/api/v1/applications/{$application->uuid}/scheduled-tasks/{$task->uuid}");
$response->assertStatus(200);
@@ -253,7 +259,7 @@ describe('DELETE /api/v1/applications/{uuid}/scheduled-tasks/{task_uuid}', funct
'destination_type' => $this->destination->getMorphClass(),
]);
$response = $this->withHeaders(authHeaders($this->bearerToken))
$response = $this->withHeaders(scheduledTaskAuthHeaders($this->bearerToken))
->deleteJson("/api/v1/applications/{$application->uuid}/scheduled-tasks/nonexistent");
$response->assertStatus(404);
@@ -279,7 +285,7 @@ describe('GET /api/v1/applications/{uuid}/scheduled-tasks/{task_uuid}/executions
'message' => 'OK',
]);
$response = $this->withHeaders(authHeaders($this->bearerToken))
$response = $this->withHeaders(scheduledTaskAuthHeaders($this->bearerToken))
->getJson("/api/v1/applications/{$application->uuid}/scheduled-tasks/{$task->uuid}/executions");
$response->assertStatus(200);
@@ -294,7 +300,7 @@ describe('GET /api/v1/applications/{uuid}/scheduled-tasks/{task_uuid}/executions
'destination_type' => $this->destination->getMorphClass(),
]);
$response = $this->withHeaders(authHeaders($this->bearerToken))
$response = $this->withHeaders(scheduledTaskAuthHeaders($this->bearerToken))
->getJson("/api/v1/applications/{$application->uuid}/scheduled-tasks/nonexistent/executions");
$response->assertStatus(404);
@@ -316,7 +322,7 @@ describe('Service scheduled tasks API', function () {
'name' => 'Service Task',
]);
$response = $this->withHeaders(authHeaders($this->bearerToken))
$response = $this->withHeaders(scheduledTaskAuthHeaders($this->bearerToken))
->getJson("/api/v1/services/{$service->uuid}/scheduled-tasks");
$response->assertStatus(200);
@@ -332,7 +338,7 @@ describe('Service scheduled tasks API', function () {
'environment_id' => $this->environment->id,
]);
$response = $this->withHeaders(authHeaders($this->bearerToken))
$response = $this->withHeaders(scheduledTaskAuthHeaders($this->bearerToken))
->postJson("/api/v1/services/{$service->uuid}/scheduled-tasks", [
'name' => 'Service Backup',
'command' => 'pg_dump',
@@ -356,7 +362,7 @@ describe('Service scheduled tasks API', function () {
'team_id' => $this->team->id,
]);
$response = $this->withHeaders(authHeaders($this->bearerToken))
$response = $this->withHeaders(scheduledTaskAuthHeaders($this->bearerToken))
->deleteJson("/api/v1/services/{$service->uuid}/scheduled-tasks/{$task->uuid}");
$response->assertStatus(200);
+88
View File
@@ -0,0 +1,88 @@
<?php
use App\Models\Application;
use App\Models\ApplicationSetting;
describe('Application Rollback', function () {
beforeEach(function () {
$this->application = new Application;
$this->application->forceFill([
'uuid' => 'test-app-uuid',
'git_commit_sha' => 'HEAD',
]);
$settings = new ApplicationSetting;
$settings->is_git_shallow_clone_enabled = false;
$settings->is_git_submodules_enabled = false;
$settings->is_git_lfs_enabled = false;
$this->application->setRelation('settings', $settings);
});
test('setGitImportSettings uses passed commit instead of application git_commit_sha', function () {
$rollbackCommit = 'abc123def456abc123def456abc123def456abc1';
$result = $this->application->setGitImportSettings(
deployment_uuid: 'test-uuid',
git_clone_command: 'git clone',
public: true,
commit: $rollbackCommit
);
expect($result)->toContain($rollbackCommit);
});
test('setGitImportSettings with shallow clone fetches specific commit', function () {
$this->application->settings->is_git_shallow_clone_enabled = true;
$rollbackCommit = 'abc123def456abc123def456abc123def456abc1';
$result = $this->application->setGitImportSettings(
deployment_uuid: 'test-uuid',
git_clone_command: 'git clone',
public: true,
commit: $rollbackCommit
);
expect($result)
->toContain('git fetch --depth=1 origin')
->toContain($rollbackCommit);
});
test('setGitImportSettings falls back to git_commit_sha when no commit passed', function () {
$this->application->git_commit_sha = 'def789abc012def789abc012def789abc012def7';
$result = $this->application->setGitImportSettings(
deployment_uuid: 'test-uuid',
git_clone_command: 'git clone',
public: true,
);
expect($result)->toContain('def789abc012def789abc012def789abc012def7');
});
test('setGitImportSettings escapes shell metacharacters in commit parameter', function () {
$maliciousCommit = 'abc123; rm -rf /';
$result = $this->application->setGitImportSettings(
deployment_uuid: 'test-uuid',
git_clone_command: 'git clone',
public: true,
commit: $maliciousCommit
);
// escapeshellarg wraps the value in single quotes, neutralizing metacharacters
expect($result)
->toContain("checkout 'abc123; rm -rf /'")
->not->toContain('checkout abc123; rm -rf /');
});
test('setGitImportSettings does not append checkout when commit is HEAD', function () {
$result = $this->application->setGitImportSettings(
deployment_uuid: 'test-uuid',
git_clone_command: 'git clone',
public: true,
);
expect($result)->not->toContain('advice.detachedHead=false checkout');
});
});
@@ -198,3 +198,20 @@ test('ConvertEntrypointSingleQuotedWithDoubleQuotesInside', function () {
'entrypoint' => 'python -c "print(\"hi\")"',
]);
});
test('ConvertIp6', function () {
$input = '--ip6 2001:db8::1';
$output = convertDockerRunToCompose($input);
expect($output)->toBe([
'ip6' => ['2001:db8::1'],
]);
});
test('ConvertIpAndIp6Together', function () {
$input = '--ip 172.20.0.5 --ip6 2001:db8::1';
$output = convertDockerRunToCompose($input);
expect($output)->toBe([
'ip' => ['172.20.0.5'],
'ip6' => ['2001:db8::1'],
]);
});
@@ -0,0 +1,64 @@
<?php
use App\Models\InstanceSettings;
use Illuminate\Support\Facades\Cache;
uses(\Illuminate\Foundation\Testing\RefreshDatabase::class);
beforeEach(function () {
Cache::forget('instance_settings_fqdn_host');
InstanceSettings::updateOrCreate(['id' => 0], ['fqdn' => null]);
// Ensure session.secure starts unconfigured for each test
config(['session.secure' => null]);
});
it('sets session.secure to true when request arrives over HTTPS via proxy', function () {
$this->get('/login', [
'X-Forwarded-Proto' => 'https',
'X-Forwarded-For' => '1.2.3.4',
]);
expect(config('session.secure'))->toBeTrue();
});
it('does not set session.secure for plain HTTP requests', function () {
$this->get('/login');
expect(config('session.secure'))->toBeNull();
});
it('does not override explicit SESSION_SECURE_COOKIE=false for HTTPS requests', function () {
config(['session.secure' => false]);
$this->get('/login', [
'X-Forwarded-Proto' => 'https',
'X-Forwarded-For' => '1.2.3.4',
]);
// Explicit false must not be overridden — our check is `=== null` only
expect(config('session.secure'))->toBeFalse();
});
it('does not override explicit SESSION_SECURE_COOKIE=true', function () {
config(['session.secure' => true]);
$this->get('/login');
expect(config('session.secure'))->toBeTrue();
});
it('marks session cookie with Secure flag when accessed over HTTPS proxy', function () {
$response = $this->get('/login', [
'X-Forwarded-Proto' => 'https',
'X-Forwarded-For' => '1.2.3.4',
]);
$response->assertSuccessful();
$cookieName = config('session.cookie');
$sessionCookie = collect($response->headers->all('set-cookie'))
->first(fn ($c) => str_contains($c, $cookieName));
expect($sessionCookie)->not->toBeNull()
->and(strtolower($sessionCookie))->toContain('; secure');
});
+203 -5
View File
@@ -86,7 +86,7 @@ test('IP allowlist handles empty and invalid entries', function () {
expect(checkIPAgainstAllowlist('192.168.1.1', ['192.168.1.0/-1']))->toBeFalse(); // Invalid mask
});
test('IP allowlist with various subnet sizes', function () {
test('IP allowlist with various IPv4 subnet sizes', function () {
// /32 - single host
expect(checkIPAgainstAllowlist('192.168.1.1', ['192.168.1.1/32']))->toBeTrue();
expect(checkIPAgainstAllowlist('192.168.1.2', ['192.168.1.1/32']))->toBeFalse();
@@ -96,16 +96,98 @@ test('IP allowlist with various subnet sizes', function () {
expect(checkIPAgainstAllowlist('192.168.1.1', ['192.168.1.0/31']))->toBeTrue();
expect(checkIPAgainstAllowlist('192.168.1.2', ['192.168.1.0/31']))->toBeFalse();
// /16 - class B
// /25 - half a /24
expect(checkIPAgainstAllowlist('192.168.1.1', ['192.168.1.0/25']))->toBeTrue();
expect(checkIPAgainstAllowlist('192.168.1.127', ['192.168.1.0/25']))->toBeTrue();
expect(checkIPAgainstAllowlist('192.168.1.128', ['192.168.1.0/25']))->toBeFalse();
// /16
expect(checkIPAgainstAllowlist('172.16.0.1', ['172.16.0.0/16']))->toBeTrue();
expect(checkIPAgainstAllowlist('172.16.255.255', ['172.16.0.0/16']))->toBeTrue();
expect(checkIPAgainstAllowlist('172.17.0.1', ['172.16.0.0/16']))->toBeFalse();
// /12
expect(checkIPAgainstAllowlist('172.16.0.1', ['172.16.0.0/12']))->toBeTrue();
expect(checkIPAgainstAllowlist('172.31.255.255', ['172.16.0.0/12']))->toBeTrue();
expect(checkIPAgainstAllowlist('172.32.0.1', ['172.16.0.0/12']))->toBeFalse();
// /8
expect(checkIPAgainstAllowlist('10.255.255.255', ['10.0.0.0/8']))->toBeTrue();
expect(checkIPAgainstAllowlist('11.0.0.1', ['10.0.0.0/8']))->toBeFalse();
// /0 - all addresses
expect(checkIPAgainstAllowlist('1.1.1.1', ['0.0.0.0/0']))->toBeTrue();
expect(checkIPAgainstAllowlist('255.255.255.255', ['0.0.0.0/0']))->toBeTrue();
});
test('IP allowlist with various IPv6 subnet sizes', function () {
// /128 - single host
expect(checkIPAgainstAllowlist('2001:db8::1', ['2001:db8::1/128']))->toBeTrue();
expect(checkIPAgainstAllowlist('2001:db8::2', ['2001:db8::1/128']))->toBeFalse();
// /127 - point-to-point link
expect(checkIPAgainstAllowlist('2001:db8::0', ['2001:db8::/127']))->toBeTrue();
expect(checkIPAgainstAllowlist('2001:db8::1', ['2001:db8::/127']))->toBeTrue();
expect(checkIPAgainstAllowlist('2001:db8::2', ['2001:db8::/127']))->toBeFalse();
// /64 - standard subnet
expect(checkIPAgainstAllowlist('2001:db8:abcd:1234::1', ['2001:db8:abcd:1234::/64']))->toBeTrue();
expect(checkIPAgainstAllowlist('2001:db8:abcd:1234:ffff:ffff:ffff:ffff', ['2001:db8:abcd:1234::/64']))->toBeTrue();
expect(checkIPAgainstAllowlist('2001:db8:abcd:1235::1', ['2001:db8:abcd:1234::/64']))->toBeFalse();
// /48 - site prefix
expect(checkIPAgainstAllowlist('2001:db8:1234::1', ['2001:db8:1234::/48']))->toBeTrue();
expect(checkIPAgainstAllowlist('2001:db8:1234:ffff::1', ['2001:db8:1234::/48']))->toBeTrue();
expect(checkIPAgainstAllowlist('2001:db8:1235::1', ['2001:db8:1234::/48']))->toBeFalse();
// /32 - ISP allocation
expect(checkIPAgainstAllowlist('2001:db8::1', ['2001:db8::/32']))->toBeTrue();
expect(checkIPAgainstAllowlist('2001:db8:ffff:ffff::1', ['2001:db8::/32']))->toBeTrue();
expect(checkIPAgainstAllowlist('2001:db9::1', ['2001:db8::/32']))->toBeFalse();
// /16
expect(checkIPAgainstAllowlist('2001:0000::1', ['2001::/16']))->toBeTrue();
expect(checkIPAgainstAllowlist('2001:ffff:ffff::1', ['2001::/16']))->toBeTrue();
expect(checkIPAgainstAllowlist('2002::1', ['2001::/16']))->toBeFalse();
});
test('IP allowlist with bare IPv6 addresses', function () {
expect(checkIPAgainstAllowlist('2001:db8::1', ['2001:db8::1']))->toBeTrue();
expect(checkIPAgainstAllowlist('2001:db8::2', ['2001:db8::1']))->toBeFalse();
expect(checkIPAgainstAllowlist('::1', ['::1']))->toBeTrue();
expect(checkIPAgainstAllowlist('::1', ['::2']))->toBeFalse();
});
test('IP allowlist with IPv6 CIDR notation', function () {
// /64 prefix — issue #8729 exact case
expect(checkIPAgainstAllowlist('2a01:e0a:21d:8230::1', ['2a01:e0a:21d:8230::/64']))->toBeTrue();
expect(checkIPAgainstAllowlist('2a01:e0a:21d:8230:abcd:ef01:2345:6789', ['2a01:e0a:21d:8230::/64']))->toBeTrue();
expect(checkIPAgainstAllowlist('2a01:e0a:21d:8231::1', ['2a01:e0a:21d:8230::/64']))->toBeFalse();
// /128 — single host
expect(checkIPAgainstAllowlist('2001:db8::1', ['2001:db8::1/128']))->toBeTrue();
expect(checkIPAgainstAllowlist('2001:db8::2', ['2001:db8::1/128']))->toBeFalse();
// /48 prefix
expect(checkIPAgainstAllowlist('2001:db8:1234::1', ['2001:db8:1234::/48']))->toBeTrue();
expect(checkIPAgainstAllowlist('2001:db8:1235::1', ['2001:db8:1234::/48']))->toBeFalse();
});
test('IP allowlist with mixed IPv4 and IPv6', function () {
$allowlist = ['192.168.1.100', '10.0.0.0/8', '2a01:e0a:21d:8230::/64'];
expect(checkIPAgainstAllowlist('192.168.1.100', $allowlist))->toBeTrue();
expect(checkIPAgainstAllowlist('10.5.5.5', $allowlist))->toBeTrue();
expect(checkIPAgainstAllowlist('2a01:e0a:21d:8230::cafe', $allowlist))->toBeTrue();
expect(checkIPAgainstAllowlist('2a01:e0a:21d:8231::1', $allowlist))->toBeFalse();
expect(checkIPAgainstAllowlist('8.8.8.8', $allowlist))->toBeFalse();
});
test('IP allowlist handles invalid IPv6 masks', function () {
expect(checkIPAgainstAllowlist('2001:db8::1', ['2001:db8::/129']))->toBeFalse(); // mask > 128
expect(checkIPAgainstAllowlist('2001:db8::1', ['2001:db8::/-1']))->toBeFalse(); // negative mask
});
test('IP allowlist comma-separated string input', function () {
// Test with comma-separated string (as it would come from the settings)
$allowlistString = '192.168.1.100,10.0.0.0/8,172.16.0.0/16';
@@ -134,14 +216,21 @@ test('ValidIpOrCidr validation rule', function () {
// Valid cases - should pass
expect($validate(''))->toBeTrue(); // Empty is allowed
expect($validate('0.0.0.0'))->toBeTrue(); // 0.0.0.0 is allowed
expect($validate('192.168.1.1'))->toBeTrue(); // Valid IP
expect($validate('192.168.1.0/24'))->toBeTrue(); // Valid CIDR
expect($validate('10.0.0.0/8'))->toBeTrue(); // Valid CIDR
expect($validate('192.168.1.1'))->toBeTrue(); // Valid IPv4
expect($validate('192.168.1.0/24'))->toBeTrue(); // Valid IPv4 CIDR
expect($validate('10.0.0.0/8'))->toBeTrue(); // Valid IPv4 CIDR
expect($validate('192.168.1.1,10.0.0.1'))->toBeTrue(); // Multiple valid IPs
expect($validate('192.168.1.0/24,10.0.0.0/8'))->toBeTrue(); // Multiple CIDRs
expect($validate('0.0.0.0/0'))->toBeTrue(); // 0.0.0.0 with subnet
expect($validate('0.0.0.0/24'))->toBeTrue(); // 0.0.0.0 with any subnet
expect($validate(' 192.168.1.1 '))->toBeTrue(); // With spaces
// IPv6 valid cases — issue #8729
expect($validate('2001:db8::1'))->toBeTrue(); // Valid bare IPv6
expect($validate('::1'))->toBeTrue(); // Loopback IPv6
expect($validate('2a01:e0a:21d:8230::/64'))->toBeTrue(); // IPv6 /64 CIDR
expect($validate('2001:db8::/48'))->toBeTrue(); // IPv6 /48 CIDR
expect($validate('2001:db8::1/128'))->toBeTrue(); // IPv6 /128 CIDR
expect($validate('192.168.1.1,2a01:e0a:21d:8230::/64'))->toBeTrue(); // Mixed IPv4 + IPv6 CIDR
// Invalid cases - should fail
expect($validate('1'))->toBeFalse(); // Single digit
@@ -155,6 +244,7 @@ test('ValidIpOrCidr validation rule', function () {
expect($validate('not.an.ip.address'))->toBeFalse(); // Invalid format
expect($validate('192.168'))->toBeFalse(); // Incomplete IP
expect($validate('192.168.1.1.1'))->toBeFalse(); // Too many octets
expect($validate('2001:db8::/129'))->toBeFalse(); // IPv6 mask > 128
});
test('ValidIpOrCidr validation rule error messages', function () {
@@ -181,3 +271,111 @@ test('ValidIpOrCidr validation rule error messages', function () {
expect($error)->toContain('10.0.0.256');
expect($error)->not->toContain('192.168.1.1'); // Valid IP should not be in error
});
test('deduplicateAllowlist removes bare IPv4 covered by various subnets', function () {
// /24
expect(deduplicateAllowlist(['192.168.1.5', '192.168.1.0/24']))->toBe(['192.168.1.0/24']);
// /16
expect(deduplicateAllowlist(['172.16.5.10', '172.16.0.0/16']))->toBe(['172.16.0.0/16']);
// /8
expect(deduplicateAllowlist(['10.50.100.200', '10.0.0.0/8']))->toBe(['10.0.0.0/8']);
// /32 — same host, first entry wins (both equivalent)
expect(deduplicateAllowlist(['192.168.1.1', '192.168.1.1/32']))->toBe(['192.168.1.1']);
// /31 — point-to-point
expect(deduplicateAllowlist(['192.168.1.0', '192.168.1.0/31']))->toBe(['192.168.1.0/31']);
// IP outside subnet — both preserved
expect(deduplicateAllowlist(['172.17.0.1', '172.16.0.0/16']))->toBe(['172.17.0.1', '172.16.0.0/16']);
});
test('deduplicateAllowlist removes narrow IPv4 CIDR covered by broader CIDR', function () {
// /32 inside /24
expect(deduplicateAllowlist(['192.168.1.1/32', '192.168.1.0/24']))->toBe(['192.168.1.0/24']);
// /25 inside /24
expect(deduplicateAllowlist(['192.168.1.0/25', '192.168.1.0/24']))->toBe(['192.168.1.0/24']);
// /24 inside /16
expect(deduplicateAllowlist(['192.168.1.0/24', '192.168.0.0/16']))->toBe(['192.168.0.0/16']);
// /16 inside /12
expect(deduplicateAllowlist(['172.16.0.0/16', '172.16.0.0/12']))->toBe(['172.16.0.0/12']);
// /16 inside /8
expect(deduplicateAllowlist(['10.1.0.0/16', '10.0.0.0/8']))->toBe(['10.0.0.0/8']);
// /24 inside /8
expect(deduplicateAllowlist(['10.1.2.0/24', '10.0.0.0/8']))->toBe(['10.0.0.0/8']);
// /12 inside /8
expect(deduplicateAllowlist(['172.16.0.0/12', '172.0.0.0/8']))->toBe(['172.0.0.0/8']);
// /31 inside /24
expect(deduplicateAllowlist(['192.168.1.0/31', '192.168.1.0/24']))->toBe(['192.168.1.0/24']);
// Non-overlapping CIDRs — both preserved
expect(deduplicateAllowlist(['192.168.1.0/24', '10.0.0.0/8']))->toBe(['192.168.1.0/24', '10.0.0.0/8']);
expect(deduplicateAllowlist(['172.16.0.0/16', '192.168.0.0/16']))->toBe(['172.16.0.0/16', '192.168.0.0/16']);
});
test('deduplicateAllowlist removes bare IPv6 covered by various prefixes', function () {
// /64 — issue #8729 exact scenario
expect(deduplicateAllowlist(['2a01:e0a:21d:8230::', '127.0.0.1', '2a01:e0a:21d:8230::/64']))
->toBe(['127.0.0.1', '2a01:e0a:21d:8230::/64']);
// /48
expect(deduplicateAllowlist(['2001:db8:1234::1', '2001:db8:1234::/48']))->toBe(['2001:db8:1234::/48']);
// /128 — same host, first entry wins (both equivalent)
expect(deduplicateAllowlist(['2001:db8::1', '2001:db8::1/128']))->toBe(['2001:db8::1']);
// IP outside prefix — both preserved
expect(deduplicateAllowlist(['2001:db8:1235::1', '2001:db8:1234::/48']))
->toBe(['2001:db8:1235::1', '2001:db8:1234::/48']);
});
test('deduplicateAllowlist removes narrow IPv6 CIDR covered by broader prefix', function () {
// /128 inside /64
expect(deduplicateAllowlist(['2a01:e0a:21d:8230::5/128', '2a01:e0a:21d:8230::/64']))->toBe(['2a01:e0a:21d:8230::/64']);
// /127 inside /64
expect(deduplicateAllowlist(['2001:db8:1234:5678::/127', '2001:db8:1234:5678::/64']))->toBe(['2001:db8:1234:5678::/64']);
// /64 inside /48
expect(deduplicateAllowlist(['2001:db8:1234:5678::/64', '2001:db8:1234::/48']))->toBe(['2001:db8:1234::/48']);
// /48 inside /32
expect(deduplicateAllowlist(['2001:db8:abcd::/48', '2001:db8::/32']))->toBe(['2001:db8::/32']);
// /32 inside /16
expect(deduplicateAllowlist(['2001:db8::/32', '2001::/16']))->toBe(['2001::/16']);
// /64 inside /32
expect(deduplicateAllowlist(['2001:db8:1234:5678::/64', '2001:db8::/32']))->toBe(['2001:db8::/32']);
// Non-overlapping IPv6 — both preserved
expect(deduplicateAllowlist(['2001:db8::/32', 'fd00::/8']))->toBe(['2001:db8::/32', 'fd00::/8']);
expect(deduplicateAllowlist(['2001:db8:1234::/48', '2001:db8:5678::/48']))->toBe(['2001:db8:1234::/48', '2001:db8:5678::/48']);
});
test('deduplicateAllowlist mixed IPv4 and IPv6 subnets', function () {
$result = deduplicateAllowlist([
'192.168.1.5', // covered by 192.168.0.0/16
'192.168.0.0/16',
'2a01:e0a:21d:8230::1', // covered by ::/64
'2a01:e0a:21d:8230::/64',
'10.0.0.1', // not covered by anything
'::1', // not covered by anything
]);
expect($result)->toBe(['192.168.0.0/16', '2a01:e0a:21d:8230::/64', '10.0.0.1', '::1']);
});
test('deduplicateAllowlist preserves non-overlapping entries', function () {
$result = deduplicateAllowlist(['192.168.1.1', '10.0.0.1', '172.16.0.0/16']);
expect($result)->toBe(['192.168.1.1', '10.0.0.1', '172.16.0.0/16']);
});
test('deduplicateAllowlist handles exact duplicates', function () {
expect(deduplicateAllowlist(['192.168.1.1', '192.168.1.1']))->toBe(['192.168.1.1']);
expect(deduplicateAllowlist(['10.0.0.0/8', '10.0.0.0/8']))->toBe(['10.0.0.0/8']);
expect(deduplicateAllowlist(['2001:db8::1', '2001:db8::1']))->toBe(['2001:db8::1']);
});
test('deduplicateAllowlist handles single entry and empty array', function () {
expect(deduplicateAllowlist(['10.0.0.1']))->toBe(['10.0.0.1']);
expect(deduplicateAllowlist([]))->toBe([]);
});
test('deduplicateAllowlist with 0.0.0.0 removes everything else', function () {
$result = deduplicateAllowlist(['192.168.1.1', '0.0.0.0', '10.0.0.0/8']);
expect($result)->toBe(['0.0.0.0']);
});
test('deduplicateAllowlist multiple nested CIDRs keeps only broadest', function () {
// IPv4: three levels of nesting
expect(deduplicateAllowlist(['10.1.2.0/24', '10.1.0.0/16', '10.0.0.0/8']))->toBe(['10.0.0.0/8']);
// IPv6: three levels of nesting
expect(deduplicateAllowlist(['2001:db8:1234:5678::/64', '2001:db8:1234::/48', '2001:db8::/32']))->toBe(['2001:db8::/32']);
});
@@ -286,6 +286,56 @@ it('skips host validation for API routes', function () {
expect($response->status())->not->toBe(400);
});
it('trusts localhost when FQDN is configured', function () {
InstanceSettings::updateOrCreate(
['id' => 0],
['fqdn' => 'https://coolify.example.com']
);
$middleware = new TrustHosts($this->app);
$hosts = $middleware->hosts();
expect($hosts)->toContain('localhost');
});
it('trusts 127.0.0.1 when FQDN is configured', function () {
InstanceSettings::updateOrCreate(
['id' => 0],
['fqdn' => 'https://coolify.example.com']
);
$middleware = new TrustHosts($this->app);
$hosts = $middleware->hosts();
expect($hosts)->toContain('127.0.0.1');
});
it('trusts IPv6 loopback when FQDN is configured', function () {
InstanceSettings::updateOrCreate(
['id' => 0],
['fqdn' => 'https://coolify.example.com']
);
$middleware = new TrustHosts($this->app);
$hosts = $middleware->hosts();
expect($hosts)->toContain('[::1]');
});
it('allows local access via localhost when FQDN is configured and request uses localhost host header', function () {
InstanceSettings::updateOrCreate(
['id' => 0],
['fqdn' => 'https://coolify.example.com']
);
$response = $this->get('/', [
'Host' => 'localhost',
]);
// Should NOT be rejected as untrusted host (would be 400)
expect($response->status())->not->toBe(400);
});
it('skips host validation for webhook endpoints', function () {
// All webhook routes are under /webhooks/* prefix (see RouteServiceProvider)
// and use cryptographic signature validation instead of host validation
+83
View File
@@ -0,0 +1,83 @@
<?php
use App\Jobs\ServerLimitCheckJob;
use App\Models\Server;
use App\Models\Team;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Notification;
uses(RefreshDatabase::class);
beforeEach(function () {
config()->set('constants.coolify.self_hosted', false);
Notification::fake();
$this->team = Team::factory()->create(['custom_server_limit' => 5]);
});
function createServerForTeam(Team $team, bool $forceDisabled = false): Server
{
$server = Server::factory()->create(['team_id' => $team->id]);
if ($forceDisabled) {
$server->settings()->update(['force_disabled' => true]);
}
return $server->fresh(['settings']);
}
it('re-enables force-disabled servers when under the limit', function () {
createServerForTeam($this->team);
$server2 = createServerForTeam($this->team, forceDisabled: true);
$server3 = createServerForTeam($this->team, forceDisabled: true);
expect($server2->settings->force_disabled)->toBeTruthy();
expect($server3->settings->force_disabled)->toBeTruthy();
// 3 servers, limit 5 → all should be re-enabled
ServerLimitCheckJob::dispatchSync($this->team);
expect($server2->fresh()->settings->force_disabled)->toBeFalsy();
expect($server3->fresh()->settings->force_disabled)->toBeFalsy();
});
it('re-enables force-disabled servers when exactly at the limit', function () {
$this->team->update(['custom_server_limit' => 3]);
createServerForTeam($this->team);
createServerForTeam($this->team);
$server3 = createServerForTeam($this->team, forceDisabled: true);
// 3 servers, limit 3 → disabled one should be re-enabled
ServerLimitCheckJob::dispatchSync($this->team);
expect($server3->fresh()->settings->force_disabled)->toBeFalsy();
});
it('disables newest servers when over the limit', function () {
$this->team->update(['custom_server_limit' => 2]);
$oldest = createServerForTeam($this->team);
sleep(1);
$middle = createServerForTeam($this->team);
sleep(1);
$newest = createServerForTeam($this->team);
// 3 servers, limit 2 → newest 1 should be disabled
ServerLimitCheckJob::dispatchSync($this->team);
expect($oldest->fresh()->settings->force_disabled)->toBeFalsy();
expect($middle->fresh()->settings->force_disabled)->toBeFalsy();
expect($newest->fresh()->settings->force_disabled)->toBeTruthy();
});
it('does not change servers when under limit and none are force-disabled', function () {
$server1 = createServerForTeam($this->team);
$server2 = createServerForTeam($this->team);
// 2 servers, limit 5 → nothing to do
ServerLimitCheckJob::dispatchSync($this->team);
expect($server1->fresh()->settings->force_disabled)->toBeFalsy();
expect($server2->fresh()->settings->force_disabled)->toBeFalsy();
});
@@ -0,0 +1,96 @@
<?php
use App\Actions\Stripe\CancelSubscriptionAtPeriodEnd;
use App\Models\Subscription;
use App\Models\Team;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Stripe\Service\SubscriptionService;
use Stripe\StripeClient;
uses(RefreshDatabase::class);
beforeEach(function () {
config()->set('constants.coolify.self_hosted', false);
config()->set('subscription.provider', 'stripe');
config()->set('subscription.stripe_api_key', 'sk_test_fake');
$this->team = Team::factory()->create();
$this->user = User::factory()->create();
$this->team->members()->attach($this->user->id, ['role' => 'owner']);
$this->subscription = Subscription::create([
'team_id' => $this->team->id,
'stripe_subscription_id' => 'sub_test_456',
'stripe_customer_id' => 'cus_test_456',
'stripe_invoice_paid' => true,
'stripe_plan_id' => 'price_test_456',
'stripe_cancel_at_period_end' => false,
'stripe_past_due' => false,
]);
$this->mockStripe = Mockery::mock(StripeClient::class);
$this->mockSubscriptions = Mockery::mock(SubscriptionService::class);
$this->mockStripe->subscriptions = $this->mockSubscriptions;
});
describe('CancelSubscriptionAtPeriodEnd', function () {
test('cancels subscription at period end successfully', function () {
$this->mockSubscriptions
->shouldReceive('update')
->with('sub_test_456', ['cancel_at_period_end' => true])
->andReturn((object) ['status' => 'active', 'cancel_at_period_end' => true]);
$action = new CancelSubscriptionAtPeriodEnd($this->mockStripe);
$result = $action->execute($this->team);
expect($result['success'])->toBeTrue();
expect($result['error'])->toBeNull();
$this->subscription->refresh();
expect($this->subscription->stripe_cancel_at_period_end)->toBeTruthy();
expect($this->subscription->stripe_invoice_paid)->toBeTruthy();
});
test('fails when no subscription exists', function () {
$team = Team::factory()->create();
$action = new CancelSubscriptionAtPeriodEnd($this->mockStripe);
$result = $action->execute($team);
expect($result['success'])->toBeFalse();
expect($result['error'])->toContain('No active subscription');
});
test('fails when subscription is not active', function () {
$this->subscription->update(['stripe_invoice_paid' => false]);
$action = new CancelSubscriptionAtPeriodEnd($this->mockStripe);
$result = $action->execute($this->team);
expect($result['success'])->toBeFalse();
expect($result['error'])->toContain('not active');
});
test('fails when already set to cancel at period end', function () {
$this->subscription->update(['stripe_cancel_at_period_end' => true]);
$action = new CancelSubscriptionAtPeriodEnd($this->mockStripe);
$result = $action->execute($this->team);
expect($result['success'])->toBeFalse();
expect($result['error'])->toContain('already set to cancel');
});
test('handles stripe API error gracefully', function () {
$this->mockSubscriptions
->shouldReceive('update')
->andThrow(new \Stripe\Exception\InvalidRequestException('Subscription not found'));
$action = new CancelSubscriptionAtPeriodEnd($this->mockStripe);
$result = $action->execute($this->team);
expect($result['success'])->toBeFalse();
expect($result['error'])->toContain('Stripe error');
});
});
@@ -0,0 +1,271 @@
<?php
use App\Actions\Stripe\RefundSubscription;
use App\Models\Subscription;
use App\Models\Team;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Stripe\Service\InvoiceService;
use Stripe\Service\RefundService;
use Stripe\Service\SubscriptionService;
use Stripe\StripeClient;
uses(RefreshDatabase::class);
beforeEach(function () {
config()->set('constants.coolify.self_hosted', false);
config()->set('subscription.provider', 'stripe');
config()->set('subscription.stripe_api_key', 'sk_test_fake');
$this->team = Team::factory()->create();
$this->user = User::factory()->create();
$this->team->members()->attach($this->user->id, ['role' => 'owner']);
$this->subscription = Subscription::create([
'team_id' => $this->team->id,
'stripe_subscription_id' => 'sub_test_123',
'stripe_customer_id' => 'cus_test_123',
'stripe_invoice_paid' => true,
'stripe_plan_id' => 'price_test_123',
'stripe_cancel_at_period_end' => false,
'stripe_past_due' => false,
]);
$this->mockStripe = Mockery::mock(StripeClient::class);
$this->mockSubscriptions = Mockery::mock(SubscriptionService::class);
$this->mockInvoices = Mockery::mock(InvoiceService::class);
$this->mockRefunds = Mockery::mock(RefundService::class);
$this->mockStripe->subscriptions = $this->mockSubscriptions;
$this->mockStripe->invoices = $this->mockInvoices;
$this->mockStripe->refunds = $this->mockRefunds;
});
describe('checkEligibility', function () {
test('returns eligible when subscription is within 30 days', function () {
$stripeSubscription = (object) [
'status' => 'active',
'start_date' => now()->subDays(10)->timestamp,
];
$this->mockSubscriptions
->shouldReceive('retrieve')
->with('sub_test_123')
->andReturn($stripeSubscription);
$action = new RefundSubscription($this->mockStripe);
$result = $action->checkEligibility($this->team);
expect($result['eligible'])->toBeTrue();
expect($result['days_remaining'])->toBe(20);
});
test('returns ineligible when subscription is past 30 days', function () {
$stripeSubscription = (object) [
'status' => 'active',
'start_date' => now()->subDays(35)->timestamp,
];
$this->mockSubscriptions
->shouldReceive('retrieve')
->with('sub_test_123')
->andReturn($stripeSubscription);
$action = new RefundSubscription($this->mockStripe);
$result = $action->checkEligibility($this->team);
expect($result['eligible'])->toBeFalse();
expect($result['days_remaining'])->toBe(0);
expect($result['reason'])->toContain('30-day refund window has expired');
});
test('returns ineligible when subscription is not active', function () {
$stripeSubscription = (object) [
'status' => 'canceled',
'start_date' => now()->subDays(5)->timestamp,
];
$this->mockSubscriptions
->shouldReceive('retrieve')
->with('sub_test_123')
->andReturn($stripeSubscription);
$action = new RefundSubscription($this->mockStripe);
$result = $action->checkEligibility($this->team);
expect($result['eligible'])->toBeFalse();
});
test('returns ineligible when no subscription exists', function () {
$team = Team::factory()->create();
$action = new RefundSubscription($this->mockStripe);
$result = $action->checkEligibility($team);
expect($result['eligible'])->toBeFalse();
expect($result['reason'])->toContain('No active subscription');
});
test('returns ineligible when invoice is not paid', function () {
$this->subscription->update(['stripe_invoice_paid' => false]);
$action = new RefundSubscription($this->mockStripe);
$result = $action->checkEligibility($this->team);
expect($result['eligible'])->toBeFalse();
expect($result['reason'])->toContain('not paid');
});
test('returns ineligible when team has already been refunded', function () {
$this->subscription->update(['stripe_refunded_at' => now()->subDays(60)]);
$action = new RefundSubscription($this->mockStripe);
$result = $action->checkEligibility($this->team);
expect($result['eligible'])->toBeFalse();
expect($result['reason'])->toContain('already been processed');
});
test('returns ineligible when stripe subscription not found', function () {
$this->mockSubscriptions
->shouldReceive('retrieve')
->with('sub_test_123')
->andThrow(new \Stripe\Exception\InvalidRequestException('No such subscription'));
$action = new RefundSubscription($this->mockStripe);
$result = $action->checkEligibility($this->team);
expect($result['eligible'])->toBeFalse();
expect($result['reason'])->toContain('not found in Stripe');
});
});
describe('execute', function () {
test('processes refund successfully', function () {
$stripeSubscription = (object) [
'status' => 'active',
'start_date' => now()->subDays(10)->timestamp,
];
$this->mockSubscriptions
->shouldReceive('retrieve')
->with('sub_test_123')
->andReturn($stripeSubscription);
$invoiceCollection = (object) ['data' => [
(object) ['payment_intent' => 'pi_test_123'],
]];
$this->mockInvoices
->shouldReceive('all')
->with([
'subscription' => 'sub_test_123',
'status' => 'paid',
'limit' => 1,
])
->andReturn($invoiceCollection);
$this->mockRefunds
->shouldReceive('create')
->with(['payment_intent' => 'pi_test_123'])
->andReturn((object) ['id' => 're_test_123']);
$this->mockSubscriptions
->shouldReceive('cancel')
->with('sub_test_123')
->andReturn((object) ['status' => 'canceled']);
$action = new RefundSubscription($this->mockStripe);
$result = $action->execute($this->team);
expect($result['success'])->toBeTrue();
expect($result['error'])->toBeNull();
$this->subscription->refresh();
expect($this->subscription->stripe_invoice_paid)->toBeFalsy();
expect($this->subscription->stripe_feedback)->toBe('Refund requested by user');
expect($this->subscription->stripe_refunded_at)->not->toBeNull();
});
test('prevents a second refund after re-subscribing', function () {
$this->subscription->update([
'stripe_refunded_at' => now()->subDays(15),
'stripe_invoice_paid' => true,
'stripe_subscription_id' => 'sub_test_new_456',
]);
$action = new RefundSubscription($this->mockStripe);
$result = $action->execute($this->team);
expect($result['success'])->toBeFalse();
expect($result['error'])->toContain('already been processed');
});
test('fails when no paid invoice found', function () {
$stripeSubscription = (object) [
'status' => 'active',
'start_date' => now()->subDays(10)->timestamp,
];
$this->mockSubscriptions
->shouldReceive('retrieve')
->with('sub_test_123')
->andReturn($stripeSubscription);
$invoiceCollection = (object) ['data' => []];
$this->mockInvoices
->shouldReceive('all')
->andReturn($invoiceCollection);
$action = new RefundSubscription($this->mockStripe);
$result = $action->execute($this->team);
expect($result['success'])->toBeFalse();
expect($result['error'])->toContain('No paid invoice');
});
test('fails when invoice has no payment intent', function () {
$stripeSubscription = (object) [
'status' => 'active',
'start_date' => now()->subDays(10)->timestamp,
];
$this->mockSubscriptions
->shouldReceive('retrieve')
->with('sub_test_123')
->andReturn($stripeSubscription);
$invoiceCollection = (object) ['data' => [
(object) ['payment_intent' => null],
]];
$this->mockInvoices
->shouldReceive('all')
->andReturn($invoiceCollection);
$action = new RefundSubscription($this->mockStripe);
$result = $action->execute($this->team);
expect($result['success'])->toBeFalse();
expect($result['error'])->toContain('No payment intent');
});
test('fails when subscription is past refund window', function () {
$stripeSubscription = (object) [
'status' => 'active',
'start_date' => now()->subDays(35)->timestamp,
];
$this->mockSubscriptions
->shouldReceive('retrieve')
->with('sub_test_123')
->andReturn($stripeSubscription);
$action = new RefundSubscription($this->mockStripe);
$result = $action->execute($this->team);
expect($result['success'])->toBeFalse();
expect($result['error'])->toContain('30-day refund window');
});
});
@@ -0,0 +1,85 @@
<?php
use App\Actions\Stripe\ResumeSubscription;
use App\Models\Subscription;
use App\Models\Team;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Stripe\Service\SubscriptionService;
use Stripe\StripeClient;
uses(RefreshDatabase::class);
beforeEach(function () {
config()->set('constants.coolify.self_hosted', false);
config()->set('subscription.provider', 'stripe');
config()->set('subscription.stripe_api_key', 'sk_test_fake');
$this->team = Team::factory()->create();
$this->user = User::factory()->create();
$this->team->members()->attach($this->user->id, ['role' => 'owner']);
$this->subscription = Subscription::create([
'team_id' => $this->team->id,
'stripe_subscription_id' => 'sub_test_789',
'stripe_customer_id' => 'cus_test_789',
'stripe_invoice_paid' => true,
'stripe_plan_id' => 'price_test_789',
'stripe_cancel_at_period_end' => true,
'stripe_past_due' => false,
]);
$this->mockStripe = Mockery::mock(StripeClient::class);
$this->mockSubscriptions = Mockery::mock(SubscriptionService::class);
$this->mockStripe->subscriptions = $this->mockSubscriptions;
});
describe('ResumeSubscription', function () {
test('resumes subscription successfully', function () {
$this->mockSubscriptions
->shouldReceive('update')
->with('sub_test_789', ['cancel_at_period_end' => false])
->andReturn((object) ['status' => 'active', 'cancel_at_period_end' => false]);
$action = new ResumeSubscription($this->mockStripe);
$result = $action->execute($this->team);
expect($result['success'])->toBeTrue();
expect($result['error'])->toBeNull();
$this->subscription->refresh();
expect($this->subscription->stripe_cancel_at_period_end)->toBeFalsy();
});
test('fails when no subscription exists', function () {
$team = Team::factory()->create();
$action = new ResumeSubscription($this->mockStripe);
$result = $action->execute($team);
expect($result['success'])->toBeFalse();
expect($result['error'])->toContain('No active subscription');
});
test('fails when subscription is not set to cancel', function () {
$this->subscription->update(['stripe_cancel_at_period_end' => false]);
$action = new ResumeSubscription($this->mockStripe);
$result = $action->execute($this->team);
expect($result['success'])->toBeFalse();
expect($result['error'])->toContain('not set to cancel');
});
test('handles stripe API error gracefully', function () {
$this->mockSubscriptions
->shouldReceive('update')
->andThrow(new \Stripe\Exception\InvalidRequestException('Subscription not found'));
$action = new ResumeSubscription($this->mockStripe);
$result = $action->execute($this->team);
expect($result['success'])->toBeFalse();
expect($result['error'])->toContain('Stripe error');
});
});
@@ -0,0 +1,375 @@
<?php
use App\Actions\Stripe\UpdateSubscriptionQuantity;
use App\Jobs\ServerLimitCheckJob;
use App\Models\Subscription;
use App\Models\Team;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Queue;
use Stripe\Service\InvoiceService;
use Stripe\Service\SubscriptionService;
use Stripe\Service\TaxRateService;
use Stripe\StripeClient;
uses(RefreshDatabase::class);
beforeEach(function () {
config()->set('constants.coolify.self_hosted', false);
config()->set('subscription.provider', 'stripe');
config()->set('subscription.stripe_api_key', 'sk_test_fake');
$this->team = Team::factory()->create();
$this->user = User::factory()->create();
$this->team->members()->attach($this->user->id, ['role' => 'owner']);
$this->subscription = Subscription::create([
'team_id' => $this->team->id,
'stripe_subscription_id' => 'sub_test_qty',
'stripe_customer_id' => 'cus_test_qty',
'stripe_invoice_paid' => true,
'stripe_plan_id' => 'price_test_qty',
'stripe_cancel_at_period_end' => false,
'stripe_past_due' => false,
]);
$this->mockStripe = Mockery::mock(StripeClient::class);
$this->mockSubscriptions = Mockery::mock(SubscriptionService::class);
$this->mockInvoices = Mockery::mock(InvoiceService::class);
$this->mockTaxRates = Mockery::mock(TaxRateService::class);
$this->mockStripe->subscriptions = $this->mockSubscriptions;
$this->mockStripe->invoices = $this->mockInvoices;
$this->mockStripe->taxRates = $this->mockTaxRates;
$this->stripeSubscriptionResponse = (object) [
'items' => (object) [
'data' => [(object) [
'id' => 'si_item_123',
'quantity' => 2,
'price' => (object) ['unit_amount' => 500, 'currency' => 'usd'],
]],
],
];
});
describe('UpdateSubscriptionQuantity::execute', function () {
test('updates quantity successfully', function () {
Queue::fake();
$this->mockSubscriptions
->shouldReceive('retrieve')
->with('sub_test_qty')
->andReturn($this->stripeSubscriptionResponse);
$this->mockSubscriptions
->shouldReceive('update')
->with('sub_test_qty', [
'items' => [
['id' => 'si_item_123', 'quantity' => 5],
],
'proration_behavior' => 'always_invoice',
'expand' => ['latest_invoice'],
])
->andReturn((object) [
'status' => 'active',
'latest_invoice' => (object) ['status' => 'paid'],
]);
$action = new UpdateSubscriptionQuantity($this->mockStripe);
$result = $action->execute($this->team, 5);
expect($result['success'])->toBeTrue();
expect($result['error'])->toBeNull();
$this->team->refresh();
expect($this->team->custom_server_limit)->toBe(5);
Queue::assertPushed(ServerLimitCheckJob::class, function ($job) {
return $job->team->id === $this->team->id;
});
});
test('reverts subscription and voids invoice when payment fails', function () {
Queue::fake();
$this->mockSubscriptions
->shouldReceive('retrieve')
->with('sub_test_qty')
->andReturn($this->stripeSubscriptionResponse);
// First update: changes quantity but payment fails
$this->mockSubscriptions
->shouldReceive('update')
->with('sub_test_qty', [
'items' => [
['id' => 'si_item_123', 'quantity' => 5],
],
'proration_behavior' => 'always_invoice',
'expand' => ['latest_invoice'],
])
->andReturn((object) [
'status' => 'active',
'latest_invoice' => (object) ['id' => 'in_failed_123', 'status' => 'open'],
]);
// Revert: restores original quantity
$this->mockSubscriptions
->shouldReceive('update')
->with('sub_test_qty', [
'items' => [
['id' => 'si_item_123', 'quantity' => 2],
],
'proration_behavior' => 'none',
])
->andReturn((object) ['status' => 'active']);
// Void the unpaid invoice
$this->mockInvoices
->shouldReceive('voidInvoice')
->with('in_failed_123')
->once();
$action = new UpdateSubscriptionQuantity($this->mockStripe);
$result = $action->execute($this->team, 5);
expect($result['success'])->toBeFalse();
expect($result['error'])->toContain('Payment failed');
$this->team->refresh();
expect($this->team->custom_server_limit)->not->toBe(5);
Queue::assertNotPushed(ServerLimitCheckJob::class);
});
test('rejects quantity below minimum of 2', function () {
$action = new UpdateSubscriptionQuantity($this->mockStripe);
$result = $action->execute($this->team, 1);
expect($result['success'])->toBeFalse();
expect($result['error'])->toContain('Minimum server limit is 2');
});
test('fails when no subscription exists', function () {
$team = Team::factory()->create();
$action = new UpdateSubscriptionQuantity($this->mockStripe);
$result = $action->execute($team, 5);
expect($result['success'])->toBeFalse();
expect($result['error'])->toContain('No active subscription');
});
test('fails when subscription is not active', function () {
$this->subscription->update(['stripe_invoice_paid' => false]);
$action = new UpdateSubscriptionQuantity($this->mockStripe);
$result = $action->execute($this->team, 5);
expect($result['success'])->toBeFalse();
expect($result['error'])->toContain('not active');
});
test('fails when subscription item cannot be found', function () {
$this->mockSubscriptions
->shouldReceive('retrieve')
->with('sub_test_qty')
->andReturn((object) [
'items' => (object) ['data' => []],
]);
$action = new UpdateSubscriptionQuantity($this->mockStripe);
$result = $action->execute($this->team, 5);
expect($result['success'])->toBeFalse();
expect($result['error'])->toContain('Could not find subscription item');
});
test('handles stripe API error gracefully', function () {
$this->mockSubscriptions
->shouldReceive('retrieve')
->andThrow(new \Stripe\Exception\InvalidRequestException('Subscription not found'));
$action = new UpdateSubscriptionQuantity($this->mockStripe);
$result = $action->execute($this->team, 5);
expect($result['success'])->toBeFalse();
expect($result['error'])->toContain('Stripe error');
});
test('handles generic exception gracefully', function () {
$this->mockSubscriptions
->shouldReceive('retrieve')
->andThrow(new \RuntimeException('Network error'));
$action = new UpdateSubscriptionQuantity($this->mockStripe);
$result = $action->execute($this->team, 5);
expect($result['success'])->toBeFalse();
expect($result['error'])->toContain('unexpected error');
});
});
describe('UpdateSubscriptionQuantity::fetchPricePreview', function () {
test('returns full preview with proration and recurring cost with tax', function () {
$this->mockSubscriptions
->shouldReceive('retrieve')
->with('sub_test_qty')
->andReturn($this->stripeSubscriptionResponse);
$this->mockInvoices
->shouldReceive('upcoming')
->with([
'customer' => 'cus_test_qty',
'subscription' => 'sub_test_qty',
'subscription_items' => [
['id' => 'si_item_123', 'quantity' => 3],
],
'subscription_proration_behavior' => 'create_prorations',
])
->andReturn((object) [
'amount_due' => 2540,
'total' => 2540,
'subtotal' => 2000,
'tax' => 540,
'currency' => 'usd',
'lines' => (object) [
'data' => [
(object) ['amount' => -300, 'proration' => true], // credit for unused
(object) ['amount' => 800, 'proration' => true], // charge for new qty
(object) ['amount' => 1500, 'proration' => false], // next cycle
],
],
'total_tax_amounts' => [
(object) ['tax_rate' => 'txr_123'],
],
]);
$this->mockTaxRates
->shouldReceive('retrieve')
->with('txr_123')
->andReturn((object) [
'display_name' => 'VAT',
'jurisdiction' => 'HU',
'percentage' => 27,
]);
$action = new UpdateSubscriptionQuantity($this->mockStripe);
$result = $action->fetchPricePreview($this->team, 3);
expect($result['success'])->toBeTrue();
// Due now: invoice total (2540) - recurring total (1905) = 635
expect($result['preview']['due_now'])->toBe(635);
// Recurring: 3 × $5.00 = $15.00
expect($result['preview']['recurring_subtotal'])->toBe(1500);
// Tax: $15.00 × 27% = $4.05
expect($result['preview']['recurring_tax'])->toBe(405);
// Total: $15.00 + $4.05 = $19.05
expect($result['preview']['recurring_total'])->toBe(1905);
expect($result['preview']['unit_price'])->toBe(500);
expect($result['preview']['tax_description'])->toContain('VAT');
expect($result['preview']['tax_description'])->toContain('27%');
expect($result['preview']['quantity'])->toBe(3);
expect($result['preview']['currency'])->toBe('USD');
});
test('returns preview without tax when no tax applies', function () {
$this->mockSubscriptions
->shouldReceive('retrieve')
->with('sub_test_qty')
->andReturn($this->stripeSubscriptionResponse);
$this->mockInvoices
->shouldReceive('upcoming')
->andReturn((object) [
'amount_due' => 1250,
'total' => 1250,
'subtotal' => 1250,
'tax' => 0,
'currency' => 'usd',
'lines' => (object) [
'data' => [
(object) ['amount' => 250, 'proration' => true], // proration charge
(object) ['amount' => 1000, 'proration' => false], // next cycle
],
],
'total_tax_amounts' => [],
]);
$action = new UpdateSubscriptionQuantity($this->mockStripe);
$result = $action->fetchPricePreview($this->team, 2);
expect($result['success'])->toBeTrue();
// Due now: invoice total (1250) - recurring total (1000) = 250
expect($result['preview']['due_now'])->toBe(250);
// 2 × $5.00 = $10.00, no tax
expect($result['preview']['recurring_subtotal'])->toBe(1000);
expect($result['preview']['recurring_tax'])->toBe(0);
expect($result['preview']['recurring_total'])->toBe(1000);
expect($result['preview']['tax_description'])->toBeNull();
});
test('fails when no subscription exists', function () {
$team = Team::factory()->create();
$action = new UpdateSubscriptionQuantity($this->mockStripe);
$result = $action->fetchPricePreview($team, 5);
expect($result['success'])->toBeFalse();
expect($result['preview'])->toBeNull();
});
test('fails when subscription item not found', function () {
$this->mockSubscriptions
->shouldReceive('retrieve')
->with('sub_test_qty')
->andReturn((object) [
'items' => (object) ['data' => []],
]);
$action = new UpdateSubscriptionQuantity($this->mockStripe);
$result = $action->fetchPricePreview($this->team, 5);
expect($result['success'])->toBeFalse();
expect($result['error'])->toContain('Could not retrieve subscription details');
});
test('handles Stripe API error gracefully', function () {
$this->mockSubscriptions
->shouldReceive('retrieve')
->andThrow(new \RuntimeException('API error'));
$action = new UpdateSubscriptionQuantity($this->mockStripe);
$result = $action->fetchPricePreview($this->team, 5);
expect($result['success'])->toBeFalse();
expect($result['error'])->toContain('Could not load price preview');
expect($result['preview'])->toBeNull();
});
});
describe('Subscription billingInterval', function () {
test('returns monthly for monthly plan', function () {
config()->set('subscription.stripe_price_id_dynamic_monthly', 'price_monthly_123');
$this->subscription->update(['stripe_plan_id' => 'price_monthly_123']);
$this->subscription->refresh();
expect($this->subscription->billingInterval())->toBe('monthly');
});
test('returns yearly for yearly plan', function () {
config()->set('subscription.stripe_price_id_dynamic_yearly', 'price_yearly_123');
$this->subscription->update(['stripe_plan_id' => 'price_yearly_123']);
$this->subscription->refresh();
expect($this->subscription->billingInterval())->toBe('yearly');
});
test('defaults to monthly when plan id is null', function () {
$this->subscription->update(['stripe_plan_id' => null]);
$this->subscription->refresh();
expect($this->subscription->billingInterval())->toBe('monthly');
});
});
@@ -3,7 +3,6 @@
use App\Models\Application;
use App\Models\Server;
use App\Models\StandaloneDocker;
use Mockery;
/**
* Unit test to verify docker_compose_raw is properly synced to the Livewire component
@@ -11,7 +11,6 @@
use App\Models\Application;
use App\Models\EnvironmentVariable;
use Illuminate\Support\Collection;
use Mockery;
beforeEach(function () {
// Clean up Mockery after each test
-1
View File
@@ -1,7 +1,6 @@
<?php
use App\Models\Application;
use Mockery;
/**
* Unit tests to verify that containers without health checks are not
@@ -5,7 +5,6 @@ use App\Models\Application;
use App\Models\ApplicationDeploymentQueue;
use App\Models\ApplicationSetting;
use Illuminate\Support\Facades\Validator;
use Mockery;
beforeEach(function () {
Mockery::close();
@@ -1,7 +1,6 @@
<?php
use App\Notifications\Server\HetznerDeletionFailed;
use Mockery;
afterEach(function () {
Mockery::close();
@@ -7,7 +7,6 @@ use App\Models\InstanceSettings;
use App\Models\Server;
use Illuminate\Support\Carbon;
use Illuminate\Support\Facades\Queue;
use Mockery;
beforeEach(function () {
Queue::fake();
-1
View File
@@ -3,7 +3,6 @@
use App\Enums\ProxyTypes;
use App\Models\Server;
use Illuminate\Database\Eloquent\Builder;
use Mockery;
it('filters servers by proxy type using whereProxyType scope', function () {
// Mock the Builder
-1
View File
@@ -2,7 +2,6 @@
use App\Models\Service;
use App\Models\ServiceApplication;
use Mockery;
it('returns required port from service template', function () {
// Mock get_service_templates() function
+125
View File
@@ -0,0 +1,125 @@
<?php
use App\Helpers\SshMultiplexingHelper;
use App\Rules\ValidHostname;
use App\Rules\ValidServerIp;
// -------------------------------------------------------------------------
// ValidServerIp rule
// -------------------------------------------------------------------------
it('accepts a valid IPv4 address', function () {
$rule = new ValidServerIp;
$failed = false;
$rule->validate('ip', '192.168.1.1', function () use (&$failed) {
$failed = true;
});
expect($failed)->toBeFalse();
});
it('accepts a valid IPv6 address', function () {
$rule = new ValidServerIp;
$failed = false;
$rule->validate('ip', '2001:db8::1', function () use (&$failed) {
$failed = true;
});
expect($failed)->toBeFalse();
});
it('accepts a valid hostname', function () {
$rule = new ValidServerIp;
$failed = false;
$rule->validate('ip', 'my-server.example.com', function () use (&$failed) {
$failed = true;
});
expect($failed)->toBeFalse();
});
it('rejects injection payloads in server ip', function (string $payload) {
$rule = new ValidServerIp;
$failed = false;
$rule->validate('ip', $payload, function () use (&$failed) {
$failed = true;
});
expect($failed)->toBeTrue("ValidServerIp should reject: $payload");
})->with([
'semicolon' => ['192.168.1.1; rm -rf /'],
'pipe' => ['192.168.1.1 | cat /etc/passwd'],
'backtick' => ['192.168.1.1`id`'],
'dollar subshell' => ['192.168.1.1$(id)'],
'ampersand' => ['192.168.1.1 & id'],
'newline' => ["192.168.1.1\nid"],
'null byte' => ["192.168.1.1\0id"],
]);
// -------------------------------------------------------------------------
// Server model setter casts
// -------------------------------------------------------------------------
it('strips dangerous characters from server ip on write', function () {
$server = new App\Models\Server;
$server->ip = '192.168.1.1;rm -rf /';
// Regex [^0-9a-zA-Z.:%-] removes ; space and /; hyphen is allowed
expect($server->ip)->toBe('192.168.1.1rm-rf');
});
it('strips dangerous characters from server user on write', function () {
$server = new App\Models\Server;
$server->user = 'root$(id)';
expect($server->user)->toBe('rootid');
});
it('strips non-numeric characters from server port on write', function () {
$server = new App\Models\Server;
$server->port = '22; evil';
expect($server->port)->toBe(22);
});
// -------------------------------------------------------------------------
// escapeshellarg() in generated SSH commands (source-level verification)
// -------------------------------------------------------------------------
it('has escapedUserAtHost private static helper in SshMultiplexingHelper', function () {
$reflection = new ReflectionClass(SshMultiplexingHelper::class);
expect($reflection->hasMethod('escapedUserAtHost'))->toBeTrue();
$method = $reflection->getMethod('escapedUserAtHost');
expect($method->isPrivate())->toBeTrue();
expect($method->isStatic())->toBeTrue();
});
it('wraps port with escapeshellarg in getCommonSshOptions', function () {
$reflection = new ReflectionClass(SshMultiplexingHelper::class);
$source = file_get_contents($reflection->getFileName());
expect($source)->toContain('escapeshellarg((string) $server->port)');
});
it('has no raw user@ip string interpolation in SshMultiplexingHelper', function () {
$reflection = new ReflectionClass(SshMultiplexingHelper::class);
$source = file_get_contents($reflection->getFileName());
expect($source)->not->toContain('{$server->user}@{$server->ip}');
});
// -------------------------------------------------------------------------
// ValidHostname rejects shell metacharacters
// -------------------------------------------------------------------------
it('rejects semicolon in hostname', function () {
$rule = new ValidHostname;
$failed = false;
$rule->validate('hostname', 'example.com;id', function () use (&$failed) {
$failed = true;
});
expect($failed)->toBeTrue();
});
it('rejects backtick in hostname', function () {
$rule = new ValidHostname;
$failed = false;
$rule->validate('hostname', 'example.com`id`', function () use (&$failed) {
$failed = true;
});
expect($failed)->toBeTrue();
});