Merge remote-tracking branch 'origin/next' into api-sensitive-data-scrubber

This commit is contained in:
Andras Bacsai committed 2026-06-15 13:29:25 +02:00
commit f5ecdfa4ce
339 files changed
+11186 -2608

No files matched your search

@@ -0,0 +1,120 @@
<?php
use App\Models\Application;
use App\Models\Environment;
use App\Models\Project;
use App\Models\Server;
use App\Models\StandaloneDocker;
use App\Models\Team;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Visus\Cuid2\Cuid2;
uses(RefreshDatabase::class);
beforeEach(function () {
$this->team = Team::factory()->create();
$this->user = User::factory()->create();
$this->team->members()->attach($this->user->id, ['role' => 'owner']);
session(['currentTeam' => $this->team]);
$this->token = $this->user->createToken('test-token', ['*']);
$this->bearerToken = $this->token->plainTextToken;
$this->server = Server::factory()->create(['team_id' => $this->team->id]);
StandaloneDocker::withoutEvents(function () {
$this->destination = $this->server->standaloneDockers()->firstOrCreate(
['network' => 'coolify'],
['uuid' => (string) new Cuid2, 'name' => 'test-docker']
);
});
$this->project = Project::create([
'uuid' => (string) new Cuid2,
'name' => 'test-project',
'team_id' => $this->team->id,
]);
// Project boot event auto-creates a 'production' environment
$this->environment = $this->project->environments()->first();
$this->application = Application::factory()->create([
'environment_id' => $this->environment->id,
'destination_id' => $this->destination->id,
'destination_type' => $this->destination->getMorphClass(),
]);
});
function healthCheckAuthHeaders($bearerToken): array
{
return [
'Authorization' => 'Bearer '.$bearerToken,
'Content-Type' => 'application/json',
];
}
describe('PATCH /api/v1/applications/{uuid} health check fields', function () {
test('can update health_check_type to cmd with a command', function () {
$response = $this->withHeaders(healthCheckAuthHeaders($this->bearerToken))
->patchJson("/api/v1/applications/{$this->application->uuid}", [
'health_check_type' => 'cmd',
'health_check_command' => 'pg_isready -U postgres',
]);
$response->assertOk();
$this->application->refresh();
expect($this->application->health_check_type)->toBe('cmd');
expect($this->application->health_check_command)->toBe('pg_isready -U postgres');
});
test('can update health_check_type back to http', function () {
$this->application->update([
'health_check_type' => 'cmd',
'health_check_command' => 'redis-cli ping',
]);
$response = $this->withHeaders(healthCheckAuthHeaders($this->bearerToken))
->patchJson("/api/v1/applications/{$this->application->uuid}", [
'health_check_type' => 'http',
'health_check_command' => null,
]);
$response->assertOk();
$this->application->refresh();
expect($this->application->health_check_type)->toBe('http');
expect($this->application->health_check_command)->toBeNull();
});
test('rejects invalid health_check_type', function () {
$response = $this->withHeaders(healthCheckAuthHeaders($this->bearerToken))
->patchJson("/api/v1/applications/{$this->application->uuid}", [
'health_check_type' => 'exec',
]);
$response->assertStatus(422);
});
test('rejects health_check_command with shell operators', function () {
$response = $this->withHeaders(healthCheckAuthHeaders($this->bearerToken))
->patchJson("/api/v1/applications/{$this->application->uuid}", [
'health_check_type' => 'cmd',
'health_check_command' => 'pg_isready; rm -rf /',
]);
$response->assertStatus(422);
});
test('rejects health_check_command over 1000 characters', function () {
$response = $this->withHeaders(healthCheckAuthHeaders($this->bearerToken))
->patchJson("/api/v1/applications/{$this->application->uuid}", [
'health_check_type' => 'cmd',
'health_check_command' => str_repeat('a', 1001),
]);
$response->assertStatus(422);
});
});
@@ -0,0 +1,549 @@
<?php
use App\Models\CloudProviderToken;
use App\Models\InstanceSettings;
use App\Models\Team;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Once;
uses(RefreshDatabase::class);
beforeEach(function () {
InstanceSettings::query()->whereKey(0)->delete();
$settings = new InstanceSettings(['is_api_enabled' => true]);
$settings->id = 0;
$settings->save();
Once::flush();
// Create a team with owner
$this->team = Team::factory()->create();
$this->user = User::factory()->create();
$this->team->members()->attach($this->user->id, ['role' => 'owner']);
// Set the current team session before creating the token
session(['currentTeam' => $this->team]);
// Create an API token for the user
$this->token = $this->user->createToken('test-token', ['*']);
$this->bearerToken = $this->token->plainTextToken;
});
describe('GET /api/v1/cloud-tokens', function () {
test('lists all cloud provider tokens for the team', function () {
// Create some tokens
CloudProviderToken::factory()->count(3)->create([
'team_id' => $this->team->id,
'provider' => 'hetzner',
]);
$response = $this->withHeaders([
'Authorization' => 'Bearer '.$this->bearerToken,
'Content-Type' => 'application/json',
])->getJson('/api/v1/cloud-tokens');
$response->assertStatus(200);
$response->assertJsonCount(3);
$response->assertJsonStructure([
'*' => ['uuid', 'name', 'provider', 'team_id', 'servers_count', 'created_at', 'updated_at'],
]);
});
test('does not include tokens from other teams', function () {
// Create tokens for this team
CloudProviderToken::factory()->create([
'team_id' => $this->team->id,
'provider' => 'hetzner',
]);
// Create tokens for another team
$otherTeam = Team::factory()->create();
CloudProviderToken::factory()->count(2)->create([
'team_id' => $otherTeam->id,
'provider' => 'hetzner',
]);
$response = $this->withHeaders([
'Authorization' => 'Bearer '.$this->bearerToken,
'Content-Type' => 'application/json',
])->getJson('/api/v1/cloud-tokens');
$response->assertStatus(200);
$response->assertJsonCount(1);
});
test('rejects request without authentication', function () {
$response = $this->getJson('/api/v1/cloud-tokens');
$response->assertStatus(401);
});
test('read token does not include provider token values', function () {
CloudProviderToken::create([
'team_id' => $this->team->id,
'name' => 'Hidden Token',
'provider' => 'hetzner',
'token' => 'hidden-cloud-provider-token',
]);
$readToken = $this->user->createToken('read-token', ['read'])->plainTextToken;
$response = $this->withHeaders([
'Authorization' => 'Bearer '.$readToken,
'Content-Type' => 'application/json',
])->getJson('/api/v1/cloud-tokens');
$response->assertSuccessful();
expect($response->getContent())->not->toContain('"token":');
});
test('read sensitive token includes provider token values', function () {
CloudProviderToken::create([
'team_id' => $this->team->id,
'name' => 'Visible Token',
'provider' => 'hetzner',
'token' => 'visible-cloud-provider-token',
]);
$readSensitiveToken = $this->user->createToken('read-sensitive-token', ['read', 'read:sensitive'])->plainTextToken;
$response = $this->withHeaders([
'Authorization' => 'Bearer '.$readSensitiveToken,
'Content-Type' => 'application/json',
])->getJson('/api/v1/cloud-tokens');
$response->assertSuccessful();
$response->assertJsonFragment(['token' => 'visible-cloud-provider-token']);
});
test('root token includes provider token values', function () {
CloudProviderToken::create([
'team_id' => $this->team->id,
'name' => 'Root Visible Token',
'provider' => 'hetzner',
'token' => 'root-visible-cloud-provider-token',
]);
$rootToken = $this->user->createToken('root-token', ['root'])->plainTextToken;
$response = $this->withHeaders([
'Authorization' => 'Bearer '.$rootToken,
'Content-Type' => 'application/json',
])->getJson('/api/v1/cloud-tokens');
$response->assertSuccessful();
$response->assertJsonFragment(['token' => 'root-visible-cloud-provider-token']);
});
});
describe('GET /api/v1/cloud-tokens/{uuid}', function () {
test('gets cloud provider token by UUID', function () {
$token = CloudProviderToken::factory()->create([
'team_id' => $this->team->id,
'provider' => 'hetzner',
'name' => 'My Hetzner Token',
]);
$response = $this->withHeaders([
'Authorization' => 'Bearer '.$this->bearerToken,
'Content-Type' => 'application/json',
])->getJson("/api/v1/cloud-tokens/{$token->uuid}");
$response->assertStatus(200);
$response->assertJsonFragment(['name' => 'My Hetzner Token', 'provider' => 'hetzner']);
});
test('returns 404 for non-existent token', function () {
$response = $this->withHeaders([
'Authorization' => 'Bearer '.$this->bearerToken,
'Content-Type' => 'application/json',
])->getJson('/api/v1/cloud-tokens/non-existent-uuid');
$response->assertStatus(404);
});
test('cannot access token from another team', function () {
$otherTeam = Team::factory()->create();
$token = CloudProviderToken::factory()->create([
'team_id' => $otherTeam->id,
'provider' => 'hetzner',
]);
$response = $this->withHeaders([
'Authorization' => 'Bearer '.$this->bearerToken,
'Content-Type' => 'application/json',
])->getJson("/api/v1/cloud-tokens/{$token->uuid}");
$response->assertStatus(404);
});
test('read token does not include provider token value by UUID', function () {
$token = CloudProviderToken::create([
'team_id' => $this->team->id,
'name' => 'Hidden Token Detail',
'provider' => 'hetzner',
'token' => 'hidden-cloud-provider-token-detail',
]);
$readToken = $this->user->createToken('read-token', ['read'])->plainTextToken;
$response = $this->withHeaders([
'Authorization' => 'Bearer '.$readToken,
'Content-Type' => 'application/json',
])->getJson("/api/v1/cloud-tokens/{$token->uuid}");
$response->assertSuccessful();
expect($response->getContent())->not->toContain('"token":');
});
test('read sensitive token includes provider token value by UUID', function () {
$token = CloudProviderToken::create([
'team_id' => $this->team->id,
'name' => 'Visible Token Detail',
'provider' => 'hetzner',
'token' => 'visible-cloud-provider-token-detail',
]);
$readSensitiveToken = $this->user->createToken('read-sensitive-token', ['read', 'read:sensitive'])->plainTextToken;
$response = $this->withHeaders([
'Authorization' => 'Bearer '.$readSensitiveToken,
'Content-Type' => 'application/json',
])->getJson("/api/v1/cloud-tokens/{$token->uuid}");
$response->assertSuccessful();
$response->assertJsonFragment(['token' => 'visible-cloud-provider-token-detail']);
});
});
describe('POST /api/v1/cloud-tokens', function () {
test('creates a Hetzner cloud provider token', function () {
// Mock Hetzner API validation
Http::fake([
'https://api.hetzner.cloud/v1/servers' => Http::response([], 200),
]);
$response = $this->withHeaders([
'Authorization' => 'Bearer '.$this->bearerToken,
'Content-Type' => 'application/json',
])->postJson('/api/v1/cloud-tokens', [
'provider' => 'hetzner',
'token' => 'test-hetzner-token',
'name' => 'My Hetzner Token',
]);
$response->assertStatus(201);
$response->assertJsonStructure(['uuid']);
// Verify token was created
$this->assertDatabaseHas('cloud_provider_tokens', [
'team_id' => $this->team->id,
'provider' => 'hetzner',
'name' => 'My Hetzner Token',
]);
});
test('creates a DigitalOcean cloud provider token', function () {
// Mock DigitalOcean API validation
Http::fake([
'https://api.digitalocean.com/v2/account' => Http::response([], 200),
]);
$response = $this->withHeaders([
'Authorization' => 'Bearer '.$this->bearerToken,
'Content-Type' => 'application/json',
])->postJson('/api/v1/cloud-tokens', [
'provider' => 'digitalocean',
'token' => 'test-do-token',
'name' => 'My DO Token',
]);
$response->assertStatus(201);
$response->assertJsonStructure(['uuid']);
});
test('validates provider is required', function () {
$response = $this->withHeaders([
'Authorization' => 'Bearer '.$this->bearerToken,
'Content-Type' => 'application/json',
])->postJson('/api/v1/cloud-tokens', [
'token' => 'test-token',
'name' => 'My Token',
]);
$response->assertStatus(422);
$response->assertJsonValidationErrors(['provider']);
});
test('validates token is required', function () {
$response = $this->withHeaders([
'Authorization' => 'Bearer '.$this->bearerToken,
'Content-Type' => 'application/json',
])->postJson('/api/v1/cloud-tokens', [
'provider' => 'hetzner',
'name' => 'My Token',
]);
$response->assertStatus(422);
$response->assertJsonValidationErrors(['token']);
});
test('validates name is required', function () {
$response = $this->withHeaders([
'Authorization' => 'Bearer '.$this->bearerToken,
'Content-Type' => 'application/json',
])->postJson('/api/v1/cloud-tokens', [
'provider' => 'hetzner',
'token' => 'test-token',
]);
$response->assertStatus(422);
$response->assertJsonValidationErrors(['name']);
});
test('validates provider must be hetzner or digitalocean', function () {
$response = $this->withHeaders([
'Authorization' => 'Bearer '.$this->bearerToken,
'Content-Type' => 'application/json',
])->postJson('/api/v1/cloud-tokens', [
'provider' => 'invalid-provider',
'token' => 'test-token',
'name' => 'My Token',
]);
$response->assertStatus(422);
$response->assertJsonValidationErrors(['provider']);
});
test('rejects invalid Hetzner token', function () {
// Mock failed Hetzner API validation
Http::fake([
'https://api.hetzner.cloud/v1/servers' => Http::response([], 401),
]);
$response = $this->withHeaders([
'Authorization' => 'Bearer '.$this->bearerToken,
'Content-Type' => 'application/json',
])->postJson('/api/v1/cloud-tokens', [
'provider' => 'hetzner',
'token' => 'invalid-token',
'name' => 'My Token',
]);
$response->assertStatus(400);
$response->assertJson(['message' => 'Invalid hetzner token. Please check your API token.']);
});
test('rejects extra fields not in allowed list', function () {
Http::fake([
'https://api.hetzner.cloud/v1/servers' => Http::response([], 200),
]);
$response = $this->withHeaders([
'Authorization' => 'Bearer '.$this->bearerToken,
'Content-Type' => 'application/json',
])->postJson('/api/v1/cloud-tokens', [
'provider' => 'hetzner',
'token' => 'test-token',
'name' => 'My Token',
'invalid_field' => 'invalid_value',
]);
$response->assertStatus(422);
});
});
describe('PATCH /api/v1/cloud-tokens/{uuid}', function () {
test('updates cloud provider token name', function () {
$token = CloudProviderToken::factory()->create([
'team_id' => $this->team->id,
'provider' => 'hetzner',
'name' => 'Old Name',
]);
$response = $this->withHeaders([
'Authorization' => 'Bearer '.$this->bearerToken,
'Content-Type' => 'application/json',
])->patchJson("/api/v1/cloud-tokens/{$token->uuid}", [
'name' => 'New Name',
]);
$response->assertStatus(200);
// Verify token name was updated
$this->assertDatabaseHas('cloud_provider_tokens', [
'uuid' => $token->uuid,
'name' => 'New Name',
]);
});
test('validates name is required', function () {
$token = CloudProviderToken::factory()->create([
'team_id' => $this->team->id,
'provider' => 'hetzner',
]);
$response = $this->withHeaders([
'Authorization' => 'Bearer '.$this->bearerToken,
'Content-Type' => 'application/json',
])->patchJson("/api/v1/cloud-tokens/{$token->uuid}", []);
$response->assertStatus(400);
$response->assertJson([
'message' => 'Invalid request.',
'error' => 'Invalid JSON.',
]);
});
test('cannot update token from another team', function () {
$otherTeam = Team::factory()->create();
$token = CloudProviderToken::factory()->create([
'team_id' => $otherTeam->id,
'provider' => 'hetzner',
]);
$response = $this->withHeaders([
'Authorization' => 'Bearer '.$this->bearerToken,
'Content-Type' => 'application/json',
])->patchJson("/api/v1/cloud-tokens/{$token->uuid}", [
'name' => 'New Name',
]);
$response->assertStatus(404);
});
});
describe('DELETE /api/v1/cloud-tokens/{uuid}', function () {
test('deletes cloud provider token', function () {
$token = CloudProviderToken::factory()->create([
'team_id' => $this->team->id,
'provider' => 'hetzner',
]);
$response = $this->withHeaders([
'Authorization' => 'Bearer '.$this->bearerToken,
'Content-Type' => 'application/json',
])->deleteJson("/api/v1/cloud-tokens/{$token->uuid}");
$response->assertStatus(200);
$response->assertJson(['message' => 'Cloud provider token deleted.']);
// Verify token was deleted
$this->assertDatabaseMissing('cloud_provider_tokens', [
'uuid' => $token->uuid,
]);
});
test('cannot delete token from another team', function () {
$otherTeam = Team::factory()->create();
$token = CloudProviderToken::factory()->create([
'team_id' => $otherTeam->id,
'provider' => 'hetzner',
]);
$response = $this->withHeaders([
'Authorization' => 'Bearer '.$this->bearerToken,
'Content-Type' => 'application/json',
])->deleteJson("/api/v1/cloud-tokens/{$token->uuid}");
$response->assertStatus(404);
});
test('returns 404 for non-existent token', function () {
$response = $this->withHeaders([
'Authorization' => 'Bearer '.$this->bearerToken,
'Content-Type' => 'application/json',
])->deleteJson('/api/v1/cloud-tokens/non-existent-uuid');
$response->assertStatus(404);
});
});
describe('POST /api/v1/cloud-tokens/{uuid}/validate', function () {
test('validates a valid Hetzner token', function () {
$token = CloudProviderToken::factory()->create([
'team_id' => $this->team->id,
'provider' => 'hetzner',
]);
Http::fake([
'https://api.hetzner.cloud/v1/servers' => Http::response([], 200),
]);
$response = $this->withHeaders([
'Authorization' => 'Bearer '.$this->bearerToken,
'Content-Type' => 'application/json',
])->postJson("/api/v1/cloud-tokens/{$token->uuid}/validate");
$response->assertStatus(200);
$response->assertJson(['valid' => true, 'message' => 'Token is valid.']);
});
test('detects invalid Hetzner token', function () {
$token = CloudProviderToken::factory()->create([
'team_id' => $this->team->id,
'provider' => 'hetzner',
]);
Http::fake([
'https://api.hetzner.cloud/v1/servers' => Http::response([], 401),
]);
$response = $this->withHeaders([
'Authorization' => 'Bearer '.$this->bearerToken,
'Content-Type' => 'application/json',
])->postJson("/api/v1/cloud-tokens/{$token->uuid}/validate");
$response->assertStatus(200);
$response->assertJson(['valid' => false, 'message' => 'Invalid hetzner token. Please check your API token.']);
});
test('validates a valid DigitalOcean token', function () {
$token = CloudProviderToken::factory()->create([
'team_id' => $this->team->id,
'provider' => 'digitalocean',
]);
Http::fake([
'https://api.digitalocean.com/v2/account' => Http::response([], 200),
]);
$response = $this->withHeaders([
'Authorization' => 'Bearer '.$this->bearerToken,
'Content-Type' => 'application/json',
])->postJson("/api/v1/cloud-tokens/{$token->uuid}/validate");
$response->assertStatus(200);
$response->assertJson(['valid' => true, 'message' => 'Token is valid.']);
});
test('writes an audit log entry when validating a stored token', function () {
$token = CloudProviderToken::factory()->create([
'team_id' => $this->team->id,
'provider' => 'hetzner',
'name' => 'Audit Token',
]);
Http::fake([
'https://api.hetzner.cloud/v1/servers' => Http::response([], 200),
]);
$auditChannel = Mockery::mock();
$auditChannel->shouldReceive('info')
->once()
->with('api.cloud_token.validated', Mockery::on(function (array $context) use ($token) {
return $context['cloud_token_uuid'] === $token->uuid
&& $context['provider'] === 'hetzner'
&& $context['valid'] === true;
}));
Log::shouldReceive('channel')->with('audit')->andReturn($auditChannel);
$this->withHeaders([
'Authorization' => 'Bearer '.$this->bearerToken,
'Content-Type' => 'application/json',
])->postJson("/api/v1/cloud-tokens/{$token->uuid}/validate")
->assertOk();
});
});
@@ -0,0 +1,277 @@
<?php
use App\Models\InstanceSettings;
use App\Models\Project;
use App\Models\S3Storage;
use App\Models\ScheduledDatabaseBackup;
use App\Models\Server;
use App\Models\StandaloneDocker;
use App\Models\StandalonePostgresql;
use App\Models\Team;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Str;
uses(RefreshDatabase::class);
beforeEach(function () {
InstanceSettings::updateOrCreate(['id' => 0]);
$this->team = Team::factory()->create();
$this->user = User::factory()->create();
$this->team->members()->attach($this->user->id, ['role' => 'owner']);
session(['currentTeam' => $this->team]);
$this->token = $this->user->createToken('test-token', ['*']);
$this->bearerToken = $this->token->plainTextToken;
$this->server = Server::factory()->create(['team_id' => $this->team->id]);
StandaloneDocker::withoutEvents(function () {
$this->destination = StandaloneDocker::firstOrCreate(
['server_id' => $this->server->id, 'network' => 'coolify'],
['uuid' => (string) Str::uuid(), 'name' => 'test-docker']
);
});
$this->project = Project::create([
'uuid' => (string) Str::uuid(),
'name' => 'Test Project',
'team_id' => $this->team->id,
]);
$this->environment = $this->project->environments()->first();
$this->database = StandalonePostgresql::create([
'uuid' => (string) Str::uuid(),
'name' => 'Test DB',
'postgres_user' => 'postgres',
'postgres_password' => 'password',
'postgres_db' => 'testdb',
'image' => 'postgres:15',
'status' => 'running',
'environment_id' => $this->environment->id,
'destination_id' => $this->destination->id,
'destination_type' => $this->destination->getMorphClass(),
]);
$this->s3Storage = S3Storage::create([
'name' => 'test-s3',
'region' => 'us-east-1',
'key' => 'test-key',
'secret' => 'test-secret',
'bucket' => 'test-bucket',
'endpoint' => 'https://s3.example.com',
'team_id' => $this->team->id,
'is_usable' => true,
]);
});
function backupHeaders(): array
{
return [
'Authorization' => 'Bearer '.test()->bearerToken,
'Content-Type' => 'application/json',
];
}
describe('POST /api/v1/databases/{uuid}/backups', function () {
test('creates backup configuration with valid frequency', function () {
$response = $this->withHeaders(backupHeaders())
->postJson("/api/v1/databases/{$this->database->uuid}/backups", [
'frequency' => 'daily',
]);
$response->assertStatus(201);
$response->assertJsonStructure(['uuid', 'message']);
$response->assertJson(['message' => 'Backup configuration created successfully.']);
});
test('creates backup with valid cron expression', function () {
$response = $this->withHeaders(backupHeaders())
->postJson("/api/v1/databases/{$this->database->uuid}/backups", [
'frequency' => '0 2 * * *',
]);
$response->assertStatus(201);
$response->assertJsonStructure(['uuid', 'message']);
});
test('accepts all predefined frequency values', function () {
$frequencies = ['every_minute', 'hourly', 'daily', 'weekly', 'monthly', 'yearly'];
foreach ($frequencies as $frequency) {
$response = $this->withHeaders(backupHeaders())
->postJson("/api/v1/databases/{$this->database->uuid}/backups", [
'frequency' => $frequency,
]);
$response->assertStatus(201, "Expected 201 for frequency '{$frequency}', got {$response->status()}");
}
});
test('validates frequency is required', function () {
$response = $this->withHeaders(backupHeaders())
->postJson("/api/v1/databases/{$this->database->uuid}/backups", [
'enabled' => true,
]);
$response->assertStatus(422);
$response->assertJsonValidationErrors(['frequency']);
});
test('rejects invalid frequency format', function () {
$response = $this->withHeaders(backupHeaders())
->postJson("/api/v1/databases/{$this->database->uuid}/backups", [
'frequency' => 'invalid-frequency',
]);
$response->assertStatus(422);
});
test('validates s3_storage_uuid required when save_s3 is true', function () {
$response = $this->withHeaders(backupHeaders())
->postJson("/api/v1/databases/{$this->database->uuid}/backups", [
'frequency' => 'daily',
'save_s3' => true,
]);
$response->assertStatus(422);
});
test('validates retention fields are integers with minimum 0', function () {
$response = $this->withHeaders(backupHeaders())
->postJson("/api/v1/databases/{$this->database->uuid}/backups", [
'frequency' => 'daily',
'database_backup_retention_amount_locally' => -1,
]);
$response->assertStatus(422);
});
test('rejects extra fields not in allowed list', function () {
$response = $this->withHeaders(backupHeaders())
->postJson("/api/v1/databases/{$this->database->uuid}/backups", [
'frequency' => 'daily',
'invalid_field' => 'invalid_value',
]);
$response->assertStatus(422);
});
test('rejects request without authentication', function () {
$response = $this->postJson("/api/v1/databases/{$this->database->uuid}/backups", [
'frequency' => 'daily',
]);
$response->assertStatus(401);
});
test('returns 404 for non-existent database uuid', function () {
$response = $this->withHeaders(backupHeaders())
->postJson('/api/v1/databases/non-existent-uuid/backups', [
'frequency' => 'daily',
]);
$response->assertStatus(404);
$response->assertJson(['message' => 'Database not found.']);
});
test('creates backup with s3 storage via API token', function () {
$response = $this->withHeaders(backupHeaders())
->postJson("/api/v1/databases/{$this->database->uuid}/backups", [
'frequency' => '0 2 * * 0',
'save_s3' => true,
's3_storage_uuid' => $this->s3Storage->uuid,
'enabled' => true,
]);
$response->assertStatus(201);
$response->assertJsonStructure(['uuid', 'message']);
$backup = ScheduledDatabaseBackup::where('uuid', $response->json('uuid'))->first();
expect($backup)->not->toBeNull();
expect($backup->s3_storage_id)->toBe($this->s3Storage->id);
expect($backup->save_s3)->toBeTrue();
expect($backup->team_id)->toBe($this->team->id);
});
test('rejects s3_storage_uuid from another team', function () {
$otherTeam = Team::factory()->create();
$otherS3 = S3Storage::create([
'name' => 'other-s3',
'region' => 'us-east-1',
'key' => 'other-key',
'secret' => 'other-secret',
'bucket' => 'other-bucket',
'endpoint' => 'https://s3.example.com',
'team_id' => $otherTeam->id,
'is_usable' => true,
]);
$response = $this->withHeaders(backupHeaders())
->postJson("/api/v1/databases/{$this->database->uuid}/backups", [
'frequency' => '0 2 * * 0',
'save_s3' => true,
's3_storage_uuid' => $otherS3->uuid,
]);
$response->assertStatus(422);
$response->assertJsonValidationErrors(['s3_storage_uuid']);
});
});
describe('PATCH /api/v1/databases/{uuid}/backups/{scheduled_backup_uuid}', function () {
test('updates backup to use s3 storage via API token', function () {
$backup = ScheduledDatabaseBackup::create([
'frequency' => 'daily',
'enabled' => true,
'database_id' => $this->database->id,
'database_type' => $this->database->getMorphClass(),
'team_id' => $this->team->id,
]);
$response = $this->withHeaders(backupHeaders())
->patchJson("/api/v1/databases/{$this->database->uuid}/backups/{$backup->uuid}", [
'save_s3' => true,
's3_storage_uuid' => $this->s3Storage->uuid,
]);
$response->assertStatus(200);
$backup->refresh();
expect($backup->s3_storage_id)->toBe($this->s3Storage->id);
expect($backup->save_s3)->toBeTrue();
});
test('rejects s3_storage_uuid from another team on update', function () {
$otherTeam = Team::factory()->create();
$otherS3 = S3Storage::create([
'name' => 'other-s3',
'region' => 'us-east-1',
'key' => 'other-key',
'secret' => 'other-secret',
'bucket' => 'other-bucket',
'endpoint' => 'https://s3.example.com',
'team_id' => $otherTeam->id,
'is_usable' => true,
]);
$backup = ScheduledDatabaseBackup::create([
'frequency' => 'daily',
'enabled' => true,
'database_id' => $this->database->id,
'database_type' => $this->database->getMorphClass(),
'team_id' => $this->team->id,
]);
$response = $this->withHeaders(backupHeaders())
->patchJson("/api/v1/databases/{$this->database->uuid}/backups/{$backup->uuid}", [
'save_s3' => true,
's3_storage_uuid' => $otherS3->uuid,
]);
$response->assertStatus(422);
$response->assertJsonValidationErrors(['s3_storage_uuid']);
});
});
@@ -0,0 +1,156 @@
<?php
use App\Enums\ApplicationDeploymentStatus;
use App\Models\ApplicationDeploymentQueue;
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::updateOrCreate(['id' => 0]);
// Create a team with owner
$this->team = Team::factory()->create();
$this->user = User::factory()->create();
$this->team->members()->attach($this->user->id, ['role' => 'owner']);
session(['currentTeam' => $this->team]);
// Create an API token for the user
$this->token = $this->user->createToken('test-token', ['*']);
$this->bearerToken = $this->token->plainTextToken;
// Create a server for the team
$this->server = Server::factory()->create(['team_id' => $this->team->id]);
});
describe('POST /api/v1/deployments/{uuid}/cancel', function () {
test('returns 401 when not authenticated', function () {
$response = $this->postJson('/api/v1/deployments/fake-uuid/cancel');
$response->assertStatus(401);
});
test('returns 404 when deployment not found', function () {
$response = $this->withHeaders([
'Authorization' => 'Bearer '.$this->bearerToken,
'Content-Type' => 'application/json',
])->postJson('/api/v1/deployments/non-existent-uuid/cancel');
$response->assertStatus(404);
$response->assertJson(['message' => 'Deployment not found.']);
});
test('returns 403 when user does not own the deployment', function () {
// Create another team and server
$otherTeam = Team::factory()->create();
$otherServer = Server::factory()->create(['team_id' => $otherTeam->id]);
// Create a deployment on the other team's server
$deployment = ApplicationDeploymentQueue::create([
'deployment_uuid' => 'test-deployment-uuid',
'application_id' => 1,
'server_id' => $otherServer->id,
'status' => ApplicationDeploymentStatus::IN_PROGRESS->value,
]);
$response = $this->withHeaders([
'Authorization' => 'Bearer '.$this->bearerToken,
'Content-Type' => 'application/json',
])->postJson("/api/v1/deployments/{$deployment->deployment_uuid}/cancel");
$response->assertStatus(403);
$response->assertJson(['message' => 'You do not have permission to cancel this deployment.']);
});
test('returns 400 when deployment is already finished', function () {
$deployment = ApplicationDeploymentQueue::create([
'deployment_uuid' => 'finished-deployment-uuid',
'application_id' => 1,
'server_id' => $this->server->id,
'status' => ApplicationDeploymentStatus::FINISHED->value,
]);
$response = $this->withHeaders([
'Authorization' => 'Bearer '.$this->bearerToken,
'Content-Type' => 'application/json',
])->postJson("/api/v1/deployments/{$deployment->deployment_uuid}/cancel");
$response->assertStatus(400);
expect($response->json('message'))->toContain('Deployment cannot be cancelled');
});
test('returns 400 when deployment is already failed', function () {
$deployment = ApplicationDeploymentQueue::create([
'deployment_uuid' => 'failed-deployment-uuid',
'application_id' => 1,
'server_id' => $this->server->id,
'status' => ApplicationDeploymentStatus::FAILED->value,
]);
$response = $this->withHeaders([
'Authorization' => 'Bearer '.$this->bearerToken,
'Content-Type' => 'application/json',
])->postJson("/api/v1/deployments/{$deployment->deployment_uuid}/cancel");
$response->assertStatus(400);
expect($response->json('message'))->toContain('Deployment cannot be cancelled');
});
test('returns 400 when deployment is already cancelled', function () {
$deployment = ApplicationDeploymentQueue::create([
'deployment_uuid' => 'cancelled-deployment-uuid',
'application_id' => 1,
'server_id' => $this->server->id,
'status' => ApplicationDeploymentStatus::CANCELLED_BY_USER->value,
]);
$response = $this->withHeaders([
'Authorization' => 'Bearer '.$this->bearerToken,
'Content-Type' => 'application/json',
])->postJson("/api/v1/deployments/{$deployment->deployment_uuid}/cancel");
$response->assertStatus(400);
expect($response->json('message'))->toContain('Deployment cannot be cancelled');
});
test('cancels queued deployment and updates status in database', function () {
$deployment = ApplicationDeploymentQueue::create([
'deployment_uuid' => 'queued-deployment-uuid',
'application_id' => 1,
'server_id' => $this->server->id,
'status' => ApplicationDeploymentStatus::QUEUED->value,
]);
$this->withHeaders([
'Authorization' => 'Bearer '.$this->bearerToken,
'Content-Type' => 'application/json',
])->postJson("/api/v1/deployments/{$deployment->deployment_uuid}/cancel");
// The controller updates status before SSH calls, so DB state is always correct
$deployment->refresh();
expect($deployment->status)->toBe(ApplicationDeploymentStatus::CANCELLED_BY_USER->value);
});
test('cancels in-progress deployment and updates status in database', function () {
$deployment = ApplicationDeploymentQueue::create([
'deployment_uuid' => 'in-progress-deployment-uuid',
'application_id' => 1,
'server_id' => $this->server->id,
'status' => ApplicationDeploymentStatus::IN_PROGRESS->value,
]);
$this->withHeaders([
'Authorization' => 'Bearer '.$this->bearerToken,
'Content-Type' => 'application/json',
])->postJson("/api/v1/deployments/{$deployment->deployment_uuid}/cancel");
// The controller updates status before SSH calls, so DB state is always correct
$deployment->refresh();
expect($deployment->status)->toBe(ApplicationDeploymentStatus::CANCELLED_BY_USER->value);
});
});
@@ -0,0 +1,123 @@
<?php
use App\Models\Application;
use App\Models\Environment;
use App\Models\Project;
use App\Models\Server;
use App\Models\StandaloneDocker;
use App\Models\Team;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
uses(RefreshDatabase::class);
beforeEach(function () {
$this->team = Team::factory()->create();
$this->user = User::factory()->create();
$this->team->members()->attach($this->user->id, ['role' => 'owner']);
session(['currentTeam' => $this->team]);
$this->token = $this->user->createToken('test-token', ['*']);
$this->bearerToken = $this->token->plainTextToken;
$this->server = Server::factory()->create(['team_id' => $this->team->id]);
$this->destination = StandaloneDocker::where('server_id', $this->server->id)->first();
$this->project = Project::factory()->create(['team_id' => $this->team->id]);
$this->environment = Environment::factory()->create(['project_id' => $this->project->id]);
});
function domainApiAuthHeaders(): array
{
return [
'Authorization' => 'Bearer '.test()->bearerToken,
];
}
test('returns domains for own team application via uuid query param', function () {
$application = Application::factory()->create([
'fqdn' => 'https://my-app.example.com',
'environment_id' => $this->environment->id,
'destination_id' => $this->destination->id,
'destination_type' => $this->destination->getMorphClass(),
]);
$response = $this->withHeaders(domainApiAuthHeaders())
->getJson("/api/v1/servers/{$this->server->uuid}/domains?uuid={$application->uuid}");
$response->assertOk();
$response->assertJsonFragment(['my-app.example.com']);
});
test('returns 404 when application uuid belongs to another team', function () {
$otherTeam = Team::factory()->create();
$otherUser = User::factory()->create();
$otherTeam->members()->attach($otherUser->id, ['role' => 'owner']);
$otherServer = Server::factory()->create(['team_id' => $otherTeam->id]);
$otherDestination = StandaloneDocker::where('server_id', $otherServer->id)->first();
$otherProject = Project::factory()->create(['team_id' => $otherTeam->id]);
$otherEnvironment = Environment::factory()->create(['project_id' => $otherProject->id]);
$otherApplication = Application::factory()->create([
'fqdn' => 'https://secret-app.internal.company.com',
'environment_id' => $otherEnvironment->id,
'destination_id' => $otherDestination->id,
'destination_type' => $otherDestination->getMorphClass(),
]);
$response = $this->withHeaders(domainApiAuthHeaders())
->getJson("/api/v1/servers/{$this->server->uuid}/domains?uuid={$otherApplication->uuid}");
$response->assertNotFound();
$response->assertJson(['message' => 'Application not found.']);
});
test('returns 404 for nonexistent application uuid', function () {
$response = $this->withHeaders(domainApiAuthHeaders())
->getJson("/api/v1/servers/{$this->server->uuid}/domains?uuid=nonexistent-uuid");
$response->assertNotFound();
$response->assertJson(['message' => 'Application not found.']);
});
test('returns 404 when server uuid belongs to another team', function () {
$otherTeam = Team::factory()->create();
$otherUser = User::factory()->create();
$otherTeam->members()->attach($otherUser->id, ['role' => 'owner']);
$otherServer = Server::factory()->create(['team_id' => $otherTeam->id]);
$response = $this->withHeaders(authHeaders())
->getJson("/api/v1/servers/{$otherServer->uuid}/domains");
$response->assertNotFound();
$response->assertJson(['message' => 'Server not found.']);
});
test('only returns domains for applications on the specified server', function () {
$application = Application::factory()->create([
'fqdn' => 'https://app-on-server.example.com',
'environment_id' => $this->environment->id,
'destination_id' => $this->destination->id,
'destination_type' => $this->destination->getMorphClass(),
]);
$otherServer = Server::factory()->create(['team_id' => $this->team->id]);
$otherDestination = StandaloneDocker::where('server_id', $otherServer->id)->first();
$applicationOnOtherServer = Application::factory()->create([
'fqdn' => 'https://app-on-other-server.example.com',
'environment_id' => $this->environment->id,
'destination_id' => $otherDestination->id,
'destination_type' => $otherDestination->getMorphClass(),
]);
$response = $this->withHeaders(authHeaders())
->getJson("/api/v1/servers/{$this->server->uuid}/domains");
$response->assertOk();
$responseContent = $response->json();
$allDomains = collect($responseContent)->pluck('domains')->flatten()->toArray();
expect($allDomains)->toContain('app-on-server.example.com');
expect($allDomains)->not->toContain('app-on-other-server.example.com');
});
@@ -0,0 +1,254 @@
<?php
use App\Models\Application;
use App\Models\Environment;
use App\Models\EnvironmentVariable;
use App\Models\InstanceSettings;
use App\Models\Project;
use App\Models\Server;
use App\Models\Service;
use App\Models\StandaloneDocker;
use App\Models\Team;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
uses(RefreshDatabase::class);
beforeEach(function () {
InstanceSettings::updateOrCreate(['id' => 0]);
$this->team = Team::factory()->create();
$this->user = User::factory()->create();
$this->team->members()->attach($this->user->id, ['role' => 'owner']);
session(['currentTeam' => $this->team]);
$this->token = $this->user->createToken('test-token', ['*']);
$this->bearerToken = $this->token->plainTextToken;
$this->server = Server::factory()->create(['team_id' => $this->team->id]);
$this->destination = StandaloneDocker::where('server_id', $this->server->id)->first();
$this->project = Project::factory()->create(['team_id' => $this->team->id]);
$this->environment = Environment::factory()->create(['project_id' => $this->project->id]);
});
describe('PATCH /api/v1/services/{uuid}/envs', function () {
test('returns the updated environment variable object', function () {
$service = Service::factory()->create([
'server_id' => $this->server->id,
'destination_id' => $this->destination->id,
'destination_type' => $this->destination->getMorphClass(),
'environment_id' => $this->environment->id,
]);
EnvironmentVariable::create([
'key' => 'APP_IMAGE_TAG',
'value' => 'old-value',
'resourceable_type' => Service::class,
'resourceable_id' => $service->id,
'is_preview' => false,
]);
$response = $this->withHeaders([
'Authorization' => 'Bearer '.$this->bearerToken,
'Content-Type' => 'application/json',
])->patchJson("/api/v1/services/{$service->uuid}/envs", [
'key' => 'APP_IMAGE_TAG',
'value' => 'new-value',
]);
$response->assertStatus(201);
$response->assertJsonStructure([
'uuid',
'key',
'is_literal',
'is_multiline',
'is_shown_once',
'created_at',
'updated_at',
]);
$response->assertJsonFragment(['key' => 'APP_IMAGE_TAG']);
$response->assertJsonMissing(['message' => 'Environment variable updated.']);
});
test('returns 404 when environment variable does not exist', function () {
$service = Service::factory()->create([
'server_id' => $this->server->id,
'destination_id' => $this->destination->id,
'destination_type' => $this->destination->getMorphClass(),
'environment_id' => $this->environment->id,
]);
$response = $this->withHeaders([
'Authorization' => 'Bearer '.$this->bearerToken,
'Content-Type' => 'application/json',
])->patchJson("/api/v1/services/{$service->uuid}/envs", [
'key' => 'NONEXISTENT_KEY',
'value' => 'some-value',
]);
$response->assertStatus(404);
$response->assertJson(['message' => 'Environment variable not found.']);
});
test('returns 404 when service does not exist', function () {
$response = $this->withHeaders([
'Authorization' => 'Bearer '.$this->bearerToken,
'Content-Type' => 'application/json',
])->patchJson('/api/v1/services/non-existent-uuid/envs', [
'key' => 'APP_IMAGE_TAG',
'value' => 'some-value',
]);
$response->assertStatus(404);
});
test('returns 422 when key is missing', function () {
$service = Service::factory()->create([
'server_id' => $this->server->id,
'destination_id' => $this->destination->id,
'destination_type' => $this->destination->getMorphClass(),
'environment_id' => $this->environment->id,
]);
$response = $this->withHeaders([
'Authorization' => 'Bearer '.$this->bearerToken,
'Content-Type' => 'application/json',
])->patchJson("/api/v1/services/{$service->uuid}/envs", [
'value' => 'some-value',
]);
$response->assertStatus(422);
});
test('uses route uuid and ignores uuid in request body', function () {
$service = Service::factory()->create([
'server_id' => $this->server->id,
'destination_id' => $this->destination->id,
'destination_type' => $this->destination->getMorphClass(),
'environment_id' => $this->environment->id,
]);
EnvironmentVariable::create([
'key' => 'TEST_KEY',
'value' => 'old-value',
'resourceable_type' => Service::class,
'resourceable_id' => $service->id,
'is_preview' => false,
]);
$response = $this->withHeaders([
'Authorization' => 'Bearer '.$this->bearerToken,
'Content-Type' => 'application/json',
])->patchJson("/api/v1/services/{$service->uuid}/envs", [
'key' => 'TEST_KEY',
'value' => 'new-value',
'uuid' => 'bogus-uuid-from-body',
]);
$response->assertStatus(201);
$response->assertJsonFragment(['key' => 'TEST_KEY']);
});
});
describe('PATCH /api/v1/applications/{uuid}/envs', function () {
test('returns the updated environment variable object', function () {
$application = Application::factory()->create([
'environment_id' => $this->environment->id,
'destination_id' => $this->destination->id,
'destination_type' => $this->destination->getMorphClass(),
]);
EnvironmentVariable::create([
'key' => 'APP_IMAGE_TAG',
'value' => 'old-value',
'resourceable_type' => Application::class,
'resourceable_id' => $application->id,
'is_preview' => false,
]);
$response = $this->withHeaders([
'Authorization' => 'Bearer '.$this->bearerToken,
'Content-Type' => 'application/json',
])->patchJson("/api/v1/applications/{$application->uuid}/envs", [
'key' => 'APP_IMAGE_TAG',
'value' => 'new-value',
]);
$response->assertStatus(201);
$response->assertJsonStructure([
'uuid',
'key',
'is_literal',
'is_multiline',
'is_shown_once',
'created_at',
'updated_at',
]);
$response->assertJsonFragment(['key' => 'APP_IMAGE_TAG']);
$response->assertJsonMissing(['message' => 'Environment variable updated.']);
});
test('returns 404 when environment variable does not exist', function () {
$application = Application::factory()->create([
'environment_id' => $this->environment->id,
'destination_id' => $this->destination->id,
'destination_type' => $this->destination->getMorphClass(),
]);
$response = $this->withHeaders([
'Authorization' => 'Bearer '.$this->bearerToken,
'Content-Type' => 'application/json',
])->patchJson("/api/v1/applications/{$application->uuid}/envs", [
'key' => 'NONEXISTENT_KEY',
'value' => 'some-value',
]);
$response->assertStatus(404);
});
test('returns 422 when key is missing', function () {
$application = Application::factory()->create([
'environment_id' => $this->environment->id,
'destination_id' => $this->destination->id,
'destination_type' => $this->destination->getMorphClass(),
]);
$response = $this->withHeaders([
'Authorization' => 'Bearer '.$this->bearerToken,
'Content-Type' => 'application/json',
])->patchJson("/api/v1/applications/{$application->uuid}/envs", [
'value' => 'some-value',
]);
$response->assertStatus(422);
});
test('rejects unknown fields in request body', function () {
$application = Application::factory()->create([
'environment_id' => $this->environment->id,
'destination_id' => $this->destination->id,
'destination_type' => $this->destination->getMorphClass(),
]);
EnvironmentVariable::create([
'key' => 'TEST_KEY',
'value' => 'old-value',
'resourceable_type' => Application::class,
'resourceable_id' => $application->id,
'is_preview' => false,
]);
$response = $this->withHeaders([
'Authorization' => 'Bearer '.$this->bearerToken,
'Content-Type' => 'application/json',
])->patchJson("/api/v1/applications/{$application->uuid}/envs", [
'key' => 'TEST_KEY',
'value' => 'new-value',
'uuid' => 'bogus-uuid-from-body',
]);
$response->assertStatus(422);
$response->assertJsonFragment(['uuid' => ['This field is not allowed.']]);
});
});
+222
View File
@@ -0,0 +1,222 @@
<?php
use App\Models\GithubApp;
use App\Models\PrivateKey;
use App\Models\Team;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
uses(RefreshDatabase::class);
beforeEach(function () {
// Create a team with owner
$this->team = Team::factory()->create();
$this->user = User::factory()->create();
$this->team->members()->attach($this->user->id, ['role' => 'owner']);
// Create an API token for the user
$this->token = $this->user->createToken('test-token', ['*'], $this->team->id);
$this->bearerToken = $this->token->plainTextToken;
// Create a private key for the team
$this->privateKey = PrivateKey::create([
'name' => 'Test Key',
'private_key' => 'test-private-key-content',
'team_id' => $this->team->id,
]);
});
describe('GET /api/v1/github-apps', function () {
test('returns 401 when not authenticated', function () {
$response = $this->getJson('/api/v1/github-apps');
$response->assertStatus(401);
});
test('returns empty array when no github apps exist', function () {
$response = $this->withHeaders([
'Authorization' => 'Bearer '.$this->bearerToken,
])->getJson('/api/v1/github-apps');
$response->assertStatus(200);
$response->assertJson([]);
});
test('returns team github apps', function () {
// Create a GitHub app for the team
$githubApp = GithubApp::create([
'name' => 'Test GitHub App',
'api_url' => 'https://api.github.com',
'html_url' => 'https://github.com',
'app_id' => 12345,
'installation_id' => 67890,
'client_id' => 'test-client-id',
'client_secret' => 'test-client-secret',
'webhook_secret' => 'test-webhook-secret',
'private_key_id' => $this->privateKey->id,
'team_id' => $this->team->id,
'is_system_wide' => false,
'is_public' => false,
]);
$response = $this->withHeaders([
'Authorization' => 'Bearer '.$this->bearerToken,
])->getJson('/api/v1/github-apps');
$response->assertStatus(200);
$response->assertJsonCount(1);
$response->assertJsonFragment([
'name' => 'Test GitHub App',
'app_id' => 12345,
]);
});
test('does not return sensitive data', function () {
// Create a GitHub app
GithubApp::create([
'name' => 'Test GitHub App',
'api_url' => 'https://api.github.com',
'html_url' => 'https://github.com',
'app_id' => 12345,
'installation_id' => 67890,
'client_id' => 'test-client-id',
'client_secret' => 'secret-should-be-hidden',
'webhook_secret' => 'webhook-secret-should-be-hidden',
'private_key_id' => $this->privateKey->id,
'team_id' => $this->team->id,
]);
$response = $this->withHeaders([
'Authorization' => 'Bearer '.$this->bearerToken,
])->getJson('/api/v1/github-apps');
$response->assertStatus(200);
$json = $response->json();
// Ensure sensitive data is not present
expect($json[0])->not->toHaveKey('client_secret');
expect($json[0])->not->toHaveKey('webhook_secret');
});
test('returns system-wide github apps', function () {
// Create a system-wide GitHub app
$systemApp = GithubApp::create([
'name' => 'System GitHub App',
'api_url' => 'https://api.github.com',
'html_url' => 'https://github.com',
'app_id' => 11111,
'installation_id' => 22222,
'client_id' => 'system-client-id',
'client_secret' => 'system-secret',
'webhook_secret' => 'system-webhook',
'private_key_id' => $this->privateKey->id,
'team_id' => $this->team->id,
'is_system_wide' => true,
]);
// Create another team and user
$otherTeam = Team::factory()->create();
$otherUser = User::factory()->create();
$otherTeam->members()->attach($otherUser->id, ['role' => 'owner']);
$otherToken = $otherUser->createToken('other-token', ['*'], $otherTeam->id);
// System-wide apps should be visible to other teams
$response = $this->withHeaders([
'Authorization' => 'Bearer '.$otherToken->plainTextToken,
])->getJson('/api/v1/github-apps');
$response->assertStatus(200);
$response->assertJsonFragment([
'name' => 'System GitHub App',
'is_system_wide' => true,
]);
});
test('does not return other teams github apps', function () {
// Create a GitHub app for this team
GithubApp::create([
'name' => 'Team 1 App',
'api_url' => 'https://api.github.com',
'html_url' => 'https://github.com',
'app_id' => 11111,
'installation_id' => 22222,
'client_id' => 'team1-client-id',
'client_secret' => 'team1-secret',
'webhook_secret' => 'team1-webhook',
'private_key_id' => $this->privateKey->id,
'team_id' => $this->team->id,
'is_system_wide' => false,
]);
// Create another team with a GitHub app
$otherTeam = Team::factory()->create();
$otherPrivateKey = PrivateKey::create([
'name' => 'Other Key',
'private_key' => 'other-key',
'team_id' => $otherTeam->id,
]);
GithubApp::create([
'name' => 'Team 2 App',
'api_url' => 'https://api.github.com',
'html_url' => 'https://github.com',
'app_id' => 33333,
'installation_id' => 44444,
'client_id' => 'team2-client-id',
'client_secret' => 'team2-secret',
'webhook_secret' => 'team2-webhook',
'private_key_id' => $otherPrivateKey->id,
'team_id' => $otherTeam->id,
'is_system_wide' => false,
]);
// Request from first team should only see their app
$response = $this->withHeaders([
'Authorization' => 'Bearer '.$this->bearerToken,
])->getJson('/api/v1/github-apps');
$response->assertStatus(200);
$response->assertJsonCount(1);
$response->assertJsonFragment(['name' => 'Team 1 App']);
$response->assertJsonMissing(['name' => 'Team 2 App']);
});
test('returns correct response structure', function () {
GithubApp::create([
'name' => 'Test App',
'api_url' => 'https://api.github.com',
'html_url' => 'https://github.com',
'custom_user' => 'git',
'custom_port' => 22,
'app_id' => 12345,
'installation_id' => 67890,
'client_id' => 'client-id',
'client_secret' => 'secret',
'webhook_secret' => 'webhook',
'private_key_id' => $this->privateKey->id,
'team_id' => $this->team->id,
]);
$response = $this->withHeaders([
'Authorization' => 'Bearer '.$this->bearerToken,
])->getJson('/api/v1/github-apps');
$response->assertStatus(200);
$response->assertJsonStructure([
[
'id',
'uuid',
'name',
'api_url',
'html_url',
'custom_user',
'custom_port',
'app_id',
'installation_id',
'client_id',
'private_key_id',
'team_id',
'type',
],
]);
});
});
+548
View File
@@ -0,0 +1,548 @@
<?php
use App\Models\CloudProviderToken;
use App\Models\InstanceSettings;
use App\Models\PrivateKey;
use App\Models\Team;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Once;
uses(RefreshDatabase::class);
beforeEach(function () {
InstanceSettings::query()->whereKey(0)->delete();
$settings = new InstanceSettings(['is_api_enabled' => true]);
$settings->id = 0;
$settings->save();
Once::flush();
// Create a team with owner
$this->team = Team::factory()->create();
$this->user = User::factory()->create();
$this->team->members()->attach($this->user->id, ['role' => 'owner']);
// Create an API token for the user
session(['currentTeam' => $this->team]);
$this->token = $this->user->createToken('test-token', ['*']);
$this->bearerToken = $this->token->plainTextToken;
// Create a Hetzner cloud provider token
$this->hetznerToken = CloudProviderToken::factory()->create([
'team_id' => $this->team->id,
'provider' => 'hetzner',
'token' => 'test-hetzner-api-token',
]);
// Create a private key
$this->privateKey = PrivateKey::factory()->create([
'team_id' => $this->team->id,
]);
});
describe('GET /api/v1/hetzner/locations', function () {
test('gets Hetzner locations', function () {
Http::fake([
'https://api.hetzner.cloud/v1/locations*' => Http::response([
'locations' => [
['id' => 1, 'name' => 'nbg1', 'description' => 'Nuremberg 1 DC Park 1', 'country' => 'DE', 'city' => 'Nuremberg'],
['id' => 2, 'name' => 'hel1', 'description' => 'Helsinki 1 DC Park 8', 'country' => 'FI', 'city' => 'Helsinki'],
],
'meta' => ['pagination' => ['next_page' => null]],
], 200),
]);
$response = $this->withHeaders([
'Authorization' => 'Bearer '.$this->bearerToken,
'Content-Type' => 'application/json',
])->getJson('/api/v1/hetzner/locations?cloud_provider_token_id='.$this->hetznerToken->uuid);
$response->assertStatus(200);
$response->assertJsonCount(2);
$response->assertJsonFragment(['name' => 'nbg1']);
});
test('requires cloud_provider_token_id parameter', function () {
$response = $this->withHeaders([
'Authorization' => 'Bearer '.$this->bearerToken,
'Content-Type' => 'application/json',
])->getJson('/api/v1/hetzner/locations');
$response->assertStatus(422);
$response->assertJsonValidationErrors(['cloud_provider_token_id']);
});
test('returns 404 for non-existent token', function () {
$response = $this->withHeaders([
'Authorization' => 'Bearer '.$this->bearerToken,
'Content-Type' => 'application/json',
])->getJson('/api/v1/hetzner/locations?cloud_provider_token_id=non-existent-uuid');
$response->assertStatus(404);
});
test('member read token cannot use a stored cloud provider token', function () {
$member = User::factory()->create();
$this->team->members()->attach($member->id, ['role' => 'member']);
session(['currentTeam' => $this->team]);
$memberToken = $member->createToken('member-read', ['read'])->plainTextToken;
Http::fake([
'https://api.hetzner.cloud/v1/locations*' => Http::response([
'locations' => [['id' => 1, 'name' => 'nbg1']],
], 200),
]);
$response = $this->withHeaders([
'Authorization' => 'Bearer '.$memberToken,
'Content-Type' => 'application/json',
])->getJson('/api/v1/hetzner/locations?cloud_provider_token_id='.$this->hetznerToken->uuid);
$response->assertForbidden();
Http::assertNothingSent();
});
});
describe('GET /api/v1/hetzner/server-types', function () {
test('gets Hetzner server types', function () {
Http::fake([
'https://api.hetzner.cloud/v1/server_types*' => Http::response([
'server_types' => [
['id' => 1, 'name' => 'cx11', 'description' => 'CX11', 'cores' => 1, 'memory' => 2.0, 'disk' => 20],
['id' => 2, 'name' => 'cx21', 'description' => 'CX21', 'cores' => 2, 'memory' => 4.0, 'disk' => 40],
],
'meta' => ['pagination' => ['next_page' => null]],
], 200),
]);
$response = $this->withHeaders([
'Authorization' => 'Bearer '.$this->bearerToken,
'Content-Type' => 'application/json',
])->getJson('/api/v1/hetzner/server-types?cloud_provider_token_id='.$this->hetznerToken->uuid);
$response->assertStatus(200);
$response->assertJsonCount(2);
$response->assertJsonFragment(['name' => 'cx11']);
});
test('filters out deprecated server types', function () {
Http::fake([
'https://api.hetzner.cloud/v1/server_types*' => Http::response([
'server_types' => [
['id' => 1, 'name' => 'cx11', 'deprecated' => false],
['id' => 2, 'name' => 'cx21', 'deprecated' => true],
],
'meta' => ['pagination' => ['next_page' => null]],
], 200),
]);
$response = $this->withHeaders([
'Authorization' => 'Bearer '.$this->bearerToken,
'Content-Type' => 'application/json',
])->getJson('/api/v1/hetzner/server-types?cloud_provider_token_id='.$this->hetznerToken->uuid);
$response->assertStatus(200);
$response->assertJsonCount(1);
$response->assertJsonFragment(['name' => 'cx11']);
$response->assertJsonMissing(['name' => 'cx21']);
});
});
describe('GET /api/v1/hetzner/images', function () {
test('gets Hetzner images', function () {
Http::fake([
'https://api.hetzner.cloud/v1/images*' => Http::response([
'images' => [
['id' => 1, 'name' => 'ubuntu-20.04', 'type' => 'system', 'deprecated' => false],
['id' => 2, 'name' => 'ubuntu-22.04', 'type' => 'system', 'deprecated' => false],
],
'meta' => ['pagination' => ['next_page' => null]],
], 200),
]);
$response = $this->withHeaders([
'Authorization' => 'Bearer '.$this->bearerToken,
'Content-Type' => 'application/json',
])->getJson('/api/v1/hetzner/images?cloud_provider_token_id='.$this->hetznerToken->uuid);
$response->assertStatus(200);
$response->assertJsonCount(2);
$response->assertJsonFragment(['name' => 'ubuntu-20.04']);
});
test('filters out deprecated images', function () {
Http::fake([
'https://api.hetzner.cloud/v1/images*' => Http::response([
'images' => [
['id' => 1, 'name' => 'ubuntu-20.04', 'type' => 'system', 'deprecated' => false],
['id' => 2, 'name' => 'ubuntu-16.04', 'type' => 'system', 'deprecated' => true],
],
'meta' => ['pagination' => ['next_page' => null]],
], 200),
]);
$response = $this->withHeaders([
'Authorization' => 'Bearer '.$this->bearerToken,
'Content-Type' => 'application/json',
])->getJson('/api/v1/hetzner/images?cloud_provider_token_id='.$this->hetznerToken->uuid);
$response->assertStatus(200);
$response->assertJsonCount(1);
$response->assertJsonFragment(['name' => 'ubuntu-20.04']);
$response->assertJsonMissing(['name' => 'ubuntu-16.04']);
});
test('filters out non-system images', function () {
Http::fake([
'https://api.hetzner.cloud/v1/images*' => Http::response([
'images' => [
['id' => 1, 'name' => 'ubuntu-20.04', 'type' => 'system', 'deprecated' => false],
['id' => 2, 'name' => 'my-snapshot', 'type' => 'snapshot', 'deprecated' => false],
],
'meta' => ['pagination' => ['next_page' => null]],
], 200),
]);
$response = $this->withHeaders([
'Authorization' => 'Bearer '.$this->bearerToken,
'Content-Type' => 'application/json',
])->getJson('/api/v1/hetzner/images?cloud_provider_token_id='.$this->hetznerToken->uuid);
$response->assertStatus(200);
$response->assertJsonCount(1);
$response->assertJsonFragment(['name' => 'ubuntu-20.04']);
$response->assertJsonMissing(['name' => 'my-snapshot']);
});
});
describe('GET /api/v1/hetzner/ssh-keys', function () {
test('gets Hetzner SSH keys', function () {
Http::fake([
'https://api.hetzner.cloud/v1/ssh_keys*' => Http::response([
'ssh_keys' => [
['id' => 1, 'name' => 'my-key', 'fingerprint' => 'aa:bb:cc:dd'],
['id' => 2, 'name' => 'another-key', 'fingerprint' => 'ee:ff:11:22'],
],
'meta' => ['pagination' => ['next_page' => null]],
], 200),
]);
$response = $this->withHeaders([
'Authorization' => 'Bearer '.$this->bearerToken,
'Content-Type' => 'application/json',
])->getJson('/api/v1/hetzner/ssh-keys?cloud_provider_token_id='.$this->hetznerToken->uuid);
$response->assertStatus(200);
$response->assertJsonCount(2);
$response->assertJsonFragment(['name' => 'my-key']);
});
});
describe('POST /api/v1/servers/hetzner', function () {
test('creates a Hetzner server', function () {
// Mock Hetzner API calls
Http::fake([
'https://api.hetzner.cloud/v1/ssh_keys*' => Http::response([
'ssh_keys' => [],
'meta' => ['pagination' => ['next_page' => null]],
], 200),
'https://api.hetzner.cloud/v1/ssh_keys' => Http::response([
'ssh_key' => ['id' => 123, 'fingerprint' => 'aa:bb:cc:dd'],
], 201),
'https://api.hetzner.cloud/v1/servers' => Http::response([
'server' => [
'id' => 456,
'name' => 'test-server',
'public_net' => [
'ipv4' => ['ip' => '1.2.3.4'],
'ipv6' => ['ip' => '2001:db8::1'],
],
],
], 201),
]);
$response = $this->withHeaders([
'Authorization' => 'Bearer '.$this->bearerToken,
'Content-Type' => 'application/json',
])->postJson('/api/v1/servers/hetzner', [
'cloud_provider_token_id' => $this->hetznerToken->uuid,
'location' => 'nbg1',
'server_type' => 'cx11',
'image' => 15512617,
'name' => 'test-server',
'private_key_uuid' => $this->privateKey->uuid,
'enable_ipv4' => true,
'enable_ipv6' => true,
]);
$response->assertStatus(201);
$response->assertJsonStructure(['uuid', 'hetzner_server_id', 'ip']);
$response->assertJsonFragment(['hetzner_server_id' => 456, 'ip' => '1.2.3.4']);
// Verify server was created in database
$this->assertDatabaseHas('servers', [
'name' => 'test-server',
'ip' => '1.2.3.4',
'team_id' => $this->team->id,
'hetzner_server_id' => 456,
]);
});
test('generates server name if not provided', function () {
Http::fake([
'https://api.hetzner.cloud/v1/ssh_keys*' => Http::response([
'ssh_keys' => [],
'meta' => ['pagination' => ['next_page' => null]],
], 200),
'https://api.hetzner.cloud/v1/ssh_keys' => Http::response([
'ssh_key' => ['id' => 123, 'fingerprint' => 'aa:bb:cc:dd'],
], 201),
'https://api.hetzner.cloud/v1/servers' => Http::response([
'server' => [
'id' => 456,
'public_net' => [
'ipv4' => ['ip' => '1.2.3.4'],
],
],
], 201),
]);
$response = $this->withHeaders([
'Authorization' => 'Bearer '.$this->bearerToken,
'Content-Type' => 'application/json',
])->postJson('/api/v1/servers/hetzner', [
'cloud_provider_token_id' => $this->hetznerToken->uuid,
'location' => 'nbg1',
'server_type' => 'cx11',
'image' => 15512617,
'private_key_uuid' => $this->privateKey->uuid,
]);
$response->assertStatus(201);
// Verify a server was created with a generated name
$this->assertDatabaseCount('servers', 1);
});
test('validates required fields', function () {
$response = $this->withHeaders([
'Authorization' => 'Bearer '.$this->bearerToken,
'Content-Type' => 'application/json',
])->postJson('/api/v1/servers/hetzner', []);
$response->assertStatus(422);
$response->assertJsonValidationErrors([
'cloud_provider_token_id',
'location',
'server_type',
'image',
'private_key_uuid',
]);
});
test('validates cloud_provider_token_id exists', function () {
$response = $this->withHeaders([
'Authorization' => 'Bearer '.$this->bearerToken,
'Content-Type' => 'application/json',
])->postJson('/api/v1/servers/hetzner', [
'cloud_provider_token_id' => 'non-existent-uuid',
'location' => 'nbg1',
'server_type' => 'cx11',
'image' => 15512617,
'private_key_uuid' => $this->privateKey->uuid,
]);
$response->assertStatus(404);
$response->assertJson(['message' => 'Hetzner cloud provider token not found.']);
});
test('validates private_key_uuid exists', function () {
$response = $this->withHeaders([
'Authorization' => 'Bearer '.$this->bearerToken,
'Content-Type' => 'application/json',
])->postJson('/api/v1/servers/hetzner', [
'cloud_provider_token_id' => $this->hetznerToken->uuid,
'location' => 'nbg1',
'server_type' => 'cx11',
'image' => 15512617,
'private_key_uuid' => 'non-existent-uuid',
]);
$response->assertStatus(404);
$response->assertJson(['message' => 'Private key not found.']);
});
test('prefers IPv4 when both IPv4 and IPv6 are enabled', function () {
Http::fake([
'https://api.hetzner.cloud/v1/ssh_keys*' => Http::response([
'ssh_keys' => [],
'meta' => ['pagination' => ['next_page' => null]],
], 200),
'https://api.hetzner.cloud/v1/ssh_keys' => Http::response([
'ssh_key' => ['id' => 123],
], 201),
'https://api.hetzner.cloud/v1/servers' => Http::response([
'server' => [
'id' => 456,
'public_net' => [
'ipv4' => ['ip' => '1.2.3.4'],
'ipv6' => ['ip' => '2001:db8::1'],
],
],
], 201),
]);
$response = $this->withHeaders([
'Authorization' => 'Bearer '.$this->bearerToken,
'Content-Type' => 'application/json',
])->postJson('/api/v1/servers/hetzner', [
'cloud_provider_token_id' => $this->hetznerToken->uuid,
'location' => 'nbg1',
'server_type' => 'cx11',
'image' => 15512617,
'private_key_uuid' => $this->privateKey->uuid,
'enable_ipv4' => true,
'enable_ipv6' => true,
]);
$response->assertStatus(201);
$response->assertJsonFragment(['ip' => '1.2.3.4']);
});
test('uses IPv6 when only IPv6 is enabled', function () {
Http::fake([
'https://api.hetzner.cloud/v1/ssh_keys*' => Http::response([
'ssh_keys' => [],
'meta' => ['pagination' => ['next_page' => null]],
], 200),
'https://api.hetzner.cloud/v1/ssh_keys' => Http::response([
'ssh_key' => ['id' => 123],
], 201),
'https://api.hetzner.cloud/v1/servers' => Http::response([
'server' => [
'id' => 456,
'public_net' => [
'ipv4' => ['ip' => null],
'ipv6' => ['ip' => '2001:db8::1'],
],
],
], 201),
]);
$response = $this->withHeaders([
'Authorization' => 'Bearer '.$this->bearerToken,
'Content-Type' => 'application/json',
])->postJson('/api/v1/servers/hetzner', [
'cloud_provider_token_id' => $this->hetznerToken->uuid,
'location' => 'nbg1',
'server_type' => 'cx11',
'image' => 15512617,
'private_key_uuid' => $this->privateKey->uuid,
'enable_ipv4' => false,
'enable_ipv6' => true,
]);
$response->assertStatus(201);
$response->assertJsonFragment(['ip' => '2001:db8::1']);
});
test('rejects extra fields not in allowed list', function () {
$response = $this->withHeaders([
'Authorization' => 'Bearer '.$this->bearerToken,
'Content-Type' => 'application/json',
])->postJson('/api/v1/servers/hetzner', [
'cloud_provider_token_id' => $this->hetznerToken->uuid,
'location' => 'nbg1',
'server_type' => 'cx11',
'image' => 15512617,
'private_key_uuid' => $this->privateKey->uuid,
'invalid_field' => 'invalid_value',
]);
$response->assertStatus(422);
});
test('rejects request without authentication', function () {
$response = $this->postJson('/api/v1/servers/hetzner', [
'cloud_provider_token_id' => $this->hetznerToken->uuid,
'location' => 'nbg1',
'server_type' => 'cx11',
'image' => 15512617,
'private_key_uuid' => $this->privateKey->uuid,
]);
$response->assertStatus(401);
});
});
describe('error responses do not leak exception details', function () {
test('locations endpoint returns generic 500 message on upstream failure', function () {
Http::fake([
'https://api.hetzner.cloud/v1/locations*' => Http::response([
'error' => ['message' => 'INTERNAL_LEAK_TOKEN_abc /var/secret/path'],
], 500),
]);
$response = $this->withHeaders([
'Authorization' => 'Bearer '.$this->bearerToken,
'Content-Type' => 'application/json',
])->getJson('/api/v1/hetzner/locations?cloud_provider_token_id='.$this->hetznerToken->uuid);
$response->assertStatus(500);
$response->assertExactJson(['message' => 'Failed to fetch Hetzner locations.']);
expect($response->getContent())->not->toContain('INTERNAL_LEAK_TOKEN_abc');
expect($response->getContent())->not->toContain('/var/secret/path');
});
test('server-types endpoint returns generic 500 message on upstream failure', function () {
Http::fake([
'https://api.hetzner.cloud/v1/server_types*' => Http::response([
'error' => ['message' => 'INTERNAL_LEAK_TOKEN_abc'],
], 500),
]);
$response = $this->withHeaders([
'Authorization' => 'Bearer '.$this->bearerToken,
'Content-Type' => 'application/json',
])->getJson('/api/v1/hetzner/server-types?cloud_provider_token_id='.$this->hetznerToken->uuid);
$response->assertStatus(500);
$response->assertExactJson(['message' => 'Failed to fetch Hetzner server types.']);
expect($response->getContent())->not->toContain('INTERNAL_LEAK_TOKEN_abc');
});
test('images endpoint returns generic 500 message on upstream failure', function () {
Http::fake([
'https://api.hetzner.cloud/v1/images*' => Http::response([
'error' => ['message' => 'INTERNAL_LEAK_TOKEN_abc'],
], 500),
]);
$response = $this->withHeaders([
'Authorization' => 'Bearer '.$this->bearerToken,
'Content-Type' => 'application/json',
])->getJson('/api/v1/hetzner/images?cloud_provider_token_id='.$this->hetznerToken->uuid);
$response->assertStatus(500);
$response->assertExactJson(['message' => 'Failed to fetch Hetzner images.']);
expect($response->getContent())->not->toContain('INTERNAL_LEAK_TOKEN_abc');
});
test('ssh-keys endpoint returns generic 500 message on upstream failure', function () {
Http::fake([
'https://api.hetzner.cloud/v1/ssh_keys*' => Http::response([
'error' => ['message' => 'INTERNAL_LEAK_TOKEN_abc'],
], 500),
]);
$response = $this->withHeaders([
'Authorization' => 'Bearer '.$this->bearerToken,
'Content-Type' => 'application/json',
])->getJson('/api/v1/hetzner/ssh-keys?cloud_provider_token_id='.$this->hetznerToken->uuid);
$response->assertStatus(500);
$response->assertExactJson(['message' => 'Failed to fetch Hetzner SSH keys.']);
expect($response->getContent())->not->toContain('INTERNAL_LEAK_TOKEN_abc');
});
});
+371
View File
@@ -0,0 +1,371 @@
<?php
use App\Models\Application;
use App\Models\Environment;
use App\Models\InstanceSettings;
use App\Models\Project;
use App\Models\ScheduledTask;
use App\Models\ScheduledTaskExecution;
use App\Models\Server;
use App\Models\Service;
use App\Models\StandaloneDocker;
use App\Models\Team;
use App\Models\User;
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']);
session(['currentTeam' => $this->team]);
$this->token = $this->user->createToken('test-token', ['*']);
$this->bearerToken = $this->token->plainTextToken;
$this->server = Server::factory()->create(['team_id' => $this->team->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 = $this->project->environments()->first();
});
function scheduledTaskAuthHeaders($bearerToken): array
{
return [
'Authorization' => 'Bearer '.$bearerToken,
'Content-Type' => 'application/json',
];
}
describe('GET /api/v1/applications/{uuid}/scheduled-tasks', function () {
test('returns empty array when no tasks exist', function () {
$application = Application::factory()->create([
'environment_id' => $this->environment->id,
'destination_id' => $this->destination->id,
'destination_type' => $this->destination->getMorphClass(),
]);
$response = $this->withHeaders(scheduledTaskAuthHeaders($this->bearerToken))
->getJson("/api/v1/applications/{$application->uuid}/scheduled-tasks");
$response->assertStatus(200);
$response->assertJson([]);
});
test('returns tasks when they exist', function () {
$application = Application::factory()->create([
'environment_id' => $this->environment->id,
'destination_id' => $this->destination->id,
'destination_type' => $this->destination->getMorphClass(),
]);
ScheduledTask::factory()->create([
'application_id' => $application->id,
'team_id' => $this->team->id,
'name' => 'Test Task',
]);
$response = $this->withHeaders(scheduledTaskAuthHeaders($this->bearerToken))
->getJson("/api/v1/applications/{$application->uuid}/scheduled-tasks");
$response->assertStatus(200);
$response->assertJsonCount(1);
$response->assertJsonFragment(['name' => 'Test Task']);
});
test('returns 404 for unknown application uuid', function () {
$response = $this->withHeaders(scheduledTaskAuthHeaders($this->bearerToken))
->getJson('/api/v1/applications/nonexistent-uuid/scheduled-tasks');
$response->assertStatus(404);
});
});
describe('POST /api/v1/applications/{uuid}/scheduled-tasks', function () {
test('creates a task with valid data', function () {
$application = Application::factory()->create([
'environment_id' => $this->environment->id,
'destination_id' => $this->destination->id,
'destination_type' => $this->destination->getMorphClass(),
]);
$response = $this->withHeaders(scheduledTaskAuthHeaders($this->bearerToken))
->postJson("/api/v1/applications/{$application->uuid}/scheduled-tasks", [
'name' => 'Backup',
'command' => 'php artisan backup',
'frequency' => '0 0 * * *',
]);
$response->assertStatus(201);
$response->assertJsonFragment(['name' => 'Backup']);
$this->assertDatabaseHas('scheduled_tasks', [
'name' => 'Backup',
'command' => 'php artisan backup',
'frequency' => '0 0 * * *',
'application_id' => $application->id,
'team_id' => $this->team->id,
]);
});
test('returns 422 when name is missing', function () {
$application = Application::factory()->create([
'environment_id' => $this->environment->id,
'destination_id' => $this->destination->id,
'destination_type' => $this->destination->getMorphClass(),
]);
$response = $this->withHeaders(scheduledTaskAuthHeaders($this->bearerToken))
->postJson("/api/v1/applications/{$application->uuid}/scheduled-tasks", [
'command' => 'echo test',
'frequency' => '* * * * *',
]);
$response->assertStatus(422);
});
test('returns 422 for invalid cron expression', function () {
$application = Application::factory()->create([
'environment_id' => $this->environment->id,
'destination_id' => $this->destination->id,
'destination_type' => $this->destination->getMorphClass(),
]);
$response = $this->withHeaders(scheduledTaskAuthHeaders($this->bearerToken))
->postJson("/api/v1/applications/{$application->uuid}/scheduled-tasks", [
'name' => 'Test',
'command' => 'echo test',
'frequency' => 'not-a-cron',
]);
$response->assertStatus(422);
$response->assertJsonPath('errors.frequency.0', 'Invalid cron expression or frequency format.');
});
test('returns 422 when extra fields are present', function () {
$application = Application::factory()->create([
'environment_id' => $this->environment->id,
'destination_id' => $this->destination->id,
'destination_type' => $this->destination->getMorphClass(),
]);
$response = $this->withHeaders(scheduledTaskAuthHeaders($this->bearerToken))
->postJson("/api/v1/applications/{$application->uuid}/scheduled-tasks", [
'name' => 'Test',
'command' => 'echo test',
'frequency' => '* * * * *',
'unknown_field' => 'value',
]);
$response->assertStatus(422);
});
test('defaults timeout and enabled when not provided', function () {
$application = Application::factory()->create([
'environment_id' => $this->environment->id,
'destination_id' => $this->destination->id,
'destination_type' => $this->destination->getMorphClass(),
]);
$response = $this->withHeaders(scheduledTaskAuthHeaders($this->bearerToken))
->postJson("/api/v1/applications/{$application->uuid}/scheduled-tasks", [
'name' => 'Test',
'command' => 'echo test',
'frequency' => '* * * * *',
]);
$response->assertStatus(201);
$this->assertDatabaseHas('scheduled_tasks', [
'name' => 'Test',
'timeout' => 300,
'enabled' => true,
]);
});
});
describe('PATCH /api/v1/applications/{uuid}/scheduled-tasks/{task_uuid}', function () {
test('updates task with partial data', function () {
$application = Application::factory()->create([
'environment_id' => $this->environment->id,
'destination_id' => $this->destination->id,
'destination_type' => $this->destination->getMorphClass(),
]);
$task = ScheduledTask::factory()->create([
'application_id' => $application->id,
'team_id' => $this->team->id,
'name' => 'Old Name',
]);
$response = $this->withHeaders(scheduledTaskAuthHeaders($this->bearerToken))
->patchJson("/api/v1/applications/{$application->uuid}/scheduled-tasks/{$task->uuid}", [
'name' => 'New Name',
]);
$response->assertStatus(200);
$response->assertJsonFragment(['name' => 'New Name']);
});
test('returns 404 when task not found', function () {
$application = Application::factory()->create([
'environment_id' => $this->environment->id,
'destination_id' => $this->destination->id,
'destination_type' => $this->destination->getMorphClass(),
]);
$response = $this->withHeaders(scheduledTaskAuthHeaders($this->bearerToken))
->patchJson("/api/v1/applications/{$application->uuid}/scheduled-tasks/nonexistent", [
'name' => 'Test',
]);
$response->assertStatus(404);
});
});
describe('DELETE /api/v1/applications/{uuid}/scheduled-tasks/{task_uuid}', function () {
test('deletes task successfully', function () {
$application = Application::factory()->create([
'environment_id' => $this->environment->id,
'destination_id' => $this->destination->id,
'destination_type' => $this->destination->getMorphClass(),
]);
$task = ScheduledTask::factory()->create([
'application_id' => $application->id,
'team_id' => $this->team->id,
]);
$response = $this->withHeaders(scheduledTaskAuthHeaders($this->bearerToken))
->deleteJson("/api/v1/applications/{$application->uuid}/scheduled-tasks/{$task->uuid}");
$response->assertStatus(200);
$response->assertJson(['message' => 'Scheduled task deleted.']);
$this->assertDatabaseMissing('scheduled_tasks', ['uuid' => $task->uuid]);
});
test('returns 404 when task not found', function () {
$application = Application::factory()->create([
'environment_id' => $this->environment->id,
'destination_id' => $this->destination->id,
'destination_type' => $this->destination->getMorphClass(),
]);
$response = $this->withHeaders(scheduledTaskAuthHeaders($this->bearerToken))
->deleteJson("/api/v1/applications/{$application->uuid}/scheduled-tasks/nonexistent");
$response->assertStatus(404);
});
});
describe('GET /api/v1/applications/{uuid}/scheduled-tasks/{task_uuid}/executions', function () {
test('returns executions for a task', function () {
$application = Application::factory()->create([
'environment_id' => $this->environment->id,
'destination_id' => $this->destination->id,
'destination_type' => $this->destination->getMorphClass(),
]);
$task = ScheduledTask::factory()->create([
'application_id' => $application->id,
'team_id' => $this->team->id,
]);
ScheduledTaskExecution::create([
'scheduled_task_id' => $task->id,
'status' => 'success',
'message' => 'OK',
]);
$response = $this->withHeaders(scheduledTaskAuthHeaders($this->bearerToken))
->getJson("/api/v1/applications/{$application->uuid}/scheduled-tasks/{$task->uuid}/executions");
$response->assertStatus(200);
$response->assertJsonCount(1);
$response->assertJsonFragment(['status' => 'success']);
});
test('returns 404 when task not found', function () {
$application = Application::factory()->create([
'environment_id' => $this->environment->id,
'destination_id' => $this->destination->id,
'destination_type' => $this->destination->getMorphClass(),
]);
$response = $this->withHeaders(scheduledTaskAuthHeaders($this->bearerToken))
->getJson("/api/v1/applications/{$application->uuid}/scheduled-tasks/nonexistent/executions");
$response->assertStatus(404);
});
});
describe('Service scheduled tasks API', function () {
test('can list tasks for a service', function () {
$service = Service::factory()->create([
'server_id' => $this->server->id,
'destination_id' => $this->destination->id,
'destination_type' => $this->destination->getMorphClass(),
'environment_id' => $this->environment->id,
]);
ScheduledTask::factory()->create([
'service_id' => $service->id,
'team_id' => $this->team->id,
'name' => 'Service Task',
]);
$response = $this->withHeaders(scheduledTaskAuthHeaders($this->bearerToken))
->getJson("/api/v1/services/{$service->uuid}/scheduled-tasks");
$response->assertStatus(200);
$response->assertJsonCount(1);
$response->assertJsonFragment(['name' => 'Service Task']);
});
test('can create a task for a service', function () {
$service = Service::factory()->create([
'server_id' => $this->server->id,
'destination_id' => $this->destination->id,
'destination_type' => $this->destination->getMorphClass(),
'environment_id' => $this->environment->id,
]);
$response = $this->withHeaders(scheduledTaskAuthHeaders($this->bearerToken))
->postJson("/api/v1/services/{$service->uuid}/scheduled-tasks", [
'name' => 'Service Backup',
'command' => 'pg_dump',
'frequency' => '0 2 * * *',
]);
$response->assertStatus(201);
$response->assertJsonFragment(['name' => 'Service Backup']);
});
test('can delete a task for a service', function () {
$service = Service::factory()->create([
'server_id' => $this->server->id,
'destination_id' => $this->destination->id,
'destination_type' => $this->destination->getMorphClass(),
'environment_id' => $this->environment->id,
]);
$task = ScheduledTask::factory()->create([
'service_id' => $service->id,
'team_id' => $this->team->id,
]);
$response = $this->withHeaders(scheduledTaskAuthHeaders($this->bearerToken))
->deleteJson("/api/v1/services/{$service->uuid}/scheduled-tasks/{$task->uuid}");
$response->assertStatus(200);
$response->assertJson(['message' => 'Scheduled task deleted.']);
});
});