diff --git a/src/app/Packages/Domains/User/Interface/TokenServiceInterface.php b/src/app/Packages/Domains/User/Interface/TokenServiceInterface.php index c224937..d9719a0 100644 --- a/src/app/Packages/Domains/User/Interface/TokenServiceInterface.php +++ b/src/app/Packages/Domains/User/Interface/TokenServiceInterface.php @@ -9,4 +9,6 @@ interface TokenServiceInterface public function createToken(UserEntity $entity): string; public function revokeAllTokens(UserEntity $entity): void; + + public function revokeCurrentToken(string $plainTextToken): void; } diff --git a/src/app/Packages/Domains/User/Service/SanctumTokenService.php b/src/app/Packages/Domains/User/Service/SanctumTokenService.php index 1c9c84d..2af30c5 100644 --- a/src/app/Packages/Domains/User/Service/SanctumTokenService.php +++ b/src/app/Packages/Domains/User/Service/SanctumTokenService.php @@ -5,6 +5,7 @@ use App\Models\User; use App\Packages\Domains\User\Interface\TokenServiceInterface; use App\Packages\Domains\User\UserEntity; +use Laravel\Sanctum\PersonalAccessToken; use RuntimeException; class SanctumTokenService implements TokenServiceInterface @@ -30,4 +31,15 @@ public function revokeAllTokens(UserEntity $entity): void $user->tokens()->delete(); } + + // Logout lives here rather than in UserRepository because it operates on personal_access_tokens, + // not on user data. No user lookup is needed — the plain-text token alone identifies the session to end. + public function revokeCurrentToken(string $plainTextToken): void + { + $token = PersonalAccessToken::findToken($plainTextToken); + + if ($token !== null) { + $token->delete(); + } + } } diff --git a/src/app/Packages/Domains/User/Tests/Service/SanctumTokenServiceTest.php b/src/app/Packages/Domains/User/Tests/Service/SanctumTokenServiceTest.php index 98db8ee..bd71646 100644 --- a/src/app/Packages/Domains/User/Tests/Service/SanctumTokenServiceTest.php +++ b/src/app/Packages/Domains/User/Tests/Service/SanctumTokenServiceTest.php @@ -133,4 +133,26 @@ public function test_revokeAllTokens_throws_when_user_not_found(): void $this->service()->revokeAllTokens($entity); } + + public function test_revokeCurrentToken_deletes_the_given_token(): void + { + $user = $this->seedUser(); + $plainText = $user->createToken('auth-token')->plainTextToken; + + $this->assertDatabaseHas('personal_access_tokens', [ + 'tokenable_id' => $user->id, + ]); + + $this->service()->revokeCurrentToken($plainText); + + $this->assertDatabaseMissing('personal_access_tokens', [ + 'tokenable_id' => $user->id, + ]); + } + + #[\PHPUnit\Framework\Attributes\DoesNotPerformAssertions] + public function test_revokeCurrentToken_does_nothing_when_token_not_found(): void + { + $this->service()->revokeCurrentToken('invalid-token-that-does-not-exist'); + } } diff --git a/src/app/Packages/Features/CommandUseCases/UseCase/User/LogoutUseCase.php b/src/app/Packages/Features/CommandUseCases/UseCase/User/LogoutUseCase.php new file mode 100644 index 0000000..432496b --- /dev/null +++ b/src/app/Packages/Features/CommandUseCases/UseCase/User/LogoutUseCase.php @@ -0,0 +1,17 @@ +tokenService->revokeCurrentToken($plainTextToken); + } +} diff --git a/src/app/Packages/Features/Controller/AuthController.php b/src/app/Packages/Features/Controller/AuthController.php index b7c18d1..40ef398 100644 --- a/src/app/Packages/Features/Controller/AuthController.php +++ b/src/app/Packages/Features/Controller/AuthController.php @@ -4,6 +4,7 @@ use App\Http\Controllers\Controller; use App\Packages\Features\CommandUseCases\UseCase\User\LoginUseCase; +use App\Packages\Features\CommandUseCases\UseCase\User\LogoutUseCase; use Illuminate\Http\JsonResponse; use Illuminate\Http\Request; use Illuminate\Support\Facades\Log; @@ -46,4 +47,25 @@ public function login( ], 500); } } + + public function logout(Request $request, LogoutUseCase $useCase): JsonResponse + { + try { + $useCase->handle($request->bearerToken() ?? ''); + + return response()->json([ + 'status' => 'success', + ], 200); + } catch (Throwable $throw) { + Log::error('Failed to logout', [ + 'message' => $throw->getMessage(), + 'trace' => $throw->getTraceAsString(), + ]); + + return response()->json([ + 'status' => 'error', + 'message' => 'Internal Server Error', + ], 500); + } + } } diff --git a/src/app/Packages/Features/Tests/CommandUseCases/LogoutUseCaseTest.php b/src/app/Packages/Features/Tests/CommandUseCases/LogoutUseCaseTest.php new file mode 100644 index 0000000..ad1b7d9 --- /dev/null +++ b/src/app/Packages/Features/Tests/CommandUseCases/LogoutUseCaseTest.php @@ -0,0 +1,45 @@ +shouldReceive('revokeCurrentToken') + ->once() + ->with('plain-text-token'); + + (new LogoutUseCase($tokenService))->handle('plain-text-token'); + } + + public function test_handle_propagates_exception_when_revocation_fails(): void + { + /** @var TokenServiceInterface|MockInterface $tokenService */ + $tokenService = Mockery::mock(TokenServiceInterface::class); + $tokenService->shouldReceive('revokeCurrentToken') + ->once() + ->andThrow(new RuntimeException('Revocation failed.')); + + $this->expectException(RuntimeException::class); + $this->expectExceptionMessage('Revocation failed.'); + + (new LogoutUseCase($tokenService))->handle('plain-text-token'); + } +} diff --git a/src/app/Packages/Features/Tests/LogoutTest.php b/src/app/Packages/Features/Tests/LogoutTest.php new file mode 100644 index 0000000..ac09939 --- /dev/null +++ b/src/app/Packages/Features/Tests/LogoutTest.php @@ -0,0 +1,89 @@ +truncate(); + } + + protected function tearDown(): void + { + $this->truncate(); + parent::tearDown(); + } + + private function truncate(): void + { + if (env('APP_ENV') === 'testing') { + DB::connection('mysql')->statement('SET FOREIGN_KEY_CHECKS=0;'); + DB::connection('mysql')->table('personal_access_tokens')->truncate(); + User::truncate(); + DB::connection('mysql')->statement('SET FOREIGN_KEY_CHECKS=1;'); + } + } + + private function seedUser(): User + { + return User::create([ + 'first_name' => 'John', + 'last_name' => 'Doe', + 'email' => 'john@example.com', + 'password' => bcrypt('secret123'), + 'age_range' => '20s', + 'subscription_tier' => 'free', + 'subscription_expires_at' => null, + ]); + } + + public function test_logout_returns_200_when_authenticated(): void + { + $user = $this->seedUser(); + $token = $user->createToken('auth-token')->plainTextToken; + + $response = $this->withToken($token) + ->postJson('/api/v1/user/logout'); + + $response->assertStatus(200) + ->assertJsonFragment(['status' => 'success']); + + $this->assertDatabaseMissing('personal_access_tokens', [ + 'tokenable_id' => $user->id, + ]); + } + + public function test_logout_returns_401_when_unauthenticated(): void + { + $response = $this->postJson('/api/v1/user/logout'); + + $response->assertStatus(401); + } + + public function test_logout_returns_500_on_unexpected_error(): void + { + $user = $this->seedUser(); + $token = $user->createToken('auth-token')->plainTextToken; + + $this->mock(LogoutUseCase::class) + ->shouldReceive('handle') + ->andThrow(new RuntimeException('Unexpected error')); + + $response = $this->withToken($token) + ->postJson('/api/v1/user/logout'); + + $response->assertStatus(500) + ->assertJsonFragment([ + 'status' => 'error', + 'message' => 'Internal Server Error', + ]); + } +} diff --git a/src/routes/api.php b/src/routes/api.php index d499e71..3b734cc 100644 --- a/src/routes/api.php +++ b/src/routes/api.php @@ -14,7 +14,11 @@ }); Route::post('/user/create', [UserController::class, 'createUser']); - Route::post('/user/login', [AuthController::class, 'login']); + + Route::controller(AuthController::class)->prefix('user')->group(function (): void { + Route::post('/login', 'login'); + Route::post('/logout', 'logout')->middleware('auth:sanctum'); + }); Route::controller(UserController::class)->prefix('users')->group(function (): void { Route::get('/{id}', 'getUserById');