Merge remote-tracking branch 'origin/next' into feat/api/tag-management

This commit is contained in:
Andras Bacsai committed 2026-07-07 14:02:45 +02:00
commit aded45acbe
48 files changed
+2314 -60

No files matched your search

+100 -2
View File
@@ -78,6 +78,63 @@ describe('GET /api/v1/cloud-tokens', 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 () {
@@ -120,6 +177,44 @@ describe('GET /api/v1/cloud-tokens/{uuid}', function () {
$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 () {
@@ -294,8 +389,11 @@ describe('PATCH /api/v1/cloud-tokens/{uuid}', function () {
'Content-Type' => 'application/json',
])->patchJson("/api/v1/cloud-tokens/{$token->uuid}", []);
$response->assertStatus(422);
$response->assertJsonValidationErrors(['name']);
$response->assertStatus(400);
$response->assertJson([
'message' => 'Invalid request.',
'error' => 'Invalid JSON.',
]);
});
test('cannot update token from another team', function () {
+43 -4
View File
@@ -11,6 +11,9 @@ use Illuminate\Support\Facades\Http;
uses(RefreshDatabase::class);
beforeEach(function () {
config()->set('app.maintenance.driver', 'file');
config()->set('cache.default', 'array');
InstanceSettings::forceCreate(['id' => 0, 'is_api_enabled' => true]);
// Create a team with owner
@@ -20,7 +23,7 @@ beforeEach(function () {
session(['currentTeam' => $this->team]);
// Create an API token for the user
$this->token = $this->user->createToken('test-token');
$this->token = $this->user->createToken('test-token', ['*']);
$this->bearerToken = $this->token->plainTextToken;
// Create a private key for the team
@@ -31,6 +34,13 @@ beforeEach(function () {
]);
});
function createGithubAppsApiToken($context, array $abilities): string
{
session(['currentTeam' => $context->team]);
return $context->user->createToken('github-apps-test-token', $abilities)->plainTextToken;
}
/**
* Generate a temporary 2048-bit RSA private key for GitHub Apps API tests.
*
@@ -96,7 +106,7 @@ describe('GET /api/v1/github-apps', function () {
]);
});
test('does not return sensitive data', function () {
test('does not return sensitive data for read tokens', function () {
// Create a GitHub app
GithubApp::create([
'name' => 'Test GitHub App',
@@ -111,8 +121,10 @@ describe('GET /api/v1/github-apps', function () {
'team_id' => $this->team->id,
]);
$readToken = createGithubAppsApiToken($this, ['read']);
$response = $this->withHeaders([
'Authorization' => 'Bearer '.$this->bearerToken,
'Authorization' => 'Bearer '.$readToken,
])->getJson('/api/v1/github-apps');
$response->assertStatus(200);
@@ -123,6 +135,33 @@ describe('GET /api/v1/github-apps', function () {
expect($json[0])->not->toHaveKey('webhook_secret');
});
test('returns sensitive data for read sensitive tokens', function () {
GithubApp::create([
'name' => 'Sensitive 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-visible',
'webhook_secret' => 'webhook-secret-should-be-visible',
'private_key_id' => $this->privateKey->id,
'team_id' => $this->team->id,
]);
$sensitiveToken = createGithubAppsApiToken($this, ['read', 'read:sensitive']);
$response = $this->withHeaders([
'Authorization' => 'Bearer '.$sensitiveToken,
])->getJson('/api/v1/github-apps');
$response->assertSuccessful();
$response->assertJsonFragment([
'client_secret' => 'secret-should-be-visible',
'webhook_secret' => 'webhook-secret-should-be-visible',
]);
});
test('returns system-wide github apps', function () {
// Create a system-wide GitHub app
$systemApp = GithubApp::create([
@@ -144,7 +183,7 @@ describe('GET /api/v1/github-apps', function () {
$otherUser = User::factory()->create();
$otherTeam->members()->attach($otherUser->id, ['role' => 'owner']);
session(['currentTeam' => $otherTeam]);
$otherToken = $otherUser->createToken('other-token');
$otherToken = $otherUser->createToken('other-token', ['*']);
// System-wide apps should be visible to other teams
$response = $this->withHeaders([
@@ -0,0 +1,46 @@
<?php
use App\Models\Environment;
use App\Models\Project;
use App\Models\Server;
use App\Models\StandalonePostgresql;
use App\Models\Team;
use Illuminate\Foundation\Testing\RefreshDatabase;
uses(RefreshDatabase::class);
/**
* Regression test: EnvironmentVariable hides `value` in serialized output, so the
* config hash must makeVisible('value') before json_encode — otherwise editing an
* env var value no longer flips the hash and no restart is suggested.
*/
it('detects environment variable value change in database config hash', function () {
$team = Team::factory()->create();
$server = Server::factory()->create(['team_id' => $team->id]);
$project = Project::factory()->create(['team_id' => $team->id]);
$environment = Environment::factory()->create(['project_id' => $project->id]);
$destination = $server->standaloneDockers()->firstOrFail();
$database = StandalonePostgresql::create([
'name' => 'config-hash-db',
'postgres_user' => 'postgres',
'postgres_password' => encrypt('password'),
'postgres_db' => 'app',
'image' => 'postgres:16-alpine',
'environment_id' => $environment->id,
'destination_id' => $destination->id,
'destination_type' => $destination->getMorphClass(),
]);
$variable = $database->environment_variables()->create([
'key' => 'APP_SECRET',
'value' => 'original-value',
]);
$database->isConfigurationChanged(save: true);
expect($database->refresh()->isConfigurationChanged())->toBeFalse();
$variable->update(['value' => 'changed-value']);
expect($database->refresh()->isConfigurationChanged())->toBeTrue();
});
File diff suppressed because it is too large. Load diff
+87 -1
View File
@@ -46,8 +46,15 @@ beforeEach(function () {
function createTestApplication($context): Application
{
return Application::factory()->create([
return Application::create([
'name' => 'test-storage-app',
'git_repository' => 'https://github.com/test/test',
'git_branch' => 'main',
'build_pack' => 'nixpacks',
'ports_exposes' => '3000',
'environment_id' => $context->environment->id,
'destination_id' => $context->destination->id,
'destination_type' => $context->destination->getMorphClass(),
]);
}
@@ -65,6 +72,19 @@ function createTestDatabase($context): StandalonePostgresql
]);
}
function createStorageApiToken($context, array $abilities): string
{
$plainTextToken = Str::random(40);
$token = $context->user->tokens()->create([
'name' => 'storage-test-token',
'token' => hash('sha256', $plainTextToken),
'abilities' => $abilities,
'team_id' => $context->team->id,
]);
return $token->getKey().'|'.$plainTextToken;
}
function createTestServiceApplication($context): array
{
$service = Service::factory()->create([
@@ -114,6 +134,39 @@ describe('GET /api/v1/applications/{uuid}/storages', function () {
$response->assertStatus(404);
});
test('hides file storage content for read tokens and reveals it for sensitive tokens', function () {
$app = createTestApplication($this);
LocalFileVolume::create([
'fs_path' => '/tmp/config.json',
'mount_path' => '/app/config.json',
'content' => '{"secret": "hidden"}',
'is_directory' => false,
'resource_id' => $app->id,
'resource_type' => $app->getMorphClass(),
]);
$readToken = createStorageApiToken($this, ['read']);
$sensitiveToken = createStorageApiToken($this, ['read', 'read:sensitive']);
$readResponse = $this->withHeaders([
'Authorization' => 'Bearer '.$readToken,
])->getJson("/api/v1/applications/{$app->uuid}/storages");
auth()->forgetGuards();
$sensitiveResponse = $this->withHeaders([
'Authorization' => 'Bearer '.$sensitiveToken,
])->getJson("/api/v1/applications/{$app->uuid}/storages");
$readResponse->assertSuccessful();
$sensitiveResponse->assertSuccessful();
expect($readResponse->getContent())->not->toContain('"content":')
->and($sensitiveResponse->getContent())->toContain('"content":')
->and($sensitiveResponse->getContent())->toContain('hidden');
});
});
describe('POST /api/v1/applications/{uuid}/storages', function () {
@@ -214,6 +267,39 @@ describe('POST /api/v1/applications/{uuid}/storages', function () {
->exists())->toBeFalse();
});
test('hides created file storage content for write tokens and reveals it for sensitive tokens', function () {
$app = createTestApplication($this);
$writeToken = createStorageApiToken($this, ['write']);
$sensitiveToken = createStorageApiToken($this, ['write', 'read:sensitive']);
$writeResponse = $this->withHeaders([
'Authorization' => 'Bearer '.$writeToken,
'Content-Type' => 'application/json',
])->postJson("/api/v1/applications/{$app->uuid}/storages", [
'type' => 'file',
'mount_path' => '/app/hidden.json',
'content' => '{"secret": "write-hidden"}',
]);
auth()->forgetGuards();
$sensitiveResponse = $this->withHeaders([
'Authorization' => 'Bearer '.$sensitiveToken,
'Content-Type' => 'application/json',
])->postJson("/api/v1/applications/{$app->uuid}/storages", [
'type' => 'file',
'mount_path' => '/app/visible.json',
'content' => '{"secret": "write-visible"}',
]);
$writeResponse->assertCreated();
$sensitiveResponse->assertCreated();
expect($writeResponse->getContent())->not->toContain('"content":')
->and($sensitiveResponse->getContent())->toContain('"content":')
->and($sensitiveResponse->getContent())->toContain('write-visible');
});
test('rejects persistent storage without name', function () {
$app = createTestApplication($this);
@@ -0,0 +1,322 @@
<?php
use App\Models\Application;
use App\Models\ApplicationDeploymentQueue;
use App\Models\CloudInitScript;
use App\Models\CloudProviderToken;
use App\Models\DiscordNotificationSettings;
use App\Models\EmailNotificationSettings;
use App\Models\EnvironmentVariable;
use App\Models\InstanceSettings;
use App\Models\LocalFileVolume;
use App\Models\OauthSetting;
use App\Models\PrivateKey;
use App\Models\PushoverNotificationSettings;
use App\Models\S3Storage;
use App\Models\Server;
use App\Models\ServerSetting;
use App\Models\Service;
use App\Models\SharedEnvironmentVariable;
use App\Models\SlackNotificationSettings;
use App\Models\SslCertificate;
use App\Models\StandaloneClickhouse;
use App\Models\StandaloneDragonfly;
use App\Models\StandaloneKeydb;
use App\Models\StandaloneMariadb;
use App\Models\StandaloneMongodb;
use App\Models\StandaloneMysql;
use App\Models\StandalonePostgresql;
use App\Models\StandaloneRedis;
use App\Models\TelegramNotificationSettings;
use App\Models\WebhookNotificationSettings;
describe('Sensitive model fields are hidden by default', function () {
test('ServerSetting hides sentinel and logdrain secrets', function () {
$hidden = (new ServerSetting)->getHidden();
expect($hidden)->toContain(
'sentinel_token',
'sentinel_custom_url',
'logdrain_newrelic_license_key',
'logdrain_axiom_api_key',
'logdrain_custom_config',
'logdrain_custom_config_parser',
);
});
test('Server hides logdrain api keys', function () {
$hidden = (new Server)->getHidden();
expect($hidden)->toContain(
'logdrain_axiom_api_key',
'logdrain_newrelic_license_key',
);
});
test('Application hides webhook secrets, dockerfile, compose, and labels', function () {
$hidden = (new Application)->getHidden();
expect($hidden)->toContain(
'http_basic_auth_password',
'manual_webhook_secret_github',
'manual_webhook_secret_gitlab',
'manual_webhook_secret_bitbucket',
'manual_webhook_secret_gitea',
'dockerfile',
'docker_compose',
'docker_compose_raw',
'custom_labels',
);
});
test('EnvironmentVariable hides value and real_value', function () {
$hidden = (new EnvironmentVariable)->getHidden();
expect($hidden)->toContain('value', 'real_value');
});
test('SharedEnvironmentVariable hides value', function () {
$hidden = (new SharedEnvironmentVariable)->getHidden();
expect($hidden)->toContain('value');
});
test('Service hides docker_compose and docker_compose_raw', function () {
$hidden = (new Service)->getHidden();
expect($hidden)->toContain('docker_compose', 'docker_compose_raw');
});
test('ApplicationDeploymentQueue hides logs', function () {
$hidden = (new ApplicationDeploymentQueue)->getHidden();
expect($hidden)->toContain('logs');
});
test('LocalFileVolume hides file content', function () {
$hidden = (new LocalFileVolume)->getHidden();
expect($hidden)->toContain('content');
});
test('PrivateKey hides private key material', function () {
$hidden = (new PrivateKey)->getHidden();
expect($hidden)->toContain('private_key');
});
test('CloudProviderToken hides provider token', function () {
$hidden = (new CloudProviderToken)->getHidden();
expect($hidden)->toContain('token');
});
test('CloudInitScript hides script content', function () {
$hidden = (new CloudInitScript)->getHidden();
expect($hidden)->toContain('script');
});
test('S3Storage hides credentials', function () {
$hidden = (new S3Storage)->getHidden();
expect($hidden)->toContain('key', 'secret');
});
test('OauthSetting hides client secret', function () {
$hidden = (new OauthSetting)->getHidden();
expect($hidden)->toContain('client_secret');
});
test('SslCertificate hides private key', function () {
$hidden = (new SslCertificate)->getHidden();
expect($hidden)->toContain('ssl_private_key');
});
test('EmailNotificationSettings hides SMTP and resend secrets', function () {
$hidden = (new EmailNotificationSettings)->getHidden();
expect($hidden)->toContain(
'smtp_from_address',
'smtp_from_name',
'smtp_recipients',
'smtp_host',
'smtp_username',
'smtp_password',
'resend_api_key',
);
});
test('InstanceSettings hides instance notification secrets', function () {
$hidden = (new InstanceSettings)->getHidden();
expect($hidden)->toContain(
'smtp_from_address',
'smtp_from_name',
'smtp_recipients',
'smtp_host',
'smtp_username',
'smtp_password',
'resend_api_key',
'sentinel_token',
);
});
test('Webhook-style notification settings hide delivery endpoints', function () {
expect((new DiscordNotificationSettings)->getHidden())->toContain('discord_webhook_url');
expect((new SlackNotificationSettings)->getHidden())->toContain('slack_webhook_url');
expect((new WebhookNotificationSettings)->getHidden())->toContain('webhook_url');
});
test('PushoverNotificationSettings hides credentials', function () {
$hidden = (new PushoverNotificationSettings)->getHidden();
expect($hidden)->toContain('pushover_user_key', 'pushover_api_token');
});
test('TelegramNotificationSettings hides bot, chat, and thread identifiers', function () {
$hidden = (new TelegramNotificationSettings)->getHidden();
expect($hidden)->toContain(
'telegram_token',
'telegram_chat_id',
'telegram_notifications_deployment_success_thread_id',
'telegram_notifications_deployment_failure_thread_id',
'telegram_notifications_status_change_thread_id',
'telegram_notifications_backup_success_thread_id',
'telegram_notifications_backup_failure_thread_id',
'telegram_notifications_scheduled_task_success_thread_id',
'telegram_notifications_scheduled_task_failure_thread_id',
'telegram_notifications_docker_cleanup_success_thread_id',
'telegram_notifications_docker_cleanup_failure_thread_id',
'telegram_notifications_server_disk_usage_thread_id',
'telegram_notifications_server_reachable_thread_id',
'telegram_notifications_server_unreachable_thread_id',
'telegram_notifications_server_patch_thread_id',
'telegram_notifications_traefik_outdated_thread_id',
);
});
test('TelegramNotificationSettings casts actual docker cleanup thread ids as encrypted', function () {
$casts = (new TelegramNotificationSettings)->getCasts();
expect($casts)->toMatchArray([
'telegram_notifications_docker_cleanup_success_thread_id' => 'encrypted',
'telegram_notifications_docker_cleanup_failure_thread_id' => 'encrypted',
]);
});
test('StandalonePostgresql hides password, init_scripts, db urls', function () {
$hidden = (new StandalonePostgresql)->getHidden();
expect($hidden)->toContain(
'postgres_password',
'init_scripts',
'internal_db_url',
'external_db_url',
);
});
test('StandaloneMysql hides passwords and db urls', function () {
$hidden = (new StandaloneMysql)->getHidden();
expect($hidden)->toContain(
'mysql_password',
'mysql_root_password',
'internal_db_url',
'external_db_url',
);
});
test('StandaloneMariadb hides passwords and db urls', function () {
$hidden = (new StandaloneMariadb)->getHidden();
expect($hidden)->toContain(
'mariadb_password',
'mariadb_root_password',
'internal_db_url',
'external_db_url',
);
});
test('StandaloneMongodb hides root password and db urls', function () {
$hidden = (new StandaloneMongodb)->getHidden();
expect($hidden)->toContain(
'mongo_initdb_root_password',
'internal_db_url',
'external_db_url',
);
});
test('StandaloneRedis hides password and db urls', function () {
$hidden = (new StandaloneRedis)->getHidden();
expect($hidden)->toContain(
'redis_password',
'internal_db_url',
'external_db_url',
);
});
test('StandaloneClickhouse hides password and db urls', function () {
$hidden = (new StandaloneClickhouse)->getHidden();
expect($hidden)->toContain(
'clickhouse_admin_password',
'internal_db_url',
'external_db_url',
);
});
test('StandaloneKeydb hides password and db urls', function () {
$hidden = (new StandaloneKeydb)->getHidden();
expect($hidden)->toContain(
'keydb_password',
'internal_db_url',
'external_db_url',
);
});
test('StandaloneDragonfly hides password and db urls', function () {
$hidden = (new StandaloneDragonfly)->getHidden();
expect($hidden)->toContain(
'dragonfly_password',
'internal_db_url',
'external_db_url',
);
});
});
describe('Sensitive fields are absent from toArray() by default', function () {
test('ServerSetting::toArray() excludes sentinel_custom_url by default', function () {
$setting = new ServerSetting;
$setting->setRawAttributes([
'server_id' => 1,
'sentinel_custom_url' => 'https://secret.example.com',
'wildcard_domain' => 'public.example.com',
], sync: true);
$array = $setting->toArray();
expect($array)->not->toHaveKey('sentinel_custom_url');
expect($array)->toHaveKey('wildcard_domain');
});
test('ServerSetting::toArray() includes sentinel_custom_url after makeVisible', function () {
$setting = new ServerSetting;
$setting->setRawAttributes([
'server_id' => 1,
'sentinel_custom_url' => 'https://secret.example.com',
], sync: true);
$setting->makeVisible(['sentinel_custom_url']);
$array = $setting->toArray();
expect($array['sentinel_custom_url'])->toBe('https://secret.example.com');
});
});