diff --git a/.github/workflows/sync-main-to-next.yml b/.github/workflows/sync-main-to-next.yml index 614175d9b4..595a21e799 100644 --- a/.github/workflows/sync-main-to-next.yml +++ b/.github/workflows/sync-main-to-next.yml @@ -45,15 +45,17 @@ jobs: exit 1 fi - existing_pr=$(gh pr list --base next --head main --state open --json url --jq '.[0].url') + sync_branch='automation/sync-main-to-next' + existing_pr=$(gh pr list --base next --head "$sync_branch" --state open --json url --jq '.[0].url') if [ -n "$existing_pr" ]; then echo "A main to next pull request already exists: $existing_pr" else + git push --force origin origin/main:"refs/heads/$sync_branch" gh pr create \ --base next \ - --head main \ + --head "$sync_branch" \ --title 'chore: merge main into next' \ - --body 'This pull request was created automatically because main could not be merged into next without conflicts.' + --body 'This pull request was created automatically because main could not be merged into next without conflicts. Resolve conflicts on this temporary branch; never update main with next.' fi echo 'main could not be merged into next without conflicts.' diff --git a/app/Http/Controllers/Api/GithubController.php b/app/Http/Controllers/Api/GithubController.php index 5c073e9c0a..840a11f692 100644 --- a/app/Http/Controllers/Api/GithubController.php +++ b/app/Http/Controllers/Api/GithubController.php @@ -15,9 +15,9 @@ use OpenApi\Attributes as OA; class GithubController extends Controller { - private function removeSensitiveData($githubApp) + private function removeSensitiveData(GithubApp $githubApp, int $teamId) { - if (request()->attributes->get('can_read_sensitive', false) === true) { + if (request()->attributes->get('can_read_sensitive', false) === true && $githubApp->team_id === $teamId) { $githubApp->makeVisible([ 'client_secret', 'webhook_secret', @@ -97,8 +97,8 @@ class GithubController extends Controller ->orWhere('is_system_wide', true); })->get(); - $githubApps = $githubApps->map(function ($app) { - return $this->removeSensitiveData($app); + $githubApps = $githubApps->map(function ($app) use ($teamId) { + return $this->removeSensitiveData($app, $teamId); }); return response()->json($githubApps); diff --git a/app/Http/Controllers/Api/GitlabController.php b/app/Http/Controllers/Api/GitlabController.php index c907af46f3..959a3067aa 100644 --- a/app/Http/Controllers/Api/GitlabController.php +++ b/app/Http/Controllers/Api/GitlabController.php @@ -13,9 +13,9 @@ use OpenApi\Attributes as OA; class GitlabController extends Controller { - private function removeSensitiveData(GitlabApp $gitlabApp) + private function removeSensitiveData(GitlabApp $gitlabApp, int $teamId) { - if (request()->attributes->get('can_read_sensitive', false) === true) { + if (request()->attributes->get('can_read_sensitive', false) === true && $gitlabApp->team_id === $teamId) { $gitlabApp->makeVisible([ 'client_secret', 'webhook_token', @@ -108,8 +108,8 @@ class GitlabController extends Controller ->orWhere('is_system_wide', true); })->get(); - $gitlabApps = $gitlabApps->map(function ($app) { - return $this->removeSensitiveData($app); + $gitlabApps = $gitlabApps->map(function ($app) use ($teamId) { + return $this->removeSensitiveData($app, $teamId); }); return response()->json($gitlabApps); @@ -280,7 +280,7 @@ class GitlabController extends Controller 'gitlab_app_name' => $gitlabApp->name, ]); - return response()->json($this->removeSensitiveData($gitlabApp->fresh()), 201); + return response()->json($this->removeSensitiveData($gitlabApp->fresh(), $teamId), 201); } catch (\Throwable $e) { return handleError($e); } @@ -441,7 +441,7 @@ class GitlabController extends Controller return response()->json([ 'message' => 'GitLab app updated successfully', - 'data' => $this->removeSensitiveData($gitlabApp->fresh()), + 'data' => $this->removeSensitiveData($gitlabApp->fresh(), $teamId), ]); } catch (ModelNotFoundException $e) { return response()->json([ diff --git a/app/Http/Controllers/Api/ProjectController.php b/app/Http/Controllers/Api/ProjectController.php index 64bf26c1bb..eb137c5349 100644 --- a/app/Http/Controllers/Api/ProjectController.php +++ b/app/Http/Controllers/Api/ProjectController.php @@ -158,6 +158,8 @@ class ProjectController extends Controller if (! $project) { return response()->json(['message' => 'Project not found.'], 404); } + $this->authorize('view', $project); + $environment = $project->environments()->whereName($request->environment_name_or_uuid)->first(); if (! $environment) { $environment = $project->environments()->whereUuid($request->environment_name_or_uuid)->first(); diff --git a/app/Http/Controllers/Api/ServersController.php b/app/Http/Controllers/Api/ServersController.php index d50a5226a9..f7966c71f1 100644 --- a/app/Http/Controllers/Api/ServersController.php +++ b/app/Http/Controllers/Api/ServersController.php @@ -550,11 +550,7 @@ class ServersController extends Controller } $foundServer = ModelsServer::whereIp($request->ip)->first(); if ($foundServer) { - if ($foundServer->team_id === $teamId) { - return response()->json(['message' => 'A server with this IP/Domain already exists in your team.'], 400); - } - - return response()->json(['message' => 'A server with this IP/Domain is already in use by another team.'], 400); + return response()->json(['message' => 'A server with this IP/Domain is already in use.'], 400); } $proxyType = $request->proxy_type ? str($request->proxy_type)->upper() : ProxyTypes::TRAEFIK->value; diff --git a/app/Http/Controllers/Api/TeamController.php b/app/Http/Controllers/Api/TeamController.php index 35e01c8314..b9f8572673 100644 --- a/app/Http/Controllers/Api/TeamController.php +++ b/app/Http/Controllers/Api/TeamController.php @@ -56,7 +56,7 @@ class TeamController extends Controller if (is_null($teamId)) { return invalidTokenResponse(); } - $teams = auth()->user()->teams->sortBy('id'); + $teams = auth()->user()->teams->where('id', $teamId)->values(); $teams = $teams->map(function ($team) { return $this->removeSensitiveData($team); }); @@ -100,13 +100,14 @@ class TeamController extends Controller )] public function team_by_id(Request $request) { - $id = $request->id; $teamId = getTeamIdFromToken(); if (is_null($teamId)) { return invalidTokenResponse(); } - $teams = auth()->user()->teams; - $team = $teams->where('id', $id)->first(); + if ((int) $request->id !== (int) $teamId) { + return response()->json(['message' => 'Team not found.'], 404); + } + $team = auth()->user()->teams->where('id', $teamId)->first(); if (is_null($team)) { return response()->json(['message' => 'Team not found.'], 404); } @@ -159,13 +160,14 @@ class TeamController extends Controller )] public function members_by_id(Request $request) { - $id = $request->id; $teamId = getTeamIdFromToken(); if (is_null($teamId)) { return invalidTokenResponse(); } - $teams = auth()->user()->teams; - $team = $teams->where('id', $id)->first(); + if ((int) $request->id !== (int) $teamId) { + return response()->json(['message' => 'Team not found.'], 404); + } + $team = auth()->user()->teams->where('id', $teamId)->first(); if (is_null($team)) { return response()->json(['message' => 'Team not found.'], 404); } diff --git a/app/Livewire/Project/Shared/ScheduledTask/Add.php b/app/Livewire/Project/Shared/ScheduledTask/Add.php index 2d6b76c25f..61bc6b0fbc 100644 --- a/app/Livewire/Project/Shared/ScheduledTask/Add.php +++ b/app/Livewire/Project/Shared/ScheduledTask/Add.php @@ -2,7 +2,10 @@ namespace App\Livewire\Project\Shared\ScheduledTask; +use App\Models\Application; use App\Models\ScheduledTask; +use App\Models\Service; +use App\Models\StandalonePostgresql; use Illuminate\Foundation\Auth\Access\AuthorizesRequests; use Illuminate\Support\Collection; use Livewire\Attributes\Locked; @@ -59,13 +62,13 @@ class Add extends Component // Get the resource based on type and id switch ($this->type) { case 'application': - $this->resource = \App\Models\Application::findOrFail($this->id); + $this->resource = Application::ownedByCurrentTeam()->findOrFail($this->id); break; case 'service': - $this->resource = \App\Models\Service::findOrFail($this->id); + $this->resource = Service::ownedByCurrentTeam()->findOrFail($this->id); break; case 'standalone-postgresql': - $this->resource = \App\Models\StandalonePostgresql::findOrFail($this->id); + $this->resource = StandalonePostgresql::ownedByCurrentTeam()->findOrFail($this->id); break; default: throw new \Exception('Invalid resource type'); diff --git a/app/Livewire/Server/DockerCleanupExecutions.php b/app/Livewire/Server/DockerCleanupExecutions.php index 56d6130644..6a739bc84c 100644 --- a/app/Livewire/Server/DockerCleanupExecutions.php +++ b/app/Livewire/Server/DockerCleanupExecutions.php @@ -2,7 +2,6 @@ namespace App\Livewire\Server; -use App\Models\DockerCleanupExecution; use App\Models\Server; use Illuminate\Support\Collection; use Livewire\Component; @@ -46,7 +45,7 @@ class DockerCleanupExecutions extends Component ->get(); if ($this->selectedKey) { - $this->selectedExecution = DockerCleanupExecution::find($this->selectedKey); + $this->selectedExecution = $this->server->dockerCleanupExecutions()->find($this->selectedKey); if ($this->selectedExecution && $this->selectedExecution->status !== 'running') { $this->isPollingActive = false; } @@ -64,7 +63,7 @@ class DockerCleanupExecutions extends Component return; } $this->selectedKey = $key; - $this->selectedExecution = DockerCleanupExecution::find($key); + $this->selectedExecution = $this->server->dockerCleanupExecutions()->find($key); $this->currentPage = 1; if ($this->selectedExecution && $this->selectedExecution->status === 'running') { diff --git a/app/Services/DeploymentConfiguration/ConfigurationDiffer.php b/app/Services/DeploymentConfiguration/ConfigurationDiffer.php index 9833b5be45..ace4888338 100644 --- a/app/Services/DeploymentConfiguration/ConfigurationDiffer.php +++ b/app/Services/DeploymentConfiguration/ConfigurationDiffer.php @@ -17,28 +17,8 @@ class ConfigurationDiffer */ private const IGNORED_KEYS = ['build.docker_compose']; - /** - * Defaults for fields introduced after configuration snapshots were first - * stored. Older snapshots omitted these keys, which should not make an - * unchanged default look like a pending configuration change. - * - * @var array - */ - private const INTRODUCED_DEFAULTS = [ - 'build.is_static' => false, - 'build.is_spa' => false, - 'build.is_git_submodules_enabled' => true, - 'build.is_git_lfs_enabled' => true, - 'build.is_git_shallow_clone_enabled' => true, - 'build.is_env_sorting_enabled' => [false, true], - 'runtime.is_consistent_container_name_enabled' => false, - 'runtime.is_container_label_escape_enabled' => true, - 'runtime.is_container_label_readonly_enabled' => true, - 'runtime.is_log_drain_enabled' => false, - 'runtime.is_swarm_only_worker_nodes' => true, - 'runtime.is_preserve_repository_enabled' => false, - 'domains.noindex_domains' => [], - ]; + /** @var array */ + private const DYNAMIC_SECTIONS = ['environment', 'storage']; /** * @param array $previousSnapshot @@ -59,11 +39,7 @@ class ConfigurationDiffer $previous = $previousItems[$key] ?? null; $current = $currentItems[$key] ?? null; - if ( - $previous === null - && array_key_exists($key, self::INTRODUCED_DEFAULTS) - && $this->matchesIntroducedDefault($key, data_get($current, 'compare_value')) - ) { + if ($previous === null && ! in_array(data_get($current, 'section'), self::DYNAMIC_SECTIONS, true)) { continue; } @@ -127,17 +103,6 @@ class ConfigurationDiffer return ConfigurationDiff::fromChanges($changes); } - private function matchesIntroducedDefault(string $key, mixed $value): bool - { - $default = self::INTRODUCED_DEFAULTS[$key]; - - if (is_array($default) && $default !== [] && array_is_list($default)) { - return in_array($value, $default, true); - } - - return $value === $default; - } - /** * Reduce two multi-line values to only the lines that differ, so the modal * shows just the changed container labels instead of the whole block. diff --git a/app/Support/DomainUrlParts.php b/app/Support/DomainUrlParts.php index 2e6da7868c..c86d5fa9a9 100644 --- a/app/Support/DomainUrlParts.php +++ b/app/Support/DomainUrlParts.php @@ -4,11 +4,11 @@ namespace App\Support; class DomainUrlParts { - public static function compose(string $scheme, string $host, string $port = '', string $path = ''): string + public static function compose(string $scheme, string $host, ?string $port = '', string $path = ''): string { $scheme = strtolower(trim($scheme)) === 'http' ? 'http' : 'https'; $host = trim($host); - $port = trim($port); + $port = trim((string) $port); $path = trim($path); if ($path !== '' && ! str_starts_with($path, '/') && ! str_starts_with($path, '?') && ! str_starts_with($path, '#')) { diff --git a/resources/views/components/modal-confirmation.blade.php b/resources/views/components/modal-confirmation.blade.php index f9b8bd98de..d0778dce4f 100644 --- a/resources/views/components/modal-confirmation.blade.php +++ b/resources/views/components/modal-confirmation.blade.php @@ -325,12 +325,15 @@ step++; } else { submitting = true; - submitForm().then((result) => { - submitting = false; - modalOpen = false; - resetModal(); - }).catch(() => { - submitting = false; + modalOpen = false; + $nextTick(() => { + submitForm().then((result) => { + submitting = false; + resetModal(); + }).catch(() => { + submitting = false; + modalOpen = true; + }); }); } "> @@ -379,17 +382,21 @@ $wire.dispatch(dispatchEventType, dispatchEventMessage); } submitting = true; - submitForm().then((result) => { - submitting = false; - if (result === true) { - modalOpen = false; - resetModal(); - } else { - passwordError = result; - password = ''; - } - }).catch(() => { - submitting = false; + modalOpen = false; + $nextTick(() => { + submitForm().then((result) => { + submitting = false; + if (result === true) { + resetModal(); + } else { + modalOpen = true; + passwordError = result; + password = ''; + } + }).catch(() => { + submitting = false; + modalOpen = true; + }); }); "> diff --git a/tests/Feature/Api/GithubAppsListApiTest.php b/tests/Feature/Api/GithubAppsListApiTest.php index 9a1f1f3d25..cb15cd9347 100644 --- a/tests/Feature/Api/GithubAppsListApiTest.php +++ b/tests/Feature/Api/GithubAppsListApiTest.php @@ -197,6 +197,39 @@ describe('GET /api/v1/github-apps', function () { ]); }); + test('does not return system-wide github app secrets owned by another team', function () { + $otherTeam = Team::factory()->create(); + $otherPrivateKey = PrivateKey::create([ + 'name' => 'System Key', + 'private_key' => validGithubAppsApiPrivateKey(), + 'team_id' => $otherTeam->id, + ]); + GithubApp::create([ + 'name' => 'Foreign 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' => 'foreign-client-secret', + 'webhook_secret' => 'foreign-webhook-secret', + 'private_key_id' => $otherPrivateKey->id, + 'team_id' => $otherTeam->id, + 'is_system_wide' => true, + ]); + + $sensitiveToken = createGithubAppsApiToken($this, ['read', 'read:sensitive']); + + $response = $this->withToken($sensitiveToken) + ->getJson('/api/v1/github-apps') + ->assertSuccessful() + ->assertJsonFragment(['name' => 'Foreign System GitHub App']); + + expect($response->json('0')) + ->not->toHaveKey('client_secret') + ->not->toHaveKey('webhook_secret'); + }); + test('does not return other teams github apps', function () { // Create a GitHub app for this team GithubApp::create([ diff --git a/tests/Feature/Api/GitlabAppsApiTest.php b/tests/Feature/Api/GitlabAppsApiTest.php index 65332c0dd6..f18829c205 100644 --- a/tests/Feature/Api/GitlabAppsApiTest.php +++ b/tests/Feature/Api/GitlabAppsApiTest.php @@ -64,6 +64,36 @@ describe('GET /api/v1/gitlab-apps', function () { expect($response->json('0'))->not->toHaveKey('client_secret') ->and($response->json('0'))->not->toHaveKey('webhook_token'); }); + + test('does not return system-wide gitlab app secrets owned by another team', function () { + $otherTeam = Team::factory()->create(); + GitlabApp::create([ + 'name' => 'Foreign System GitLab', + 'api_url' => 'https://gitlab.com/api/v4', + 'html_url' => 'https://gitlab.com', + 'client_id' => 'foreign-client-id', + 'client_secret' => 'foreign-client-secret', + 'webhook_token' => 'foreign-webhook-token', + 'access_token' => 'foreign-access-token', + 'refresh_token' => 'foreign-refresh-token', + 'team_id' => $otherTeam->id, + 'is_system_wide' => true, + ]); + + session(['currentTeam' => $this->team]); + $sensitiveToken = $this->user->createToken('sensitive-token', ['read', 'read:sensitive'])->plainTextToken; + + $response = $this->withToken($sensitiveToken) + ->getJson('/api/v1/gitlab-apps') + ->assertSuccessful() + ->assertJsonFragment(['name' => 'Foreign System GitLab']); + + expect($response->json('0')) + ->not->toHaveKey('client_secret') + ->not->toHaveKey('webhook_token') + ->not->toHaveKey('access_token') + ->not->toHaveKey('refresh_token'); + }); }); describe('POST /api/v1/gitlab-apps', function () { diff --git a/tests/Feature/Api/TeamTokenTeamApiTest.php b/tests/Feature/Api/TeamTokenTeamApiTest.php index 4f45562ea5..447579e294 100644 --- a/tests/Feature/Api/TeamTokenTeamApiTest.php +++ b/tests/Feature/Api/TeamTokenTeamApiTest.php @@ -8,6 +8,9 @@ use Illuminate\Foundation\Testing\RefreshDatabase; uses(RefreshDatabase::class); beforeEach(function () { + config()->set('app.maintenance.driver', 'file'); + config()->set('cache.default', 'array'); + InstanceSettings::unguarded(fn () => InstanceSettings::updateOrCreate(['id' => 0], ['is_api_enabled' => true])); $this->team = Team::factory()->create(['name' => 'Token Team']); @@ -27,6 +30,27 @@ function teamTokenApiHeaders(string $bearerToken): array } describe('token team endpoints', function () { + test('legacy team endpoints are restricted to the token team', function () { + $otherTeam = Team::factory()->create(['name' => 'Other Team']); + $otherMember = User::factory()->create(); + $otherTeam->members()->attach($this->user->id, ['role' => 'owner']); + $otherTeam->members()->attach($otherMember->id, ['role' => 'member']); + + $this->withHeaders(teamTokenApiHeaders($this->bearerToken)) + ->getJson('/api/v1/teams') + ->assertOk() + ->assertJsonCount(1) + ->assertJsonPath('0.id', $this->team->id); + + $this->withHeaders(teamTokenApiHeaders($this->bearerToken)) + ->getJson("/api/v1/teams/{$otherTeam->id}") + ->assertNotFound(); + + $this->withHeaders(teamTokenApiHeaders($this->bearerToken)) + ->getJson("/api/v1/teams/{$otherTeam->id}/members") + ->assertNotFound(); + }); + test('GET /team returns the token team', function () { $this->withHeaders(teamTokenApiHeaders($this->bearerToken)) ->getJson('/api/v1/team') diff --git a/tests/Feature/ModalScrollLockTest.php b/tests/Feature/ModalScrollLockTest.php index 7863325a20..17d293519d 100644 --- a/tests/Feature/ModalScrollLockTest.php +++ b/tests/Feature/ModalScrollLockTest.php @@ -7,3 +7,11 @@ test('confirmation modal closes before dispatching an event that can open anothe '/if \(dispatchEvent\) \{\s*modalOpen = false;\s*\$nextTick\(\(\) => \$wire\.dispatch\(dispatchEventType, dispatchEventMessage\)\);/s' ); }); + +test('confirmation modal releases its scroll lock before submitting a destructive action', function () { + $modal = file_get_contents(resource_path('views/components/modal-confirmation.blade.php')); + + expect($modal) + ->toMatch('/submitting = true;\s*modalOpen = false;\s*\$nextTick\(\(\) => \{\s*submitForm\(\)/s') + ->toMatch('/if \(result === true\) \{\s*resetModal\(\);/s'); +}); diff --git a/tests/Feature/ResourceAccessConsistencyTest.php b/tests/Feature/ResourceAccessConsistencyTest.php new file mode 100644 index 0000000000..2511e14342 --- /dev/null +++ b/tests/Feature/ResourceAccessConsistencyTest.php @@ -0,0 +1,120 @@ + 'file']); + + InstanceSettings::forceCreate(['id' => 0, 'is_api_enabled' => true]); + + $this->team = Team::factory()->create(); + $this->user = User::factory()->create(); + $this->user->teams()->attach($this->team, ['role' => 'owner']); + + $this->otherTeam = Team::factory()->create(); + + session(['currentTeam' => $this->team]); + + $this->privateKey = PrivateKey::withoutEvents(fn () => PrivateKey::forceCreate([ + 'uuid' => (string) Str::uuid(), + 'name' => 'IDOR test key', + 'private_key' => 'test-private-key', + 'team_id' => $this->team->id, + ])); + + $token = $this->user->createToken('idor-hardening', ['*']); + $token->accessToken->forceFill(['team_id' => $this->team->id])->save(); + $this->token = $token->plainTextToken; +}); + +test('server creation returns a consistent duplicate address response', function () { + $ownServer = Server::factory()->create([ + 'ip' => '192.0.2.10', + 'team_id' => $this->team->id, + ]); + $otherServer = Server::factory()->create([ + 'ip' => '192.0.2.20', + 'team_id' => $this->otherTeam->id, + ]); + + $payload = fn (Server $server): array => [ + 'name' => 'Duplicate server', + 'ip' => $server->ip, + 'private_key_uuid' => $this->privateKey->uuid, + 'user' => 'root', + ]; + + $ownResponse = $this->withToken($this->token)->postJson('/api/v1/servers', $payload($ownServer)); + $otherResponse = $this->withToken($this->token)->postJson('/api/v1/servers', $payload($otherServer)); + + $ownResponse->assertBadRequest(); + $otherResponse->assertBadRequest(); + expect($ownResponse->json('message')) + ->toBe('A server with this IP/Domain is already in use.') + ->toBe($otherResponse->json('message')); +}); + +test('environment details applies the project view policy', function () { + $project = Project::factory()->create(['team_id' => $this->team->id]); + $environment = Environment::factory()->create(['project_id' => $project->id]); + + Gate::before(fn (User $user, string $ability): ?bool => $ability === 'view' ? false : null); + + $this->withToken($this->token) + ->getJson("/api/v1/projects/{$project->uuid}/{$environment->uuid}") + ->assertForbidden(); +}); + +test('docker cleanup execution selection only uses the mounted server', function () { + $this->actingAs($this->user); + + $server = Server::factory()->create(['team_id' => $this->team->id]); + $otherServer = Server::factory()->create(['team_id' => $this->otherTeam->id]); + $otherExecution = DockerCleanupExecution::create([ + 'server_id' => $otherServer->id, + 'status' => 'success', + 'message' => 'other team cleanup output', + ]); + + Livewire::test(DockerCleanupExecutions::class, ['server' => $server]) + ->call('selectExecution', $otherExecution->id) + ->assertSet('selectedExecution', null); +}); + +test('scheduled task form only mounts applications from the current team', function () { + $this->actingAs($this->user); + + $server = Server::factory()->create(['team_id' => $this->otherTeam->id]); + $destination = StandaloneDocker::where('server_id', $server->id)->firstOrFail(); + $project = Project::factory()->create(['team_id' => $this->otherTeam->id]); + $environment = Environment::factory()->create(['project_id' => $project->id]); + $application = Application::factory()->create([ + 'environment_id' => $environment->id, + 'destination_id' => $destination->id, + 'destination_type' => $destination->getMorphClass(), + ]); + + Livewire::test(Add::class, [ + 'id' => (string) $application->id, + 'type' => 'application', + 'containerNames' => collect(), + ]); +})->throws(ModelNotFoundException::class); diff --git a/tests/Unit/DeploymentConfiguration/ApplicationConfigurationSnapshotTest.php b/tests/Unit/DeploymentConfiguration/ApplicationConfigurationSnapshotTest.php index 1b106a5a82..140be57643 100644 --- a/tests/Unit/DeploymentConfiguration/ApplicationConfigurationSnapshotTest.php +++ b/tests/Unit/DeploymentConfiguration/ApplicationConfigurationSnapshotTest.php @@ -296,7 +296,26 @@ it('accepts the historical environment sorting default in older snapshots', func expect(app(ConfigurationDiffer::class)->diff($previousSnapshot, $currentSnapshot)->isChanged())->toBeFalse(); }); -it('detects environment variable value changes for unlocked variables', function () { +it('does not report newly tracked static configuration as a pending change', function () { + $application = snapshotTestApplication(); + $currentSnapshot = $application->deploymentConfigurationSnapshot(); + $previousSnapshot = $currentSnapshot; + + data_set($currentSnapshot, 'sections.runtime.items', [ + ...data_get($currentSnapshot, 'sections.runtime.items'), + [ + 'key' => 'newly_tracked_setting', + 'label' => 'Newly tracked setting', + 'impact' => 'redeploy', + 'compare_value' => 'already configured', + 'display_value' => 'already configured', + ], + ]); + + expect(app(ConfigurationDiffer::class)->diff($previousSnapshot, $currentSnapshot)->isChanged())->toBeFalse(); +}); + +it('detects environment variable value changes without exposing secret values', function () { $application = snapshotTestApplication(); EnvironmentVariable::create([ 'key' => 'API_TOKEN', diff --git a/tests/Unit/DomainUrlPartsTest.php b/tests/Unit/DomainUrlPartsTest.php index 086599a1a2..5c2994934c 100644 --- a/tests/Unit/DomainUrlPartsTest.php +++ b/tests/Unit/DomainUrlPartsTest.php @@ -28,3 +28,8 @@ it('defaults empty values for an invalid URL', function () { it('preserves an explicitly configured default port', function () { expect(DomainUrlParts::split('https://app.example.com:443')['port'])->toBe('443'); }); + +it('composes a domain when Livewire hydrates an empty numeric port as null', function () { + expect(DomainUrlParts::compose('https', 'app.example.com', null)) + ->toBe('https://app.example.com'); +});