test: cover OAuth redirect URI, multi-domain previews, server clones

- Add SettingsOauth tests that reject redirect URIs off the provider
  callback route and accept the callback route with or without a
  trailing slash
- Add a compose preview test checking that every domain of a
  multi-domain service gets a PR-prefixed domain and its own Traefik
  Host rule
- Add a clone test checking that cloning a service to another server
  keeps its generated database credentials and copies its volumes
- Refresh template_last_updated_at timestamps in the service templates
This commit is contained in:
Andras Bacsai
2026-11-03 21:21:34 +01:00
parent a5b4e1d32e
commit 33cb5e3c6a
5 changed files with 132 additions and 22 deletions
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -194,3 +194,54 @@ test('cloning a cloned service replaces the clone suffix instead of adding anoth
expect($source->name)->toBe("Gitea Runner-clone-{$source->uuid}");
});
test('cloning a service to another server keeps its generated database credentials and copies its volumes there', function () {
$this->service->update(['docker_compose_raw' => <<<'YAML'
services:
freshrss:
image: freshrss/freshrss:latest
environment:
- SERVICE_URL_FRESHRSS_80
- MARIADB_USER=${SERVICE_USER_MARIADB}
- MARIADB_PASSWORD=${SERVICE_PASSWORD_MARIADB}
volumes:
- freshrss-data:/var/www/FreshRSS/data
freshrss-db:
image: mariadb:11
environment:
- MYSQL_ROOT_PASSWORD=$SERVICE_PASSWORD_ROOT
- MYSQL_USER=${SERVICE_USER_MARIADB}
- MYSQL_PASSWORD=${SERVICE_PASSWORD_MARIADB}
volumes:
- mariadb-data:/var/lib/mysql
YAML]);
$this->service->parse();
$sourceCredentials = $this->service->environment_variables()
->whereIn('key', ['SERVICE_USER_MARIADB', 'SERVICE_PASSWORD_MARIADB', 'SERVICE_PASSWORD_ROOT'])
->pluck('value', 'key')
->all();
expect($sourceCredentials)->toHaveCount(3);
$nodeB = Server::factory()->create(['team_id' => $this->team->id, 'private_key_id' => $this->service->server->private_key_id]);
$nodeB->settings()->update(['is_reachable' => true, 'is_usable' => true]);
$nodeBDestination = StandaloneDocker::where('server_id', $nodeB->id)->firstOrFail();
Livewire::test(ResourceOperations::class, ['resource' => $this->service])
->set('cloneVolumeData', true)
->call('cloneTo', $nodeBDestination->uuid)
->assertHasNoErrors();
$clone = Service::query()->whereKeyNot($this->service->id)->sole();
$cloneCredentials = $clone->environment_variables()
->whereIn('key', array_keys($sourceCredentials))
->pluck('value', 'key')
->all();
expect($clone->server_id)->toBe($nodeB->id)
->and($cloneCredentials)->toEqual($sourceCredentials)
->and($clone->environment_variables()->where('key', 'SERVICE_PASSWORD_MARIADB')->count())->toBe(1)
->and(chainedServiceVolumeClones())->toEqualCanonicalizing([
["{$this->service->uuid}_freshrss-data", "{$clone->uuid}_freshrss-data"],
["{$this->service->uuid}_mariadb-data", "{$clone->uuid}_mariadb-data"],
]);
});
@@ -1205,3 +1205,33 @@ it('replaces a custom preview domain when the generate action runs', function ()
expect($preview->fresh()->fqdn)->toBe('https://6540.app.example.com');
});
it('routes every generated compose preview domain when a service has more than one domain', function () {
$this->application->update([
'build_pack' => 'dockercompose',
'compose_parsing_version' => '3',
'preview_url_template' => '{{pr_id}}.{{domain}}',
'docker_compose_raw' => "services:\n frontend:\n image: nginx:alpine\n",
'fqdn' => null,
'docker_compose_domains' => json_encode([
'frontend' => ['domain' => 'https://dev.example.com,https://www.dev.example.com'],
]),
]);
$preview = createPreviewForPortTests($this->application->fresh(), 92, [
'docker_compose_domains' => $this->application->docker_compose_domains,
]);
$preview->generate_preview_fqdn_compose();
$preview->refresh();
expect(json_decode($preview->docker_compose_domains, true)['frontend']['domain'])
->toBe('https://92.dev.example.com,https://92.www.dev.example.com');
$labels = collect(data_get(applicationParser($this->application->fresh(), 92, $preview->id), 'services.frontend-pr-92.labels'));
$routerRules = $labels->filter(fn (string $label): bool => str_contains($label, '.rule='))->values();
expect($routerRules)->not->toBeEmpty()
->and($routerRules->every(fn (string $label): bool => preg_match('/\.rule=Host\(`[a-z0-9.]+`\)( && PathPrefix\(`\/[^`]*`\))?$/', $label) === 1))->toBeTrue()
->and($routerRules->contains(fn (string $label): bool => str_contains($label, 'Host(`92.dev.example.com`)')))->toBeTrue()
->and($routerRules->contains(fn (string $label): bool => str_contains($label, 'Host(`92.www.dev.example.com`)')))->toBeTrue();
});
+29
View File
@@ -184,6 +184,35 @@ it('validates oidc url fields before saving', function (string $field, string $v
'non-http issuer url' => ['base_url', 'ftp://idp.example.com'],
]);
it('rejects a redirect uri that does not point to the provider callback route', function (string $redirectUri) {
actingAsInstanceAdmin();
Livewire::test(SettingsOauth::class, ['provider' => 'authentik'])
->set('oauth_settings_map.authentik.redirect_uri', $redirectUri)
->call('submit')
->assertHasErrors(['oauth_settings_map.authentik.redirect_uri']);
expect(OauthSetting::where('provider', 'authentik')->first()->redirect_uri)->toBeNull();
})->with([
'swapped segments' => ['https://coolify.example.com/auth/callback/authentik'],
'other provider' => ['https://coolify.example.com/auth/github/callback'],
'no path' => ['https://coolify.example.com'],
]);
it('accepts a redirect uri on the provider callback route', function (string $redirectUri) {
actingAsInstanceAdmin();
Livewire::test(SettingsOauth::class, ['provider' => 'authentik'])
->set('oauth_settings_map.authentik.redirect_uri', $redirectUri)
->call('submit')
->assertHasNoErrors();
expect(OauthSetting::where('provider', 'authentik')->first()->redirect_uri)->toBe($redirectUri);
})->with([
'callback route' => ['https://coolify.example.com/auth/authentik/callback'],
'trailing slash' => ['https://coolify.example.com/auth/authentik/callback/'],
]);
it('does not enable oidc without required fields', function () {
actingAsInstanceAdmin();