diff --git a/app/Http/Controllers/Api/Auth/SessionTimeoutController.php b/app/Http/Controllers/Api/Auth/SessionTimeoutController.php new file mode 100644 index 00000000..94a2744c --- /dev/null +++ b/app/Http/Controllers/Api/Auth/SessionTimeoutController.php @@ -0,0 +1,67 @@ +configService->config(); + + return $this->success([ + 'config' => new SessionTimeoutConfigResource($config), + ]); + } + + public function check(SessionTimeoutCheckRequest $request): JsonResponse + { + $result = $this->timeoutService->checkTimeout(); + + if (($result['status'] ?? '') === 'expired') { + return $this->error( + $result['message'] ?? 'Session expired.', + Response::HTTP_UNAUTHORIZED, + (new SessionTimeoutStatusResource($result))->toArray($request) + ); + } + + return $this->success([ + 'session' => new SessionTimeoutStatusResource($result), + ]); + } + + public function ping(SessionTimeoutPingRequest $request): JsonResponse + { + $result = $this->timeoutService->pingActivity(); + + if (($result['status'] ?? '') === 'expired') { + return $this->error( + $result['message'] ?? 'Session expired.', + Response::HTTP_UNAUTHORIZED, + (new SessionTimeoutStatusResource($result))->toArray($request) + ); + } + + return $this->success([ + 'session' => new SessionTimeoutStatusResource($result), + ]); + } +} diff --git a/app/Http/Requests/Auth/SessionTimeoutCheckRequest.php b/app/Http/Requests/Auth/SessionTimeoutCheckRequest.php new file mode 100644 index 00000000..1ef812e3 --- /dev/null +++ b/app/Http/Requests/Auth/SessionTimeoutCheckRequest.php @@ -0,0 +1,18 @@ +check(); + } + + public function rules(): array + { + return []; + } +} diff --git a/app/Http/Requests/Auth/SessionTimeoutConfigRequest.php b/app/Http/Requests/Auth/SessionTimeoutConfigRequest.php new file mode 100644 index 00000000..a178fb64 --- /dev/null +++ b/app/Http/Requests/Auth/SessionTimeoutConfigRequest.php @@ -0,0 +1,18 @@ +check(); + } + + public function rules(): array + { + return []; + } +} diff --git a/app/Http/Requests/Auth/SessionTimeoutPingRequest.php b/app/Http/Requests/Auth/SessionTimeoutPingRequest.php new file mode 100644 index 00000000..e6ee51bf --- /dev/null +++ b/app/Http/Requests/Auth/SessionTimeoutPingRequest.php @@ -0,0 +1,18 @@ +check(); + } + + public function rules(): array + { + return []; + } +} diff --git a/app/Http/Resources/Auth/SessionTimeoutConfigResource.php b/app/Http/Resources/Auth/SessionTimeoutConfigResource.php new file mode 100644 index 00000000..dcb670e5 --- /dev/null +++ b/app/Http/Resources/Auth/SessionTimeoutConfigResource.php @@ -0,0 +1,20 @@ + (int) ($this['timeout'] ?? 0), + 'warning_time' => (int) ($this['warning_time'] ?? 0), + 'check_interval' => (int) ($this['check_interval'] ?? 0), + 'logout_url' => (string) ($this['logout_url'] ?? ''), + 'keep_alive_url' => (string) ($this['keep_alive_url'] ?? ''), + 'check_url' => (string) ($this['check_url'] ?? ''), + ]; + } +} diff --git a/app/Http/Resources/Auth/SessionTimeoutStatusResource.php b/app/Http/Resources/Auth/SessionTimeoutStatusResource.php new file mode 100644 index 00000000..887a18c9 --- /dev/null +++ b/app/Http/Resources/Auth/SessionTimeoutStatusResource.php @@ -0,0 +1,18 @@ + (string) ($this['status'] ?? ''), + 'time_remaining' => (int) ($this['time_remaining'] ?? 0), + 'redirect' => $this['redirect'] ?? null, + 'message' => $this['message'] ?? null, + ]; + } +} diff --git a/app/Services/Auth/SessionActivityService.php b/app/Services/Auth/SessionActivityService.php new file mode 100644 index 00000000..a4ea4934 --- /dev/null +++ b/app/Services/Auth/SessionActivityService.php @@ -0,0 +1,39 @@ +session->has('last_activity'); + } + + public function getLastActivity(): ?int + { + $value = $this->session->get('last_activity'); + return $value !== null ? (int) $value : null; + } + + public function setLastActivity(int $timestamp): void + { + $this->session->put('last_activity', $timestamp); + } + + public function clearLastActivity(): void + { + $this->session->forget('last_activity'); + } + + public function invalidate(): void + { + $this->session->invalidate(); + $this->session->regenerateToken(); + } +} diff --git a/app/Services/Auth/SessionTimeoutConfigService.php b/app/Services/Auth/SessionTimeoutConfigService.php new file mode 100644 index 00000000..ac3e416b --- /dev/null +++ b/app/Services/Auth/SessionTimeoutConfigService.php @@ -0,0 +1,21 @@ + SessionTimeout::TIMEOUT_DURATION, + 'warning_time' => SessionTimeout::WARNING_THRESHOLD, + 'check_interval' => SessionTimeout::CHECK_INTERVAL, + 'logout_url' => URL::to('/auth/logout'), + 'keep_alive_url' => route('session.timeout.ping'), + 'check_url' => route('session.timeout.check'), + ]; + } +} diff --git a/app/Services/Auth/SessionTimeoutService.php b/app/Services/Auth/SessionTimeoutService.php new file mode 100644 index 00000000..54f9f15b --- /dev/null +++ b/app/Services/Auth/SessionTimeoutService.php @@ -0,0 +1,72 @@ +activityService->hasLastActivity()) { + return $this->expireSession(); + } + + $lastActivity = (int) $this->activityService->getLastActivity(); + $elapsed = time() - $lastActivity; + + if ($elapsed > SessionTimeout::TIMEOUT_DURATION) { + return $this->expireSession(); + } + + if ($elapsed > SessionTimeout::WARNING_THRESHOLD) { + return [ + 'status' => 'warning', + 'time_remaining' => SessionTimeout::TIMEOUT_DURATION - $elapsed, + ]; + } + + return [ + 'status' => 'active', + 'time_remaining' => SessionTimeout::TIMEOUT_DURATION - $elapsed, + ]; + } + + public function pingActivity(): array + { + if ( + !$this->activityService->hasLastActivity() + || (time() - (int) $this->activityService->getLastActivity() > SessionTimeout::TIMEOUT_DURATION) + ) { + return $this->expireSession(); + } + + $this->activityService->setLastActivity(time()); + + return [ + 'status' => 'active', + 'time_remaining' => SessionTimeout::TIMEOUT_DURATION, + ]; + } + + private function expireSession(): array + { + Log::info('Session expired due to inactivity.'); + $this->activityService->clearLastActivity(); + $this->activityService->invalidate(); + + return [ + 'status' => 'expired', + 'redirect' => URL::to('/login'), + 'message' => 'Your session has expired due to inactivity.', + 'time_remaining' => 0, + ]; + } +} diff --git a/routes/api.php b/routes/api.php index a25e2352..f04c2fbf 100755 --- a/routes/api.php +++ b/routes/api.php @@ -182,6 +182,11 @@ Route::prefix('v1')->group(function () { Route::middleware('auth:sanctum')->group(function () { Route::get('stats', [StatsController::class, 'index']); + Route::prefix('session')->group(function () { + Route::get('timeout-config', [SessionTimeoutController::class, 'config'])->name('session.timeout.config'); + Route::get('check-timeout', [SessionTimeoutController::class, 'check'])->name('session.timeout.check'); + Route::post('ping', [SessionTimeoutController::class, 'ping'])->name('session.timeout.ping'); + }); Route::prefix('whatsapp')->group(function () { Route::get('links', [WhatsappController::class, 'index']); diff --git a/test-output.txt b/test-output.txt deleted file mode 100644 index 4a6b594b..00000000 --- a/test-output.txt +++ /dev/null @@ -1,292 +0,0 @@ -Xdebug: [Step Debug] Could not connect to debugging client. Tried: 127.0.0.1:9003 (through xdebug.client_host/xdebug.client_port). - -Xdebug: [Step Debug] Could not connect to debugging client. Tried: 127.0.0.1:9003 (through xdebug.client_host/xdebug.client_port). - - PASS Tests\Unit\Models\WhatsappGroupLinkTest - ✓ model metadata is converted 0.01s - ✓ model class loads - - PASS Tests\Unit\Models\WhatsappGroupMembershipTest - ✓ model metadata is converted 0.01s - ✓ model class loads - - PASS Tests\Unit\Models\WhatsappInviteLogTest - ✓ model metadata is converted - ✓ model class loads - - - FAIL Tests\Unit\Services\Whatsapp\WhatsappContactServiceTest - ⨯ list parent contacts returns primary and second 0.40s - - - - PASS Tests\Unit\Services\Whatsapp\WhatsappContextServiceTest - ✓ school year aliases generate expected values 0.28s - ✓ has roster for year 0.28s - - - - FAIL Tests\Unit\Services\Whatsapp\WhatsappInviteBundleServiceTest - ⨯ bundle for parent returns links and emails 0.28s - ✓ consolidate bundles merges by parent 0.28s - - - - PASS Tests\Unit\Services\Whatsapp\WhatsappInviteNotificationServiceTest - ✓ dispatch returns error when no listeners 0.29s - ✓ dispatch triggers event when listener registered 0.28s - - - FAIL Tests\Unit\Services\Whatsapp\WhatsappInviteServiceTest - ⨯ send returns success for parent mode 0.28s - - - - PASS Tests\Unit\Services\Whatsapp\WhatsappLinkServiceTest - ✓ upsert creates link 0.28s - ✓ paginate filters by active 0.28s - - - - PASS Tests\Unit\Services\Whatsapp\WhatsappMembershipServiceTest - ✓ update membership saves primary 0.29s - ✓ update membership returns error when table missing 0.29s - - - - - - - - FAIL Tests\Feature\Api\V1\Whatsapp\WhatsappInviteControllerTest - ⨯ parent contacts endpoint returns contacts 0.31s - ⨯ parent contacts by class endpoint returns sections 0.29s - ⨯ send invites returns success when listener registered 0.29s - ⨯ send invites returns error when no listener 0.29s - ⨯ membership update saves record 0.29s - ✓ send invites validation rejects payload 0.31s - - - - - - - - - FAIL Tests\Feature\Api\V1\Whatsapp\WhatsappLinkControllerTest - ✓ index returns paginated links 0.30s - ✓ show returns single link 0.30s - ⨯ store creates link 0.29s - ⨯ update updates link 0.29s - ✓ destroy deletes link 0.31s - ✓ store validation rejects invalid payload 0.30s - ✓ requires authentication 0.29s - ──────────────────────────────────────────────────────────────────────────── - FAILED Tests\Unit\Services\Whatsapp\WhatsappContactServi… QueryException - SQLSTATE[23000]: Integrity constraint violation: 19 NOT NULL constraint failed: parents.secondparent_id (Connection: sqlite, Database: :memory:, SQL: insert into "parents" ("secondparent_firstname", "secondparent_lastname", "secondparent_email", "secondparent_phone", "firstparent_id", "school_year", "semester", "created_at", "updated_at") values (Parent, Two, parent2@example.com, 5555555555, 10, 2025-2026, Fall, 2026-03-10 20:29:52, 2026-03-10 20:29:52)) - - at vendor/laravel/framework/src/Illuminate/Database/Connection.php:838 - 834▕ $exceptionType = $this->isUniqueConstraintError($e) - 835▕ ? UniqueConstraintViolationException::class - 836▕ : QueryException::class; - 837▕ - ➜ 838▕ throw new $exceptionType( - 839▕ $this->getNameWithReadWriteType(), - 840▕ $query, - 841▕ $this->prepareBindings($bindings), - 842▕ $e, - - +8 vendor frames  - 9 tests/Unit/Services/Whatsapp/WhatsappContactServiceTest.php:38 - - ──────────────────────────────────────────────────────────────────────────── - FAILED Tests\Unit\Services\Whatsapp\WhatsappInviteBundle… QueryException - SQLSTATE[23000]: Integrity constraint violation: 19 NOT NULL constraint failed: parents.secondparent_id (Connection: sqlite, Database: :memory:, SQL: insert into "parents" ("secondparent_firstname", "secondparent_lastname", "secondparent_email", "secondparent_phone", "firstparent_id", "school_year", "semester", "created_at", "updated_at") values (Parent, Two, parent2@example.com, 5555555555, 10, 2025-2026, Fall, 2026-03-10 20:29:53, 2026-03-10 20:29:53)) - - at vendor/laravel/framework/src/Illuminate/Database/Connection.php:838 - 834▕ $exceptionType = $this->isUniqueConstraintError($e) - 835▕ ? UniqueConstraintViolationException::class - 836▕ : QueryException::class; - 837▕ - ➜ 838▕ throw new $exceptionType( - 839▕ $this->getNameWithReadWriteType(), - 840▕ $query, - 841▕ $this->prepareBindings($bindings), - 842▕ $e, - - +8 vendor frames  - 9 tests/Unit/Services/Whatsapp/WhatsappInviteBundleServiceTest.php:37 - - ──────────────────────────────────────────────────────────────────────────── - FAILED Tests\Unit\Services\Whatsapp\WhatsappInviteServic… QueryException - SQLSTATE[23000]: Integrity constraint violation: 19 NOT NULL constraint failed: parents.secondparent_id (Connection: sqlite, Database: :memory:, SQL: insert into "parents" ("secondparent_firstname", "secondparent_lastname", "secondparent_email", "secondparent_phone", "firstparent_id", "school_year", "semester", "created_at", "updated_at") values (Parent, Two, parent2@example.com, 5555555555, 10, 2025-2026, Fall, 2026-03-10 20:29:54, 2026-03-10 20:29:54)) - - at vendor/laravel/framework/src/Illuminate/Database/Connection.php:838 - 834▕ $exceptionType = $this->isUniqueConstraintError($e) - 835▕ ? UniqueConstraintViolationException::class - 836▕ : QueryException::class; - 837▕ - ➜ 838▕ throw new $exceptionType( - 839▕ $this->getNameWithReadWriteType(), - 840▕ $query, - 841▕ $this->prepareBindings($bindings), - 842▕ $e, - - +8 vendor frames  - 9 tests/Unit/Services/Whatsapp/WhatsappInviteServiceTest.php:65 - 10 tests/Unit/Services/Whatsapp/WhatsappInviteServiceTest.php:21 - - ──────────────────────────────────────────────────────────────────────────── - FAILED Tests\Feature\Api\V1\Whatsapp\WhatsappInviteContr… QueryException - SQLSTATE[23000]: Integrity constraint violation: 19 NOT NULL constraint failed: parents.secondparent_id (Connection: sqlite, Database: :memory:, SQL: insert into "parents" ("secondparent_firstname", "secondparent_lastname", "secondparent_email", "secondparent_phone", "firstparent_id", "school_year", "semester", "created_at", "updated_at") values (Parent, Two, parent2@example.com, 5555555555, 10, 2025-2026, Fall, 2026-03-10 20:29:56, 2026-03-10 20:29:56)) - - at vendor/laravel/framework/src/Illuminate/Database/Connection.php:838 - 834▕ $exceptionType = $this->isUniqueConstraintError($e) - 835▕ ? UniqueConstraintViolationException::class - 836▕ : QueryException::class; - 837▕ - ➜ 838▕ throw new $exceptionType( - 839▕ $this->getNameWithReadWriteType(), - 840▕ $query, - 841▕ $this->prepareBindings($bindings), - 842▕ $e, - - +8 vendor frames  - 9 tests/Feature/Api/V1/Whatsapp/WhatsappInviteControllerTest.php:22 - - ──────────────────────────────────────────────────────────────────────────── - FAILED Tests\Feature\Api\V1\Whatsapp\WhatsappInviteContr… QueryException - SQLSTATE[23000]: Integrity constraint violation: 19 NOT NULL constraint failed: classSection.semester (Connection: sqlite, Database: :memory:, SQL: insert into "classSection" ("class_section_id", "class_section_name", "class_id", "created_at", "updated_at") values (200, Grade 1, 1, 2026-03-10 20:29:56, 2026-03-10 20:29:56)) - - at vendor/laravel/framework/src/Illuminate/Database/Connection.php:838 - 834▕ $exceptionType = $this->isUniqueConstraintError($e) - 835▕ ? UniqueConstraintViolationException::class - 836▕ : QueryException::class; - 837▕ - ➜ 838▕ throw new $exceptionType( - 839▕ $this->getNameWithReadWriteType(), - 840▕ $query, - 841▕ $this->prepareBindings($bindings), - 842▕ $e, - - +8 vendor frames  - 9 tests/Feature/Api/V1/Whatsapp/WhatsappInviteControllerTest.php:134 - 10 tests/Feature/Api/V1/Whatsapp/WhatsappInviteControllerTest.php:46 - - ──────────────────────────────────────────────────────────────────────────── - FAILED Tests\Feature\Api\V1\Whatsapp\WhatsappInviteContr… QueryException - SQLSTATE[23000]: Integrity constraint violation: 19 NOT NULL constraint failed: classSection.semester (Connection: sqlite, Database: :memory:, SQL: insert into "classSection" ("class_section_id", "class_section_name", "class_id", "created_at", "updated_at") values (200, Grade 1, 1, 2026-03-10 20:29:56, 2026-03-10 20:29:56)) - - at vendor/laravel/framework/src/Illuminate/Database/Connection.php:838 - 834▕ $exceptionType = $this->isUniqueConstraintError($e) - 835▕ ? UniqueConstraintViolationException::class - 836▕ : QueryException::class; - 837▕ - ➜ 838▕ throw new $exceptionType( - 839▕ $this->getNameWithReadWriteType(), - 840▕ $query, - 841▕ $this->prepareBindings($bindings), - 842▕ $e, - - +8 vendor frames  - 9 tests/Feature/Api/V1/Whatsapp/WhatsappInviteControllerTest.php:134 - 10 tests/Feature/Api/V1/Whatsapp/WhatsappInviteControllerTest.php:60 - - ──────────────────────────────────────────────────────────────────────────── - FAILED Tests\Feature\Api\V1\Whatsapp\WhatsappInviteContr… QueryException - SQLSTATE[23000]: Integrity constraint violation: 19 NOT NULL constraint failed: classSection.semester (Connection: sqlite, Database: :memory:, SQL: insert into "classSection" ("class_section_id", "class_section_name", "class_id", "created_at", "updated_at") values (200, Grade 1, 1, 2026-03-10 20:29:56, 2026-03-10 20:29:56)) - - at vendor/laravel/framework/src/Illuminate/Database/Connection.php:838 - 834▕ $exceptionType = $this->isUniqueConstraintError($e) - 835▕ ? UniqueConstraintViolationException::class - 836▕ : QueryException::class; - 837▕ - ➜ 838▕ throw new $exceptionType( - 839▕ $this->getNameWithReadWriteType(), - 840▕ $query, - 841▕ $this->prepareBindings($bindings), - 842▕ $e, - - +8 vendor frames  - 9 tests/Feature/Api/V1/Whatsapp/WhatsappInviteControllerTest.php:134 - 10 tests/Feature/Api/V1/Whatsapp/WhatsappInviteControllerTest.php:81 - - ──────────────────────────────────────────────────────────────────────────── - FAILED Tests\Feature\Api\V1\Whatsapp\WhatsappInviteContr… QueryException - SQLSTATE[23000]: Integrity constraint violation: 19 NOT NULL constraint failed: classSection.semester (Connection: sqlite, Database: :memory:, SQL: insert into "classSection" ("class_section_id", "class_section_name", "class_id", "created_at", "updated_at") values (200, Grade 1, 1, 2026-03-10 20:29:57, 2026-03-10 20:29:57)) - - at vendor/laravel/framework/src/Illuminate/Database/Connection.php:838 - 834▕ $exceptionType = $this->isUniqueConstraintError($e) - 835▕ ? UniqueConstraintViolationException::class - 836▕ : QueryException::class; - 837▕ - ➜ 838▕ throw new $exceptionType( - 839▕ $this->getNameWithReadWriteType(), - 840▕ $query, - 841▕ $this->prepareBindings($bindings), - 842▕ $e, - - +8 vendor frames  - 9 tests/Feature/Api/V1/Whatsapp/WhatsappInviteControllerTest.php:134 - 10 tests/Feature/Api/V1/Whatsapp/WhatsappInviteControllerTest.php:99 - - ──────────────────────────────────────────────────────────────────────────── - FAILED Tests\Feature\Api\V1\Whatsapp\WhatsappLinkControllerTest > store… - Expected response status code [201] but received 422. -Failed asserting that 422 is identical to 201. - -The following errors occurred during the last request: - -{ - "message": "Validation failed.", - "errors": { - "class_section_id": [ - "The class section id field is required." - ], - "invite_link": [ - "The invite link field is required." - ] - } -} - - at tests/Feature/Api/V1/Whatsapp/WhatsappLinkControllerTest.php:78 - 74▕ ]; - 75▕ - 76▕ $response = $this->postJson('/api/v1/whatsapp/links', $payload); - 77▕ - ➜ 78▕ $response->assertStatus(201); - 79▕ $response->assertJsonPath('status', true); - 80▕ $this->assertDatabaseHas('whatsapp_group_links', [ - 81▕ 'class_section_id' => 202, - 82▕ 'invite_link' => 'https://chat.whatsapp.com/new', - - ──────────────────────────────────────────────────────────────────────────── - FAILED Tests\Feature\Api\V1\Whatsapp\WhatsappLinkControllerTest > update… - Failed asserting that a row in the table [whatsapp_group_links] matches the attributes { - "id": 1, - "invite_link": "https:\/\/chat.whatsapp.com\/updated", - "active": 0 -}. - -Found similar results: [ - { - "id": 1, - "invite_link": "https:\/\/chat.whatsapp.com\/old", - "active": 1 - } -]. - - at tests/Feature/Api/V1/Whatsapp/WhatsappLinkControllerTest.php:109 - 105▕ ]); - 106▕ - 107▕ $response->assertOk(); - 108▕ $response->assertJsonPath('status', true); - ➜ 109▕ $this->assertDatabaseHas('whatsapp_group_links', [ - 110▕ 'id' => $linkId, - 111▕ 'invite_link' => 'https://chat.whatsapp.com/updated', - 112▕ 'active' => 0, - 113▕ ]); - - - Tests: 10 failed, 21 passed (52 assertions) - Duration: 7.96s diff --git a/tests/Feature/Api/V1/Auth/SessionTimeoutControllerTest.php b/tests/Feature/Api/V1/Auth/SessionTimeoutControllerTest.php new file mode 100644 index 00000000..26f576c4 --- /dev/null +++ b/tests/Feature/Api/V1/Auth/SessionTimeoutControllerTest.php @@ -0,0 +1,123 @@ +seedUser(); + Sanctum::actingAs($user); + + $response = $this->getJson('/api/v1/session/timeout-config'); + + $response->assertOk(); + $response->assertJsonPath('status', true); + $this->assertArrayHasKey('timeout', $response->json('data.config')); + $this->assertArrayHasKey('keep_alive_url', $response->json('data.config')); + } + + public function test_check_timeout_returns_active_status(): void + { + $user = $this->seedUser(); + Sanctum::actingAs($user); + + $response = $this + ->withSession(['last_activity' => time() - 10]) + ->getJson('/api/v1/session/check-timeout'); + + $response->assertOk(); + $response->assertJsonPath('status', true); + $response->assertJsonPath('data.session.status', 'active'); + } + + public function test_check_timeout_returns_warning_status(): void + { + $user = $this->seedUser(); + Sanctum::actingAs($user); + + $response = $this + ->withSession(['last_activity' => time() - 1000]) + ->getJson('/api/v1/session/check-timeout'); + + $response->assertOk(); + $response->assertJsonPath('status', true); + $response->assertJsonPath('data.session.status', 'warning'); + } + + public function test_check_timeout_returns_expired_status(): void + { + $user = $this->seedUser(); + Sanctum::actingAs($user); + + $response = $this + ->withSession(['last_activity' => time() - 5000]) + ->getJson('/api/v1/session/check-timeout'); + + $response->assertStatus(401); + $response->assertJsonPath('status', false); + $response->assertJsonPath('errors.status', 'expired'); + } + + public function test_ping_updates_activity(): void + { + $user = $this->seedUser(); + Sanctum::actingAs($user); + + $response = $this + ->withSession(['last_activity' => time() - 10]) + ->postJson('/api/v1/session/ping'); + + $response->assertOk(); + $response->assertJsonPath('status', true); + $response->assertJsonPath('data.session.status', 'active'); + } + + public function test_ping_returns_expired_when_missing_activity(): void + { + $user = $this->seedUser(); + Sanctum::actingAs($user); + + $response = $this->postJson('/api/v1/session/ping'); + + $response->assertStatus(401); + $response->assertJsonPath('status', false); + } + + public function test_requires_authentication(): void + { + $response = $this->getJson('/api/v1/session/timeout-config'); + + $response->assertStatus(401); + } + + private function seedUser(): User + { + $userId = DB::table('users')->insertGetId([ + 'firstname' => 'Admin', + 'lastname' => 'User', + 'cellphone' => '9999999999', + 'email' => 'session@example.com', + 'address_street' => '123 Street', + 'city' => 'City', + 'state' => 'ST', + 'zip' => '12345', + 'accept_school_policy' => 1, + 'password' => bcrypt('password'), + 'user_type' => 'primary', + 'semester' => 'Fall', + 'school_year' => '2025-2026', + 'status' => 'Active', + ]); + + return User::query()->findOrFail($userId); + } +} diff --git a/tests/Unit/Services/Auth/SessionActivityServiceTest.php b/tests/Unit/Services/Auth/SessionActivityServiceTest.php new file mode 100644 index 00000000..50a02aeb --- /dev/null +++ b/tests/Unit/Services/Auth/SessionActivityServiceTest.php @@ -0,0 +1,42 @@ +makeSessionStore(); + $service = new SessionActivityService($store); + + $service->setLastActivity(12345); + + $this->assertTrue($service->hasLastActivity()); + $this->assertSame(12345, $service->getLastActivity()); + } + + public function test_clear_last_activity(): void + { + $store = $this->makeSessionStore(); + $service = new SessionActivityService($store); + + $service->setLastActivity(12345); + $service->clearLastActivity(); + + $this->assertFalse($service->hasLastActivity()); + } + + private function makeSessionStore(): Store + { + $handler = new ArraySessionHandler(10); + $store = new Store('test', $handler); + $store->start(); + + return $store; + } +} diff --git a/tests/Unit/Services/Auth/SessionTimeoutConfigServiceTest.php b/tests/Unit/Services/Auth/SessionTimeoutConfigServiceTest.php new file mode 100644 index 00000000..86d9c4c1 --- /dev/null +++ b/tests/Unit/Services/Auth/SessionTimeoutConfigServiceTest.php @@ -0,0 +1,22 @@ +config(); + + $this->assertArrayHasKey('timeout', $config); + $this->assertArrayHasKey('warning_time', $config); + $this->assertArrayHasKey('check_interval', $config); + $this->assertArrayHasKey('logout_url', $config); + $this->assertArrayHasKey('keep_alive_url', $config); + $this->assertArrayHasKey('check_url', $config); + } +} diff --git a/tests/Unit/Services/Auth/SessionTimeoutServiceTest.php b/tests/Unit/Services/Auth/SessionTimeoutServiceTest.php new file mode 100644 index 00000000..8d799e16 --- /dev/null +++ b/tests/Unit/Services/Auth/SessionTimeoutServiceTest.php @@ -0,0 +1,51 @@ +shouldReceive('hasLastActivity')->andReturn(false); + $activity->shouldReceive('clearLastActivity')->once(); + $activity->shouldReceive('invalidate')->once(); + + $service = new SessionTimeoutService($activity); + $result = $service->checkTimeout(); + + $this->assertSame('expired', $result['status']); + } + + public function test_check_timeout_returns_warning(): void + { + $activity = Mockery::mock(SessionActivityService::class); + $activity->shouldReceive('hasLastActivity')->andReturn(true); + $activity->shouldReceive('getLastActivity')->andReturn(time() - (SessionTimeout::WARNING_THRESHOLD + 10)); + + $service = new SessionTimeoutService($activity); + $result = $service->checkTimeout(); + + $this->assertSame('warning', $result['status']); + } + + public function test_ping_returns_expired_when_timed_out(): void + { + $activity = Mockery::mock(SessionActivityService::class); + $activity->shouldReceive('hasLastActivity')->andReturn(true); + $activity->shouldReceive('getLastActivity')->andReturn(time() - (SessionTimeout::TIMEOUT_DURATION + 10)); + $activity->shouldReceive('clearLastActivity')->once(); + $activity->shouldReceive('invalidate')->once(); + + $service = new SessionTimeoutService($activity); + $result = $service->pingActivity(); + + $this->assertSame('expired', $result['status']); + } +}