');
diff --git a/tests/Feature/Security/CloudInitScriptsTest.php b/tests/Feature/Security/CloudInitScriptsTest.php
index e161d6aa4..44b0b10af 100644
--- a/tests/Feature/Security/CloudInitScriptsTest.php
+++ b/tests/Feature/Security/CloudInitScriptsTest.php
@@ -42,7 +42,7 @@ test('cloud-init script form does not show a cancel button in the modal', functi
->assertDontSee('Cancel');
});
-test('cloud-init script cards link to the script detail page without inline actions or created time', function () {
+test('cloud-init script cards open the modal editor without inline actions or created time', function () {
$script = CloudInitScript::query()->create([
'team_id' => $this->team->id,
'name' => 'Docker Host Setup',
@@ -51,10 +51,10 @@ test('cloud-init script cards link to the script detail page without inline acti
Livewire::test(CloudInitScripts::class)
->assertSee('Docker Host Setup')
- ->assertSee(route('security.cloud-init-scripts.show', ['cloud_init_script_uuid' => $script->uuid]), false)
+ ->assertSee('Edit Cloud-Init Script')
+ ->assertDontSee(route('security.cloud-init-scripts.show', ['cloud_init_script_uuid' => $script->uuid]), false)
->assertDontSee('Created')
- ->assertDontSee('Edit')
- ->assertDontSee('Delete');
+ ->assertSee('Delete');
});
test('cloud-init script detail page shows editable script fields', function () {
diff --git a/tests/Feature/Security/CloudProviderTokenFormTest.php b/tests/Feature/Security/CloudProviderTokenFormTest.php
index 666a9b120..caea0c4d8 100644
--- a/tests/Feature/Security/CloudProviderTokenFormTest.php
+++ b/tests/Feature/Security/CloudProviderTokenFormTest.php
@@ -55,6 +55,29 @@ test('adding a digitalocean token from a modal closes the modal and refreshes di
&& data_get($dispatch, 'to') === 'security.cloud-provider-tokens'))->toBeTrue();
});
+test('security cloud token form lets users choose every supported provider', function () {
+ Livewire::test(CloudProviderTokenForm::class)
+ ->assertSee('Provider')
+ ->assertSee('Hetzner')
+ ->assertSee('DigitalOcean')
+ ->assertSee('Vultr');
+});
+
+test('cloud provider help link reacts to provider selection without a live request', function () {
+ $view = file_get_contents(resource_path('views/livewire/security/cloud-provider-token-form.blade.php'));
+
+ expect($view)
+ ->toContain("selectedProvider: \$wire.entangle('provider')")
+ ->toContain(':wire="false"')
+ ->toContain('x-model="selectedProvider"')
+ ->toContain(':href="providerConsoleUrl"')
+ ->toContain('x-text="providerName + \' console\'"')
+ ->not->toContain('wire:model.live="provider"');
+
+ expect(strpos($view, ':href="providerConsoleUrl"'))
+ ->toBeLessThan(strpos($view, ''));
+});
+
test('adding a cloud provider token stores an optional description', function () {
Http::fake([
'https://api.hetzner.cloud/v1/servers' => Http::response([], 200),
diff --git a/tests/Feature/Security/CloudProviderTokenShowTest.php b/tests/Feature/Security/CloudProviderTokenShowTest.php
index cb527913f..c3a1ce082 100644
--- a/tests/Feature/Security/CloudProviderTokenShowTest.php
+++ b/tests/Feature/Security/CloudProviderTokenShowTest.php
@@ -30,14 +30,16 @@ beforeEach(function () {
$this->actingAs($this->user);
});
-test('saved cloud token cards link to the token detail page', function () {
+test('saved cloud token rows render frontend-only modal editors', function () {
$token = CloudProviderToken::factory()->create([
'team_id' => $this->team->id,
'name' => 'Production Hetzner',
]);
Livewire::test(CloudProviderTokens::class)
- ->assertSee(route('security.cloud-tokens.show', ['cloud_token_uuid' => $token->uuid]), false);
+ ->assertSee('Edit Cloud Token')
+ ->assertSee('Production Hetzner')
+ ->assertDontSee(route('security.cloud-tokens.show', ['cloud_token_uuid' => $token->uuid]), false);
});
test('cloud token detail page shows editable name and description fields', function () {
diff --git a/tests/Feature/Security/PrivateKeyDropdownTest.php b/tests/Feature/Security/PrivateKeyDropdownTest.php
index 5dfeb31ab..9c19c7792 100644
--- a/tests/Feature/Security/PrivateKeyDropdownTest.php
+++ b/tests/Feature/Security/PrivateKeyDropdownTest.php
@@ -39,14 +39,14 @@ beforeEach(function () {
test('private key index shows highlighted add dropdown actions', function () {
Livewire::test(Index::class)
- ->assertSee('+ Add')
+ ->assertSee('New private key')
->assertSee('Generate ED25519')
->assertSee('Generate RSA')
->assertSee('Add manually')
->assertDontSee('Manage your SSH keys for your servers and integrations.');
});
-test('generating a private key from the index stores it and redirects to details', function () {
+test('generating a private key from the index stores it without redirecting to a detail page', function () {
$component = Livewire::test(Index::class)
->call('generatePrivateKey', 'ed25519');
@@ -55,9 +55,9 @@ test('generating a private key from the index stores it and redirects to details
expect($privateKey->team_id)->toBe($this->team->id)
->and($privateKey->public_key)->toStartWith('ssh-ed25519');
- $component->assertRedirect(route('security.private-key.show', [
- 'private_key_uuid' => $privateKey->uuid,
- ]));
+ $component
+ ->assertDispatched('success')
+ ->assertNoRedirect();
});
test('manual private key form does not expose key generation controls', function () {
diff --git a/tests/Feature/SecurityResourceModalEditorsTest.php b/tests/Feature/SecurityResourceModalEditorsTest.php
new file mode 100644
index 000000000..b2cb0a588
--- /dev/null
+++ b/tests/Feature/SecurityResourceModalEditorsTest.php
@@ -0,0 +1,84 @@
+ InstanceSettings::query()->create(['id' => 0]));
+ Once::flush();
+
+ $team = Team::factory()->create();
+ $user = User::factory()->create();
+ $team->members()->attach($user->id, ['role' => 'owner']);
+
+ $this->actingAs($user);
+ session(['currentTeam' => $team]);
+ $this->team = $team;
+});
+
+it('opens security resources in modal editors and keeps create actions in card headers', function () {
+ $views = [
+ resource_path('views/livewire/security/private-key/index.blade.php'),
+ resource_path('views/livewire/security/cloud-provider-tokens.blade.php'),
+ resource_path('views/livewire/security/cloud-init-scripts.blade.php'),
+ ];
+
+ foreach ($views as $view) {
+ $contents = file_get_contents($view);
+
+ expect($contents)
+ ->toContain('toContain('')
+ ->toContain('toContain(':contentClicks="false"')
+ ->toContain('@click="modalOpen=true"')
+ ->not->toContain('wire:click="openEditor(')
+ ->not->toContain('href="{{ route(\'security.');
+ }
+
+ expect(file_get_contents($views[0]))->toContain('>Private key
', '>Status');
+ expect(file_get_contents($views[1]))->toContain('>Token', '>Provider');
+ expect(file_get_contents($views[2]))->toContain('>Script', '>Last updated');
+
+ expect(substr_count(file_get_contents($views[0]), 'sm:grid-cols-[minmax(0,1.2fr)_minmax(0,1fr)_7rem_1.75rem]'))->toBeGreaterThanOrEqual(2);
+ expect(substr_count(file_get_contents($views[1]), 'sm:grid-cols-[minmax(0,1fr)_8rem_minmax(0,1fr)_1.75rem]'))->toBeGreaterThanOrEqual(2);
+ expect(substr_count(file_get_contents($views[2]), 'grid-cols-[minmax(0,1fr)_12rem_1.75rem]'))->toBeGreaterThanOrEqual(2);
+ expect(file_get_contents($views[2]))
+ ->toContain('Last updated
')
+ ->toContain('')
+ ->not->toContain('
Last updated
');
+
+ foreach ($views as $view) {
+ expect(file_get_contents($view))
+ ->toContain('grid-cols-[')
+ ->toContain('items-center gap-3')
+ ->toContain('class="pl-11"')
+ ->toContain('text-[13px] font-medium');
+ }
+});
+
+it('deletes a cloud-init script from its modal editor without redirecting to a detail page', function () {
+ $script = CloudInitScript::query()->create([
+ 'team_id' => $this->team->id,
+ 'name' => 'Docker host',
+ 'script' => "#cloud-config\npackages:\n - curl\n",
+ ]);
+
+ Livewire::test(CloudInitScriptShow::class, [
+ 'cloud_init_script_uuid' => $script->uuid,
+ 'modalMode' => true,
+ ])->call('delete')
+ ->assertDispatched('securityResourceChanged')
+ ->assertDispatched('close-modal');
+
+ $this->assertModelMissing($script);
+});
diff --git a/tests/Feature/SecuritySettingsIconsTest.php b/tests/Feature/SecuritySettingsIconsTest.php
new file mode 100644
index 000000000..6bf9da201
--- /dev/null
+++ b/tests/Feature/SecuritySettingsIconsTest.php
@@ -0,0 +1,15 @@
+toContain("'label' => 'Private Keys'", "'icon' => 'keys'")
+ ->toContain("'label' => 'Cloud Tokens'", "'icon' => 'cloud'")
+ ->toContain("'label' => 'Cloud-Init Scripts'", "'icon' => 'file-content'")
+ ->toContain("'label' => 'API Tokens'", "'icon' => 'code'")
+ ->and($icons)
+ ->toContain("'cloud' =>")
+ ->toContain("'code' =>");
+});
diff --git a/tests/Feature/SecuritySettingsNavigationTest.php b/tests/Feature/SecuritySettingsNavigationTest.php
new file mode 100644
index 000000000..e89bf8093
--- /dev/null
+++ b/tests/Feature/SecuritySettingsNavigationTest.php
@@ -0,0 +1,31 @@
+toContain('
')
+ ->not->toContain('toContain('application-settings-navigation')
+ ->toContain("'label' => 'Private Keys'")
+ ->toContain("'label' => 'Cloud Tokens'")
+ ->toContain("'label' => 'Cloud-Init Scripts'")
+ ->toContain("'label' => 'API Tokens'");
+
+ expect($navbar)
+ ->toContain("request()->routeIs('security.*')")
+ ->not->toContain("['label' => 'API Tokens', 'route' => 'security.api-tokens'");
+});
diff --git a/tests/Feature/ServiceComposeResourcesViewSwitcherTest.php b/tests/Feature/ServiceComposeResourcesViewSwitcherTest.php
new file mode 100644
index 000000000..e5bc753ca
--- /dev/null
+++ b/tests/Feature/ServiceComposeResourcesViewSwitcherTest.php
@@ -0,0 +1,28 @@
+toContain("localStorage.getItem('service-compose-resources-view') || 'table'")
+ ->toContain("setViewMode('table')")
+ ->toContain("setViewMode('grid')")
+ ->toContain('aria-label="Table view"')
+ ->toContain('aria-label="Grid view"')
+ ->toContain("localStorage.setItem('service-compose-resources-view', mode)")
+ ->not->toContain('>Sort')
+ ->and($resourceCard)
+ ->toContain("x-show=\"viewMode === 'grid'\"")
+ ->toContain("x-show=\"viewMode === 'table'\"");
+});
+
+it('directs compose application domain management to the parent service', function () {
+ $resourceSettings = file_get_contents(resource_path('views/livewire/project/service/index.blade.php'));
+
+ expect($resourceSettings)
+ ->toContain('Manage domains, DNS checks, and redirects on the parent service')
+ ->toContain("route('project.service.domains', \$parameters)")
+ ->toContain('Manage domains')
+ ->not->toContain('toContain('')
+ ->toContain('')
->toContain('settings-section title="Sender"')
->toContain('settings-email-send-test')
- // Self-closing navbar means no actions slot is passed into the tab bar.
- ->not->toContain('');
+ ->not->toContain('toContain("{{ \$oauth_setting['enabled'] ? 'Disable' : 'Enable' }}")
+ ->toContain('x-data="{ enabled: @js((bool) $oauth_setting[\'enabled\']), provider: @js($provider) }"')
+ ->toContain('invalidField.reportValidity()')
+ ->toContain('$wire.toggleProvider(provider)')
+ ->toContain('label="Client ID" required')
+ ->toContain('autocomplete="new-password" required')
+ ->not->toContain('label="Provider status"');
+});
+
+it('requires the provider-specific fields used by oauth enablement', function () {
+ $component = new SettingsOauth;
+ $method = new ReflectionMethod($component, 'providerRules');
+
+ expect($method->invoke($component, 'github'))->toBe([
+ 'oauth_settings_map.github.client_id' => 'required',
+ 'oauth_settings_map.github.client_secret' => 'required',
+ ])->and($method->invoke($component, 'azure'))->toHaveKeys([
+ 'oauth_settings_map.azure.client_id',
+ 'oauth_settings_map.azure.client_secret',
+ 'oauth_settings_map.azure.tenant',
+ ])->and($method->invoke($component, 'authentik'))->toHaveKeys([
+ 'oauth_settings_map.authentik.client_id',
+ 'oauth_settings_map.authentik.client_secret',
+ 'oauth_settings_map.authentik.base_url',
+ ]);
+});
diff --git a/tests/Feature/SettingsScheduledJobsRefreshPlacementTest.php b/tests/Feature/SettingsScheduledJobsRefreshPlacementTest.php
index e53f58f69..c4728a95b 100644
--- a/tests/Feature/SettingsScheduledJobsRefreshPlacementTest.php
+++ b/tests/Feature/SettingsScheduledJobsRefreshPlacementTest.php
@@ -4,11 +4,10 @@ test('scheduled jobs refresh control lives on the activity section not the navba
$view = file_get_contents(resource_path('views/livewire/settings/scheduled-jobs.blade.php'));
expect($view)
- ->toContain('')
+ ->toContain('')
->toContain('settings-section title="Scheduler activity"')
->toContain('wire:click="refresh"')
- // Self-closing navbar means Refresh is not passed as a tab-bar action.
- ->not->toContain('');
+ ->not->toContain('toContain('align-self: start')
->toContain('position: sticky')
- ->toContain('top: 3.5rem')
- ->toContain('max-height: calc(100dvh - 4.25rem)')
+ ->toContain('top: calc(3rem + 1.75rem)')
+ ->toContain('max-height: calc(100dvh - 5.5rem)')
->toContain('overflow-y: auto');
});
diff --git a/tests/Feature/SettingsTitleSidebarAlignmentTest.php b/tests/Feature/SettingsTitleSidebarAlignmentTest.php
index 039bde370..7c8b20ec6 100644
--- a/tests/Feature/SettingsTitleSidebarAlignmentTest.php
+++ b/tests/Feature/SettingsTitleSidebarAlignmentTest.php
@@ -4,40 +4,48 @@
* Settings title sits above the workspace; sidebar is below it. Title shell and
* workspace share max-w-[1180px] so their left edges align.
*/
-test('settings navbar and workspace share the same max width shell', function () {
- $navbar = file_get_contents(resource_path('views/components/settings/navbar.blade.php'));
+test('instance settings pages use one shared sidebar workspace', function () {
+ $layout = file_get_contents(resource_path('views/components/settings/layout.blade.php'));
- expect($navbar)
+ expect($layout)
->toContain('max-w-[1180px]')
- ->toContain("title' => 'Settings'")
- ->toContain(':titleOnDesktop="false"');
+ ->toContain('application-settings-navigation')
+ ->toContain("'Configuration' =>")
+ ->toContain("'Instance' =>");
$pages = [
resource_path('views/livewire/settings/index.blade.php'),
resource_path('views/livewire/settings/advanced.blade.php'),
resource_path('views/livewire/settings/updates.blade.php'),
resource_path('views/livewire/settings-oauth.blade.php'),
+ resource_path('views/livewire/settings-backup.blade.php'),
+ resource_path('views/livewire/settings-email.blade.php'),
+ resource_path('views/livewire/settings/scheduled-jobs.blade.php'),
];
foreach ($pages as $path) {
$blade = file_get_contents($path);
expect($blade)
- ->toContain('toContain('max-w-[1180px]')
- ->toContain('xl:grid-cols-[210px_minmax(0,1fr)]')
+ ->toContain('')
+ ->not->toContain('not->toContain('x-settings.page-header');
}
+
+ $oauth = file_get_contents(resource_path('views/livewire/settings-oauth.blade.php'));
+ expect($oauth)
+ ->toContain('')
+ ->toContain('aria-label="OAuth providers"')
+ ->toContain('window.scrollToSettingsSection?.')
+ ->toContain('history.replaceState')
+ ->not->toContain('xl:grid-cols-[210px_minmax(0,1fr)]');
});
test('dashboard navbar hides family titles at lg by default', function () {
$dashboardNavbar = file_get_contents(resource_path('views/components/dashboard/navbar.blade.php'));
- $settingsNavbar = file_get_contents(resource_path('views/components/settings/navbar.blade.php'));
expect($dashboardNavbar)
->toContain("'titleOnDesktop' => false")
- ->toContain("'lg:hidden' => ! \$titleOnDesktop");
+ ->toContain("'lg:hidden' => \$mobileTitleOnly || (! \$titleOnDesktop && \$showNav)");
- expect($settingsNavbar)
- ->toContain(':titleOnDesktop="false"');
});
diff --git a/tests/Feature/SettingsUpdatesIconTest.php b/tests/Feature/SettingsUpdatesIconTest.php
index b64a882e7..3178c57f3 100644
--- a/tests/Feature/SettingsUpdatesIconTest.php
+++ b/tests/Feature/SettingsUpdatesIconTest.php
@@ -4,7 +4,7 @@
* Instance settings Updates nav must use the reicon "refresh3" glyph.
*/
test('settings updates sidebar uses the refresh3 icon', function () {
- $sidebar = file_get_contents(resource_path('views/components/settings/sidebar.blade.php'));
+ $sidebar = file_get_contents(resource_path('views/components/settings/layout.blade.php'));
$reicon = file_get_contents(resource_path('views/components/reicon.blade.php'));
expect($sidebar)
diff --git a/tests/Feature/Team/TeamDeletionTest.php b/tests/Feature/Team/TeamDeletionTest.php
index 65d6c54f1..ed6ee8a4f 100644
--- a/tests/Feature/Team/TeamDeletionTest.php
+++ b/tests/Feature/Team/TeamDeletionTest.php
@@ -1,6 +1,6 @@
0]);
+ InstanceSettings::forceCreate(['id' => 0]);
$this->owner = User::factory()->create();
@@ -27,7 +27,7 @@ test('deleting a team switches session to another team without error', function
$this->actingAs($this->owner);
session(['currentTeam' => $this->teamToDelete]);
- Livewire::test(Index::class)
+ Livewire::test(DangerZone::class)
->call('delete')
->assertRedirect(route('team.index'));
diff --git a/tests/Feature/TeamSettingsNavigationTest.php b/tests/Feature/TeamSettingsNavigationTest.php
new file mode 100644
index 000000000..2597edc1b
--- /dev/null
+++ b/tests/Feature/TeamSettingsNavigationTest.php
@@ -0,0 +1,39 @@
+toContain('')
+ ->not->toContain('toContain("'label' => 'General'")
+ ->toContain("'label' => 'Members'")
+ ->toContain("'label' => 'Admin View'")
+ ->toContain("'label' => 'Danger Zone'")
+ ->toContain('application-settings-navigation')
+ ->not->toContain('New team');
+ expect(file_get_contents(resource_path('views/livewire/team/index.blade.php')))
+ ->toContain('buttonTitle="New team"')
+ ->not->toContain('Delete team');
+ expect(file_get_contents(resource_path('views/livewire/team/danger-zone.blade.php')))
+ ->toContain('Delete team')
+ ->toContain('status="Permanent"')
+ ->toContain('border-red-300');
+ expect(file_get_contents(resource_path('views/livewire/switch-team.blade.php')))
+ ->toContain('New team')
+ ->toContain('team-switcher-create-expanded')
+ ->toContain('team-switcher-create-collapsed');
+ expect($navbar)
+ ->toContain("request()->routeIs('team.index', 'team.member.index', 'team.admin-view', 'team.danger-zone')")
+ ->not->toContain("['label' => 'Members', 'route' => 'team.member.index'");
+});