Feat: Add Keka HR integration for OAuth and user token management

- 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.
This commit is contained in:
= 2026-06-11 13:40:52 +00:00
parent 66ae0bad37
commit b90dd5c092
9 changed files with 689 additions and 4 deletions

View File

@ -0,0 +1,243 @@
<?php
declare(strict_types=1);
namespace App\Http\Controllers;
use App\Models\{ConnectedApp, OauthToken};
use App\Services\UserAppAccessService;
use Illuminate\Http\{RedirectResponse, Request};
use Illuminate\Support\Facades\Log;
class KekaController extends Controller
{
/**
* Step 1: Redirect the user to Keka's external login page.
*/
public function connect(): RedirectResponse
{
$user = auth()->user();
if (! $user) {
abort(403, 'Unauthorized');
}
$activeServices = app(UserAppAccessService::class)->getActiveServices($user);
$hasKekaAccess = $activeServices->contains(fn ($service) => 'keka-hr' === $service->provider_slug);
if (! $hasKekaAccess) {
abort(403, 'You do not have permission to access the required application.');
}
$app = ConnectedApp::whereHas('provider', function ($query): void {
$query->where('slug', 'keka-hr');
})->first();
if (! $app) {
abort(404, 'Keka HR app registration not found.');
}
$kekaUrl = data_get($app->settings, 'keka.keka_url') ?? data_get($app->settings, 'keka_url') ?? 'https://sentientgeeks.keka.com';
$provider = data_get($app->settings, 'keka.provider') ?? data_get($app->settings, 'provider') ?? 'Office365';
$redirectUrl = $this->getKekaExternalLoginUrl((string) $kekaUrl, (string) $provider);
Log::info('Redirecting user to Keka external login page.', [
'user_id' => $user->id,
'keka_url' => $kekaUrl,
'redirect_url' => $redirectUrl,
]);
return redirect($redirectUrl);
}
/**
* Parse the configured Keka URL to build the correct external login URL.
*/
private function getKekaExternalLoginUrl(string $configuredUrl, string $provider): string
{
$parsed = parse_url($configuredUrl);
$host = $parsed['host'] ?? 'login.keka.com';
$query = [];
if (isset($parsed['query'])) {
parse_str($parsed['query'], $query);
}
$companyId = $query['companyId'] ?? $query['companyid'] ?? null;
if (! $companyId && 'login.keka.com' !== $host) {
$parts = explode('.', $host);
if (count($parts) >= 2) {
$companyId = $parts[0];
}
}
$buildQuery = [
'provider' => $provider,
];
// if ($companyId) {
// $buildQuery['companyId'] = $companyId;
// }
$queryString = http_build_query($buildQuery);
return "https://login.keka.com/Account/ExternalLogin?{$queryString}";
}
/**
* Step 2: Handle the Microsoft callback redirection.
* We record/store the tokens in the database, and then pass the code/state to Keka.
*/
public function callback(Request $request)
{
$user = auth()->user();
if (! $user) {
abort(403, 'Unauthorized');
}
$activeServices = app(UserAppAccessService::class)->getActiveServices($user);
$hasKekaAccess = $activeServices->contains(fn ($service) => 'keka-hr' === $service->provider_slug);
if (! $hasKekaAccess) {
abort(403, 'You do not have permission to access the required application.');
}
$code = $request->input('code');
$state = $request->input('state');
if (empty($code)) {
return redirect()->route('dashboard.user')->with('keka_error', 'Keka connection failed: Missing code.');
}
// Retrieve Keka App configurations
$app = ConnectedApp::whereHas('provider', function ($query): void {
$query->where('slug', 'keka-hr');
})->first();
if (! $app) {
abort(404, 'Keka HR app registration not found.');
}
$kekaUrl = data_get($app->settings, 'keka.keka_url') ?? data_get($app->settings, 'keka_url') ?? 'https://sentientgeeks.keka.com';
$callbackPath = data_get($app->settings, 'keka.callback_path') ?? 'signin-oidc';
$callbackUrl = $this->getKekaCallbackUrl((string) $kekaUrl, (string) $callbackPath);
// Store token details (Option B: copy user's existing Microsoft token or save code/placeholder)
$microsoftToken = $user->oauthToken('microsoft');
if ($microsoftToken) {
$accessToken = $microsoftToken->access_token;
$refreshToken = $microsoftToken->refresh_token;
$tokenType = $microsoftToken->token_type;
$scopes = $microsoftToken->scopes;
$expiresAt = $microsoftToken->expires_at;
} else {
$accessToken = (string) $code;
$refreshToken = 'keka-placeholder-refresh';
$tokenType = 'Bearer';
$scopes = 'openid profile email';
$expiresAt = now()->addHour();
}
OauthToken::updateOrCreate(
[
'user_id' => $user->id,
'provider' => 'keka',
],
[
'access_token' => $accessToken,
'refresh_token' => $refreshToken,
'token_type' => $tokenType,
'scopes' => $scopes,
'expires_at' => $expiresAt,
]
);
Log::info('Keka tokens stored successfully. Passing OIDC response to Keka.', [
'user_id' => $user->id,
'callback_url' => $callbackUrl,
'method' => $request->method(),
]);
if ($request->isMethod('post')) {
$inputs = '';
foreach ($request->all() as $key => $value) {
$safeKey = htmlspecialchars((string) $key, ENT_QUOTES, 'UTF-8');
$safeValue = htmlspecialchars((string) $value, ENT_QUOTES, 'UTF-8');
$inputs .= "<input type=\"hidden\" name=\"{$safeKey}\" value=\"{$safeValue}\" />\n";
}
$html = <<<HTML
<!DOCTYPE html>
<html>
<head>
<title>Forwarding to Keka...</title>
</head>
<body onload="document.forms[0].submit()">
<form method="post" action="{$callbackUrl}">
{$inputs}
<noscript>
<p>JavaScript is required to connect to Keka. Click the button below to continue.</p>
<input type="submit" value="Continue" />
</noscript>
</form>
</body>
</html>
HTML;
return response($html);
}
$queryParams = $request->query();
$queryString = http_build_query($queryParams);
return redirect("{$callbackUrl}?{$queryString}");
}
/**
* Parse the configured Keka URL to construct the callback URL.
*/
private function getKekaCallbackUrl(string $configuredUrl, string $callbackPath): string
{
$parsed = parse_url($configuredUrl);
$host = $parsed['host'] ?? 'login.keka.com';
if ('login.keka.com' !== $host) {
$parts = explode('.', $host);
if (count($parts) >= 2 && 'keka' === $parts[1]) {
$host = 'login.keka.com';
}
}
$scheme = $parsed['scheme'] ?? 'https';
return "{$scheme}://{$host}/".mb_ltrim($callbackPath, '/');
}
/**
* Disconnect/Revoke the Keka integration for the user.
*/
public function disconnect(): RedirectResponse
{
$user = auth()->user();
if (! $user) {
abort(403, 'Unauthorized');
}
$activeServices = app(UserAppAccessService::class)->getActiveServices($user);
$hasKekaAccess = $activeServices->contains(fn ($service) => 'keka-hr' === $service->provider_slug);
if (! $hasKekaAccess) {
abort(403, 'You do not have permission to access the required application.');
}
$user->oauthToken('keka')?->delete();
Log::info('Keka integration disconnected for user.', [
'user_id' => $user->id,
]);
return redirect()->route('dashboard.user')->with('keka_success', 'Keka HR disconnected.');
}
}

View File

@ -30,6 +30,8 @@ class StoreConnectedAppFrom extends Form
public array $samlAttributes = [];
public string $kekaUrl = '';
protected ConnectedAppService $service;
public function boot(ConnectedAppService $service): void
@ -65,6 +67,11 @@ public function rules(): array
}
}
$provider = \App\Models\ConnectionProvider::query()->find($this->providerId);
if ($provider && 'keka-hr' === $provider->slug) {
$rules['kekaUrl'] = 'required|url';
}
return $rules;
}
@ -82,6 +89,7 @@ public function set(ConnectedAppData $data): void
$this->samlAcsUrl = $data->settings['saml']['acs_url'] ?? '';
$this->samlNameIdFormat = $data->settings['saml']['nameid_format'] ?? 'persistent';
$this->samlNameIdSource = $data->settings['saml']['nameid_source'] ?? 'immutable_id';
$this->kekaUrl = $data->settings['keka_url'] ?? $data->settings['keka']['keka_url'] ?? '';
$this->samlAttributes = [];
$attributes = $data->settings['saml']['attributes'] ?? [];

View File

@ -11,5 +11,9 @@
commands: __DIR__.'/../routes/console.php',
health: '/up',
)
->withMiddleware(function (Middleware $middleware): void {})
->withMiddleware(function (Middleware $middleware): void {
$middleware->validateCsrfTokens(except: [
'keka/callback',
]);
})
->withExceptions(function (Exceptions $exceptions): void {})->create();

View File

@ -77,6 +77,16 @@ public function isSaml(): bool
return $protocol && strtolower($protocol->name) === "saml";
}
#[Computed]
public function isKekaProvider(): bool
{
$provider = $this->providers()->firstWhere(
"id",
(int) $this->form->providerId,
);
return $provider && $provider->slug === "keka-hr";
}
public function save(bool $goBack = true): void
{
$this->form->validate();
@ -98,6 +108,15 @@ public function save(bool $goBack = true): void
"attributes" => $attributes,
],
];
} elseif ($this->isKekaProvider()) {
$settings = [
"keka_url" => $this->form->kekaUrl,
"keka" => [
"keka_url" => $this->form->kekaUrl,
"callback_path" => "signin-oidc",
"provider" => "Office365",
],
];
}
$data = new ConnectAppRequest(
@ -150,7 +169,7 @@ public function goBack(): void
/>
<x-mary-choices
required
wire:model="form.providerId"
wire:model.live="form.providerId"
:label="__('Provider')"
placeholder="Select"
:single="true"
@ -169,6 +188,14 @@ public function goBack(): void
<x-dashboard.connected-apps.saml-configuration :form="$this->form"/>
@endif
@if($this->isKekaProvider)
<div class="md:col-span-2 grid grid-cols-1 md:grid-cols-2 gap-4 border-t border-gray-200 pt-4 mt-2">
<h3 class="font-semibold text-sm text-blue-600 md:col-span-2">Keka Configuration</h3>
<x-mary-input wire:model="form.kekaUrl" required :label="__('Keka Portal URL')"
placeholder="e.g. https://sentientgeeks.keka.com" class="md:col-span-2"/>
</div>
@endif
<div class="md:col-span-2 flex gap-x-4 font-medium justify-end">
<x-mary-button wire:click.prevent="goBack" spinner="goBack">Cancel</x-mary-button>
<x-mary-button wire:click.prevent="save(false)">Save & Add Another</x-mary-button>

View File

@ -79,6 +79,16 @@ public function isSaml(): bool
return $protocol && strtolower($protocol->name) === "saml";
}
#[Computed]
public function isKekaProvider(): bool
{
$provider = $this->providers()->firstWhere(
"id",
(int) $this->form->providerId,
);
return $provider && $provider->slug === "keka-hr";
}
/**
* @returns DataCollection<int, ConnectionStatus>
*/
@ -113,6 +123,15 @@ public function save(): void
"attributes" => $attributes,
],
];
} elseif ($this->isKekaProvider()) {
$settings = [
"keka_url" => $this->form->kekaUrl,
"keka" => [
"keka_url" => $this->form->kekaUrl,
"callback_path" => "signin-oidc",
"provider" => "Office365",
],
];
}
$serviceData = new ConnectAppRequest(
@ -157,7 +176,7 @@ public function removeSamlAttribute(int $index): void
/>
<x-mary-choices
required
wire:model="form.providerId"
wire:model.live="form.providerId"
:label="__('Provider')"
placeholder="Select"
:single="true"
@ -175,6 +194,14 @@ public function removeSamlAttribute(int $index): void
@if($this->isSaml)
<x-dashboard.connected-apps.saml-configuration :form="$this->form"/>
@endif
@if($this->isKekaProvider)
<div class="md:col-span-2 grid grid-cols-1 md:grid-cols-2 gap-4 border-t border-gray-200 pt-4 mt-2">
<h3 class="font-semibold text-sm text-blue-600 md:col-span-2">Keka Configuration</h3>
<x-mary-input wire:model="form.kekaUrl" required :label="__('Keka Portal URL')"
placeholder="e.g. https://sentientgeeks.keka.com" class="md:col-span-2"/>
</div>
@endif
</div>
<x-slot:actions class="pr-2">
<x-mary-button :link="route('apps.index')" wire:navigate>

View File

@ -40,6 +40,14 @@ public function mount(): void
if (session("entra_error")) {
$this->error(session("entra_error"));
}
if (session("keka_success")) {
$this->success(session("keka_success"));
}
if (session("keka_error")) {
$this->error(session("keka_error"));
}
}
#[Computed]
@ -67,6 +75,20 @@ public function isEntraConnected(): bool
return $token !== null && !$token->isExpired();
}
#[Computed]
public function kekaToken(): ?OauthToken
{
return auth()->user()?->oauthToken('keka');
}
#[Computed]
public function isKekaConnected(): bool
{
$token = $this->kekaToken();
return $token !== null && !$token->isExpired();
}
};
?>
@ -189,6 +211,58 @@ class="btn-primary flex-1"
</div>
</div>
@endif
@if($service->protocol_name === 'url' && $service->provider_slug === 'keka-hr')
<div
class="flex flex-col gap-3">
<div class="flex items-center justify-between">
<span class="text-xs font-semibold text-gray-700">Keka Integration</span>
@if($this->isKekaConnected)
<x-mary-badge
class="badge-soft badge-success text-[10px] font-semibold px-2 py-0.5">
Connected
</x-mary-badge>
@else
<x-mary-badge
class="badge-soft badge-neutral text-[10px] font-semibold px-2 py-0.5">
Not Connected
</x-mary-badge>
@endif
</div>
<div class="flex items-center gap-2">
@if($this->isKekaConnected)
<x-mary-button
external
icon="lucide.external-link"
:link="route('keka.connect')"
class=" btn-primary flex-1"
label="Open Keka"
/>
<form method="POST" action="{{ route('keka.disconnect') }}"
class="inline">
@csrf
@method('DELETE')
<x-mary-button
icon="lucide.unplug"
type="submit"
class=" btn-error"
tooltip="Disconnect"
/>
</form>
@else
<x-mary-button
no-wire-navigate
icon="lucide.plug"
:link="route('keka.connect')"
class="btn-primary flex-1"
label="Connect"
/>
@endif
</div>
</div>
@endif
</div>
<div class="pt-4 border-t border-gray-200 flex items-center justify-between gap-4">
@ -230,6 +304,24 @@ class="btn-sm btn-circle btn-soft btn-primary"
tooltip="Launch Service"
/>
@endif
@elseif($service->provider_slug === 'keka-hr')
@if($this->isKekaConnected)
<x-mary-button
external
icon="lucide.external-link"
:link="route('keka.connect')"
class="btn-sm btn-circle btn-soft btn-primary"
tooltip="Launch Service"
/>
@else
<x-mary-button
no-wire-navigate
icon="lucide.external-link"
:link="route('keka.connect')"
class="btn-sm btn-circle btn-soft btn-primary"
tooltip="Launch Service"
/>
@endif
@else
<x-mary-button
icon="lucide.external-link"

View File

@ -8,7 +8,7 @@
PermissionPermissionEnum,
RoleAndAppsPermissionEnum,
UserPermissionEnum};
use App\Http\Controllers\EntraController;
use App\Http\Controllers\{EntraController, KekaController};
use App\Http\Controllers\{ResolveDashboardRouteController, SamlIdpController};
use Illuminate\Support\Facades\Route;
use Spatie\Permission\Middleware\{PermissionMiddleware, RoleMiddleware};
@ -96,4 +96,10 @@
Route::delete('disconnect', [EntraController::class, 'disconnect'])->name('disconnect');
});
Route::middleware(['auth', 'verified'])->prefix('keka')->name('keka.')->group(function (): void {
Route::get('connect', [KekaController::class, 'connect'])->name('connect');
Route::match(['get', 'post'], 'callback', [KekaController::class, 'callback'])->name('callback');
Route::delete('disconnect', [KekaController::class, 'disconnect'])->name('disconnect');
});
require __DIR__.'/settings.php';

14
scratch_keka.php Normal file
View File

@ -0,0 +1,14 @@
<?php
declare(strict_types=1);
require 'vendor/autoload.php';
$app = require_once 'bootstrap/app.php';
$app->make(Illuminate\Contracts\Console\Kernel::class)->bootstrap();
$kekaApp = App\Models\ConnectedApp::whereHas('provider', function ($query): void {
$query->where('slug', 'keka-hr');
})->first();
if ($kekaApp) {
print_r($kekaApp->toArray());
} else {
echo "No Keka HR app found\n";
}

View File

@ -0,0 +1,264 @@
<?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);
});