- Implemented `KekaController` to manage OAuth flows for Keka HR, including connection, callback, and disconnection. - Added required routes, middleware updates, and session handling for Keka integration. - Enhanced dashboard UI to display Keka connection status and provide seamless connect/disconnect options. - Included validation and configuration for Keka Portal URL in connected apps. - Developed feature tests to ensure reliable authentication and token handling workflows for Keka.
265 lines
9.2 KiB
PHP
265 lines
9.2 KiB
PHP
<?php
|
|
|
|
declare(strict_types=1);
|
|
|
|
use App\Models\{OauthToken, User};
|
|
use Livewire\Livewire;
|
|
|
|
function assignKekaHrAccess(User $user): void
|
|
{
|
|
$protocol = App\Models\ConnectionProtocol::query()->where('name', 'url')->first();
|
|
if (! $protocol) {
|
|
$protocol = new App\Models\ConnectionProtocol();
|
|
$protocol->name = 'url';
|
|
$protocol->save();
|
|
}
|
|
|
|
$provider = App\Models\ConnectionProvider::query()->where('slug', 'keka-hr')->first();
|
|
if (! $provider) {
|
|
$provider = new App\Models\ConnectionProvider();
|
|
$provider->name = 'Keka HR';
|
|
$provider->slug = 'keka-hr';
|
|
$provider->save();
|
|
}
|
|
|
|
$status = App\Models\ConnectionStatus::query()->where('name', 'connected')->first();
|
|
if (! $status) {
|
|
$status = new App\Models\ConnectionStatus();
|
|
$status->name = 'connected';
|
|
$status->save();
|
|
}
|
|
|
|
$app = App\Models\ConnectedApp::query()->create([
|
|
'name' => 'Keka HR Portal',
|
|
'slug' => 'keka-hr-portal',
|
|
'connection_protocol_id' => $protocol->id,
|
|
'connection_provider_id' => $provider->id,
|
|
'connection_status_id' => $status->id,
|
|
'settings' => [
|
|
'keka' => [
|
|
'keka_url' => 'https://sentientgeeks.keka.com',
|
|
'callback_path' => 'signin-oidc',
|
|
'provider' => 'Office365',
|
|
],
|
|
],
|
|
]);
|
|
|
|
$role = App\Models\Role::findOrCreate('Keka User');
|
|
$role->update(['priority' => 10]);
|
|
$role->apps()->attach($app->id, ['duration' => now()->addYear()->toDateTimeString()]);
|
|
|
|
$user->assignRole($role);
|
|
}
|
|
|
|
it('redirects unauthenticated users to login when connecting', function (): void {
|
|
$this->get(route('keka.connect'))
|
|
->assertRedirect(route('login'));
|
|
});
|
|
|
|
it('returns 403 forbidden if user does not have permission for keka-hr app', function (): void {
|
|
$user = User::factory()->create();
|
|
|
|
$this->actingAs($user)
|
|
->get(route('keka.connect'))
|
|
->assertStatus(403);
|
|
});
|
|
|
|
it('redirects to Keka login page on connect when authorized', function (): void {
|
|
$user = User::factory()->create();
|
|
assignKekaHrAccess($user);
|
|
|
|
$response = $this->actingAs($user)
|
|
->get(route('keka.connect'));
|
|
|
|
$response->assertRedirect('https://login.keka.com/Account/ExternalLogin?provider=Office365&companyId=sentientgeeks');
|
|
});
|
|
|
|
it('stores token and redirects to Keka callback on successful callback when authorized', function (): void {
|
|
$user = User::factory()->create();
|
|
assignKekaHrAccess($user);
|
|
|
|
// Create a microsoft token first to check if callback copies it
|
|
OauthToken::create([
|
|
'user_id' => $user->id,
|
|
'provider' => 'microsoft',
|
|
'access_token' => 'ms-access-token',
|
|
'refresh_token' => 'ms-refresh-token',
|
|
'token_type' => 'Bearer',
|
|
'expires_at' => now()->addHour(),
|
|
]);
|
|
|
|
$response = $this->actingAs($user)
|
|
->get(route('keka.callback', [
|
|
'code' => 'keka-code-value',
|
|
'state' => 'keka-state-value',
|
|
]));
|
|
|
|
$response->assertRedirect('https://login.keka.com/signin-oidc?code=keka-code-value&state=keka-state-value');
|
|
|
|
$this->assertDatabaseHas('oauth_tokens', [
|
|
'user_id' => $user->id,
|
|
'provider' => 'keka',
|
|
'access_token' => 'ms-access-token',
|
|
'refresh_token' => 'ms-refresh-token',
|
|
]);
|
|
});
|
|
|
|
it('stores placeholder token and redirects to Keka callback when no microsoft token exists', function (): void {
|
|
$user = User::factory()->create();
|
|
assignKekaHrAccess($user);
|
|
|
|
$response = $this->actingAs($user)
|
|
->get(route('keka.callback', [
|
|
'code' => 'keka-code-value2',
|
|
'state' => 'keka-state-value2',
|
|
]));
|
|
|
|
$response->assertRedirect('https://login.keka.com/signin-oidc?code=keka-code-value2&state=keka-state-value2');
|
|
|
|
$this->assertDatabaseHas('oauth_tokens', [
|
|
'user_id' => $user->id,
|
|
'provider' => 'keka',
|
|
'access_token' => 'keka-code-value2',
|
|
]);
|
|
});
|
|
|
|
it('redirects to dashboard with error on callback if code is missing', function (): void {
|
|
$user = User::factory()->create();
|
|
assignKekaHrAccess($user);
|
|
|
|
$response = $this->actingAs($user)
|
|
->get(route('keka.callback', [
|
|
'state' => 'keka-state-value',
|
|
]));
|
|
|
|
$response->assertRedirect(route('dashboard.user'));
|
|
$response->assertSessionHas('keka_error');
|
|
});
|
|
|
|
it('disconnects and removes the token when authorized', function (): void {
|
|
$user = User::factory()->create();
|
|
assignKekaHrAccess($user);
|
|
|
|
OauthToken::create([
|
|
'user_id' => $user->id,
|
|
'provider' => 'keka',
|
|
'access_token' => 'fake-keka-token',
|
|
'refresh_token' => 'fake-keka-refresh',
|
|
'token_type' => 'Bearer',
|
|
'expires_at' => now()->addHour(),
|
|
]);
|
|
|
|
$response = $this->actingAs($user)
|
|
->delete(route('keka.disconnect'));
|
|
|
|
$response->assertRedirect(route('dashboard.user'));
|
|
$response->assertSessionHas('keka_success', 'Keka HR disconnected.');
|
|
|
|
$this->assertDatabaseMissing('oauth_tokens', [
|
|
'user_id' => $user->id,
|
|
'provider' => 'keka',
|
|
]);
|
|
});
|
|
|
|
it('requires a valid kekaUrl when creating a Keka HR connected app', function (): void {
|
|
$this->seed(Database\Seeders\ConnectionProtocolSeeder::class);
|
|
$this->seed(Database\Seeders\ConnectionProviderSeeder::class);
|
|
$this->seed(Database\Seeders\ConnectionStatusSeeder::class);
|
|
|
|
$admin = User::factory()->create();
|
|
$admin->assignRole(App\Models\Role::findOrCreate('Admin'));
|
|
|
|
$protocol = App\Models\ConnectionProtocol::query()->where('name', 'url')->firstOrFail();
|
|
$provider = App\Models\ConnectionProvider::query()->where('slug', 'keka-hr')->firstOrFail();
|
|
$status = App\Models\ConnectionStatus::query()->where('name', 'connected')->firstOrFail();
|
|
|
|
$this->actingAs($admin);
|
|
|
|
// Test validation failure
|
|
Livewire::test('pages::connected-apps.create')
|
|
->set('form.name', 'My Keka App')
|
|
->set('form.protocolId', $protocol->id)
|
|
->set('form.providerId', $provider->id)
|
|
->set('form.statusId', $status->id)
|
|
->set('form.kekaUrl', 'not-a-valid-url')
|
|
->call('save')
|
|
->assertHasErrors(['form.kekaUrl' => 'url']);
|
|
|
|
// Test validation success
|
|
Livewire::test('pages::connected-apps.create')
|
|
->set('form.name', 'My Keka App')
|
|
->set('form.protocolId', $protocol->id)
|
|
->set('form.providerId', $provider->id)
|
|
->set('form.statusId', $status->id)
|
|
->set('form.kekaUrl', 'https://sentientgeeks.keka.com')
|
|
->call('save')
|
|
->assertHasNoErrors();
|
|
|
|
$this->assertDatabaseHas('connected_apps', [
|
|
'name' => 'My Keka App',
|
|
'connection_protocol_id' => $protocol->id,
|
|
'connection_provider_id' => $provider->id,
|
|
]);
|
|
|
|
$app = App\Models\ConnectedApp::query()->where('name', 'My Keka App')->firstOrFail();
|
|
expect(data_get($app->settings, 'keka_url'))->toBe('https://sentientgeeks.keka.com');
|
|
expect(data_get($app->settings, 'keka.provider'))->toBe('Office365');
|
|
});
|
|
|
|
it('successfully edits a Keka HR connected app', function (): void {
|
|
$this->seed(Database\Seeders\ConnectionProtocolSeeder::class);
|
|
$this->seed(Database\Seeders\ConnectionProviderSeeder::class);
|
|
$this->seed(Database\Seeders\ConnectionStatusSeeder::class);
|
|
|
|
$admin = User::factory()->create();
|
|
$admin->assignRole(App\Models\Role::findOrCreate('Admin'));
|
|
|
|
$protocol = App\Models\ConnectionProtocol::query()->where('name', 'url')->firstOrFail();
|
|
$provider = App\Models\ConnectionProvider::query()->where('slug', 'keka-hr')->firstOrFail();
|
|
$status = App\Models\ConnectionStatus::query()->where('name', 'connected')->firstOrFail();
|
|
|
|
$app = App\Models\ConnectedApp::query()->create([
|
|
'name' => 'My Keka App',
|
|
'slug' => 'my-keka-app',
|
|
'connection_protocol_id' => $protocol->id,
|
|
'connection_provider_id' => $provider->id,
|
|
'connection_status_id' => $status->id,
|
|
'settings' => [
|
|
'keka_url' => 'https://old.keka.com',
|
|
'keka' => [
|
|
'keka_url' => 'https://old.keka.com',
|
|
'provider' => 'OpenIdConnect',
|
|
],
|
|
],
|
|
]);
|
|
|
|
$this->actingAs($admin);
|
|
|
|
Livewire::test('pages::connected-apps.edit', ['id' => $app->id])
|
|
->set('form.kekaUrl', 'https://new.keka.com')
|
|
->call('save')
|
|
->assertHasNoErrors();
|
|
|
|
$app->refresh();
|
|
expect(data_get($app->settings, 'keka_url'))->toBe('https://new.keka.com');
|
|
expect(data_get($app->settings, 'keka.provider'))->toBe('Office365');
|
|
});
|
|
|
|
it('forwards POST callback response via an auto-submitting HTML form', function (): void {
|
|
$user = User::factory()->create();
|
|
assignKekaHrAccess($user);
|
|
|
|
$response = $this->actingAs($user)
|
|
->post(route('keka.callback'), [
|
|
'code' => 'post-code-value',
|
|
'state' => 'post-state-value',
|
|
'session_state' => 'session-state-value',
|
|
]);
|
|
|
|
$response->assertStatus(200);
|
|
$response->assertSee('action="https://login.keka.com/signin-oidc"', false);
|
|
$response->assertSee('name="code" value="post-code-value"', false);
|
|
$response->assertSee('name="state" value="post-state-value"', false);
|
|
$response->assertSee('name="session_state" value="session-state-value"', false);
|
|
});
|