diff --git a/.github/workflows/static_analysis.yml b/.github/workflows/static_analysis.yml new file mode 100644 index 0000000..42a7049 --- /dev/null +++ b/.github/workflows/static_analysis.yml @@ -0,0 +1,41 @@ +name: Static Analysis + +on: + pull_request: + types: [opened, synchronize] + paths: + - "src/**/*.php" + workflow_dispatch: {} + +jobs: + mago: + runs-on: ubuntu-latest + timeout-minutes: 5 + + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Setup PHP + uses: shivammathur/setup-php@v2 + with: + php-version: "8.2" + coverage: none + tools: composer + + - name: Cache Composer dependencies + uses: actions/cache@v4 + with: + path: ~/.cache/composer + key: ${{ runner.os }}-composer-${{ hashFiles('src/composer.lock') }} + + - name: Install dependencies + run: composer install --prefer-dist --no-progress + working-directory: ./src + + - name: Setup Mago + uses: nhedger/setup-mago@v1 + + - name: Run Mago lint + run: ./vendor/bin/mago lint + working-directory: ./src \ No newline at end of file diff --git a/src/.gitignore b/src/.gitignore index c4b6a95..453f061 100644 --- a/src/.gitignore +++ b/src/.gitignore @@ -1,4 +1,5 @@ /.phpunit.cache +/.composer /node_modules /public/build /public/hot diff --git a/src/app/Models/User.php b/src/app/Models/User.php index 4eb13ff..741315d 100644 --- a/src/app/Models/User.php +++ b/src/app/Models/User.php @@ -3,9 +3,11 @@ namespace App\Models; use Illuminate\Foundation\Auth\User as Authenticatable; +use Laravel\Sanctum\HasApiTokens; class User extends Authenticatable { + use HasApiTokens; protected $table = 'users'; protected $fillable = [ diff --git a/src/app/Packages/Domains/User/Factory/UserEntityFactory.php b/src/app/Packages/Domains/User/Factory/UserEntityFactory.php index 5f7825e..5082d3f 100644 --- a/src/app/Packages/Domains/User/Factory/UserEntityFactory.php +++ b/src/app/Packages/Domains/User/Factory/UserEntityFactory.php @@ -5,6 +5,7 @@ use App\Packages\Domains\User\Factory\SubscriptionFactory; use App\Packages\Domains\User\UserEntity; use App\Packages\Domains\User\AgeRange; +use App\Packages\Domains\User\ValueObject\Email; final class UserEntityFactory { @@ -20,7 +21,7 @@ public static function build(array $data): UserEntity id: isset($data['id']) ? (int) $data['id'] : null, firstName: $data['first_name'], lastName: $data['last_name'], - email: $data['email'], + email: new Email($data['email']), ageRange: AgeRange::from($data['age_range']), subscription: $subscription, passwordHash: $data['password_hash'] ?? null, diff --git a/src/app/Packages/Domains/User/Interface/TokenServiceInterface.php b/src/app/Packages/Domains/User/Interface/TokenServiceInterface.php new file mode 100644 index 0000000..c224937 --- /dev/null +++ b/src/app/Packages/Domains/User/Interface/TokenServiceInterface.php @@ -0,0 +1,12 @@ +getId()); + + if ($user === null) { + throw new RuntimeException('User not found.'); + } + + return $user->createToken('auth-token')->plainTextToken; + } + + public function revokeAllTokens(UserEntity $entity): void + { + $user = User::find($entity->getId()); + + if ($user === null) { + throw new RuntimeException('User not found.'); + } + + $user->tokens()->delete(); + } +} diff --git a/src/app/Packages/Domains/User/Tests/Factory/UserEntityFactoryTest.php b/src/app/Packages/Domains/User/Tests/Factory/UserEntityFactoryTest.php index a26b6bd..c5649cf 100644 --- a/src/app/Packages/Domains/User/Tests/Factory/UserEntityFactoryTest.php +++ b/src/app/Packages/Domains/User/Tests/Factory/UserEntityFactoryTest.php @@ -30,7 +30,7 @@ public function test_build_returns_user_entity_with_correct_values(): void $this->assertSame($data['id'], $entity->getId()); $this->assertSame($data['first_name'], $entity->getFirstName()); $this->assertSame($data['last_name'], $entity->getLastName()); - $this->assertSame($data['email'], $entity->getEmail()); + $this->assertSame($data['email'], $entity->getEmail()->value()); $this->assertSame(AgeRange::Teens, $entity->getAgeRange()); $this->assertTrue($entity->getSubscription()->isFree()); $this->assertNull($entity->getPasswordHash()); diff --git a/src/app/Packages/Domains/User/Tests/Service/SanctumTokenServiceTest.php b/src/app/Packages/Domains/User/Tests/Service/SanctumTokenServiceTest.php new file mode 100644 index 0000000..98db8ee --- /dev/null +++ b/src/app/Packages/Domains/User/Tests/Service/SanctumTokenServiceTest.php @@ -0,0 +1,136 @@ +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 service(): SanctumTokenService + { + return new SanctumTokenService(); + } + + private function seedUser(array $overrides = []): User + { + $faker = FakerFactory::create(); + + return User::create(array_merge([ + 'first_name' => $faker->firstName(), + 'last_name' => $faker->lastName(), + 'email' => $faker->unique()->safeEmail(), + 'password' => bcrypt('password'), + 'age_range' => 'teens', + 'subscription_tier' => 'free', + 'subscription_expires_at' => null, + ], $overrides)); + } + + private function buildEntityFromUser(User $user): UserEntity + { + return UserEntityFactory::build([ + 'id' => $user->id, + 'first_name' => $user->first_name, + 'last_name' => $user->last_name, + 'email' => $user->email, + 'age_range' => $user->age_range, + 'subscription_tier' => $user->subscription_tier, + 'subscription_expires_at' => $user->subscription_expires_at, + 'password_hash' => $user->password, + ]); + } + + public function test_createToken_returns_plain_text_token(): void + { + $user = $this->seedUser(); + $entity = $this->buildEntityFromUser($user); + + $token = $this->service()->createToken($entity); + + $this->assertIsString($token); + $this->assertNotEmpty($token); + $this->assertDatabaseHas('personal_access_tokens', [ + 'tokenable_id' => $user->id, + 'tokenable_type' => User::class, + ]); + } + + public function test_createToken_throws_when_user_not_found(): void + { + $entity = UserEntityFactory::build([ + 'id' => 999999, + 'first_name' => 'Ghost', + 'last_name' => 'User', + 'email' => 'ghost@example.com', + 'age_range' => 'teens', + 'subscription_tier' => 'free', + 'subscription_expires_at' => null, + ]); + + $this->expectException(\RuntimeException::class); + $this->expectExceptionMessage('User not found.'); + + $this->service()->createToken($entity); + } + + public function test_revokeAllTokens_deletes_tokens_for_user(): void + { + $user = $this->seedUser(); + $entity = $this->buildEntityFromUser($user); + + $user->createToken('token-1'); + $user->createToken('token-2'); + + $this->service()->revokeAllTokens($entity); + + $this->assertDatabaseMissing('personal_access_tokens', [ + 'tokenable_id' => $user->id, + ]); + } + + public function test_revokeAllTokens_throws_when_user_not_found(): void + { + $entity = UserEntityFactory::build([ + 'id' => 999999, + 'first_name' => 'Ghost', + 'last_name' => 'User', + 'email' => 'ghost@example.com', + 'age_range' => 'teens', + 'subscription_tier' => 'free', + 'subscription_expires_at' => null, + ]); + + $this->expectException(\RuntimeException::class); + $this->expectExceptionMessage('User not found.'); + + $this->service()->revokeAllTokens($entity); + } +} diff --git a/src/app/Packages/Domains/User/Tests/UserEntityTest.php b/src/app/Packages/Domains/User/Tests/UserEntityTest.php index 13b4cb5..5211af0 100644 --- a/src/app/Packages/Domains/User/Tests/UserEntityTest.php +++ b/src/app/Packages/Domains/User/Tests/UserEntityTest.php @@ -6,6 +6,7 @@ use App\Packages\Domains\User\Subscription\Subscription; use App\Packages\Domains\User\Subscription\SubscriptionTier; use App\Packages\Domains\User\UserEntity; +use App\Packages\Domains\User\ValueObject\Email; use Faker\Factory as FakerFactory; use PHPUnit\Framework\TestCase; @@ -20,7 +21,7 @@ private function buildEntity(array $overrides = []): UserEntity id: array_key_exists('id', $overrides) ? $overrides['id'] : $faker->unique()->randomNumber(5), firstName: $overrides['first_name'] ?? $faker->firstName(), lastName: $overrides['last_name'] ?? $faker->lastName(), - email: $overrides['email'] ?? $faker->unique()->safeEmail(), + email: isset($overrides['email']) ? new Email($overrides['email']) : new Email($faker->unique()->safeEmail()), ageRange: $overrides['age_range'] ?? AgeRange::Twenties, subscription: $overrides['subscription'] ?? $subscription, passwordHash: $overrides['password_hash'] ?? null, @@ -60,7 +61,7 @@ public function test_getFullName_concatenates_first_and_last_name(): void public function test_getEmail_returns_correct_value(): void { $entity = $this->buildEntity(['email' => 'john@example.com']); - $this->assertSame('john@example.com', $entity->getEmail()); + $this->assertSame('john@example.com', $entity->getEmail()->value()); } public function test_getAgeRange_returns_correct_enum(): void diff --git a/src/app/Packages/Domains/User/Tests/UserRepositoryTest.php b/src/app/Packages/Domains/User/Tests/UserRepositoryTest.php index 4cc6bed..839e1b5 100644 --- a/src/app/Packages/Domains/User/Tests/UserRepositoryTest.php +++ b/src/app/Packages/Domains/User/Tests/UserRepositoryTest.php @@ -9,6 +9,7 @@ use App\Packages\Domains\User\Subscription\SubscriptionTier; use App\Packages\Domains\User\UserEntity; use App\Packages\Domains\User\UserRepository; +use App\Packages\Domains\User\ValueObject\Email; use App\Packages\Features\QueryUseCases\Dto\User\UserDto; use Faker\Factory as FakerFactory; use Illuminate\Support\Facades\DB; @@ -138,4 +139,38 @@ public function test_deleteUser_throws_exception_when_user_not_found(): void $this->repository()->deleteUser(999999); } + + public function test_findByEmail_returns_user_entity_when_user_exists(): void + { + $user = $this->seedUser(['email' => 'john@example.com']); + + $result = $this->repository()->findByEmail(new Email('john@example.com')); + + $this->assertInstanceOf(UserEntity::class, $result); + $this->assertSame($user->email, $result->getEmail()->value()); + $this->assertSame($user->first_name, $result->getFirstName()); + $this->assertSame($user->last_name, $result->getLastName()); + } + + public function test_findByEmail_returns_null_when_user_does_not_exist(): void + { + $result = $this->repository()->findByEmail(new Email('notfound@example.com')); + + $this->assertNull($result); + } + + public function test_findByEmail_returns_correct_password_hash(): void + { + $plainPassword = 'secret123'; + $this->seedUser([ + 'email' => 'hash@example.com', + 'password' => bcrypt($plainPassword), + ]); + + $result = $this->repository()->findByEmail(new Email('hash@example.com')); + + $this->assertNotNull($result->getPasswordHash()); + $this->assertTrue(password_verify($plainPassword, $result->getPasswordHash())); + } + } \ No newline at end of file diff --git a/src/app/Packages/Domains/User/Tests/ValueObject/EmailTest.php b/src/app/Packages/Domains/User/Tests/ValueObject/EmailTest.php new file mode 100644 index 0000000..4861109 --- /dev/null +++ b/src/app/Packages/Domains/User/Tests/ValueObject/EmailTest.php @@ -0,0 +1,41 @@ +assertSame('user@example.com', $email->value()); + } + + public function test_constructor_throws_on_invalid_email(): void + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('Invalid email format'); + + new Email('not-an-email'); + } + + public function test_isEqual_returns_true_when_emails_match(): void + { + $email = new Email('user@example.com'); + $otherEmail = new Email('user@example.com'); + + $this->assertTrue($email->isEqual($otherEmail)); + } + + public function test_isEqual_returns_false_when_emails_differ(): void + { + $email = new Email('user@example.com'); + $otherEmail = new Email('other@example.com'); + + $this->assertFalse($email->isEqual($otherEmail)); + } +} diff --git a/src/app/Packages/Domains/User/UserEntity.php b/src/app/Packages/Domains/User/UserEntity.php index a8f517c..7caa778 100644 --- a/src/app/Packages/Domains/User/UserEntity.php +++ b/src/app/Packages/Domains/User/UserEntity.php @@ -3,6 +3,7 @@ namespace App\Packages\Domains\User; use App\Packages\Domains\User\Subscription\Subscription; +use App\Packages\Domains\User\ValueObject\Email; final readonly class UserEntity { @@ -10,7 +11,7 @@ public function __construct( private readonly ?int $id, private readonly string $firstName, private readonly string $lastName, - private readonly string $email, + private readonly Email $email, private readonly AgeRange $ageRange, private readonly Subscription $subscription, private readonly ?string $passwordHash = null, @@ -41,7 +42,7 @@ public function getFullName(): string return $this->firstName . ' ' . $this->lastName; } - public function getEmail(): string + public function getEmail(): Email { return $this->email; } diff --git a/src/app/Packages/Domains/User/UserRepository.php b/src/app/Packages/Domains/User/UserRepository.php index a3b8dee..43d1657 100644 --- a/src/app/Packages/Domains/User/UserRepository.php +++ b/src/app/Packages/Domains/User/UserRepository.php @@ -5,6 +5,8 @@ use App\Models\User; use App\Packages\Domains\User\Interface\UserRepositroyInterface; use App\Packages\Domains\User\UserEntity; +use App\Packages\Domains\User\ValueObject\Email; +use App\Packages\Domains\User\Factory\UserEntityFactory; use App\Packages\Features\QueryUseCases\Dto\User\UserDto; use App\Packages\Features\QueryUseCases\Factory\Dto\UserDtoFactory; use Exception; @@ -18,7 +20,7 @@ public function __construct( public function createUser(UserEntity $entity): UserDto { $insertUser = $this->userModel->create([ - 'email' => $entity->getEmail(), + 'email' => $entity->getEmail()->value(), 'first_name' => $entity->getFirstName(), 'last_name' => $entity->getLastName(), 'password' => $entity->getPasswordHash(), @@ -56,7 +58,7 @@ public function updateUser(UserEntity $entity): UserDto $user->update([ 'first_name' => $entity->getFirstName(), 'last_name' => $entity->getLastName(), - 'email' => $entity->getEmail(), + 'email' => $entity->getEmail()->value(), 'age_range' => $entity->getAgeRange()->value, 'subscription_tier' => $entity->getSubscription()->getTier()->value, 'subscription_expires_at' => $entity->getSubscription()->getExpiresAt()?->format('Y-m-d H:i:s'), @@ -77,4 +79,24 @@ public function deleteUser(int $id): void $user->delete(); } + + public function findByEmail(Email $email): ?UserEntity + { + $user = $this->userModel->where('email', $email->value())->first(); + + if ($user === null) { + return null; + } + + return UserEntityFactory::build([ + 'id' => $user->id, + 'first_name' => $user->first_name, + 'last_name' => $user->last_name, + 'email' => $user->email, + 'age_range' => $user->age_range, + 'subscription_tier' => $user->subscription_tier, + 'subscription_expires_at' => $user->subscription_expires_at, + 'password_hash' => $user->password, + ]); + } } \ No newline at end of file diff --git a/src/app/Packages/Domains/User/ValueObject/Email.php b/src/app/Packages/Domains/User/ValueObject/Email.php new file mode 100644 index 0000000..4c8eecc --- /dev/null +++ b/src/app/Packages/Domains/User/ValueObject/Email.php @@ -0,0 +1,27 @@ +value; + } + + public function isEqual(self $otherEmail): bool + { + return $this->value === $otherEmail->value; + } +} \ No newline at end of file diff --git a/src/app/Packages/Features/CommandUseCases/UseCase/User/LoginUseCase.php b/src/app/Packages/Features/CommandUseCases/UseCase/User/LoginUseCase.php new file mode 100644 index 0000000..e6207e9 --- /dev/null +++ b/src/app/Packages/Features/CommandUseCases/UseCase/User/LoginUseCase.php @@ -0,0 +1,34 @@ +userRepository->findByEmail(new Email($email)); + + if ($entity === null) { + throw new InvalidArgumentException('Invalid credentials.'); + } + + if (!password_verify($password, $entity->getPasswordHash())) { + throw new InvalidArgumentException('Invalid user data. Please try again.'); + } + + $token = $this->tokenService->createToken($entity); + + return new AuthTokenDto(token: $token); + } +} diff --git a/src/app/Packages/Features/Controller/AuthController.php b/src/app/Packages/Features/Controller/AuthController.php new file mode 100644 index 0000000..b7c18d1 --- /dev/null +++ b/src/app/Packages/Features/Controller/AuthController.php @@ -0,0 +1,49 @@ +handle( + email: $request->input('email'), + password: $request->input('password'), + ); + + return response()->json([ + 'status' => 'success', + 'data' => [ + 'token' => $dto->token, + 'token_type' => $dto->tokenType, + ], + ], 200); + } catch (InvalidArgumentException $exception) { + return response()->json([ + 'status' => 'error', + 'message' => $exception->getMessage(), + ], 401); + } catch (Throwable $throw) { + Log::error('Failed to login', [ + 'message' => $throw->getMessage(), + 'trace' => $throw->getTraceAsString(), + ]); + + return response()->json([ + 'status' => 'error', + 'message' => 'Internal Server Error', + ], 500); + } + } +} diff --git a/src/app/Packages/Features/QueryUseCases/Dto/User/AuthTokenDto.php b/src/app/Packages/Features/QueryUseCases/Dto/User/AuthTokenDto.php new file mode 100644 index 0000000..0524ed3 --- /dev/null +++ b/src/app/Packages/Features/QueryUseCases/Dto/User/AuthTokenDto.php @@ -0,0 +1,11 @@ + 1, + 'first_name' => 'John', + 'last_name' => 'Doe', + 'email' => $email, + 'age_range' => '20s', + 'subscription_tier' => 'free', + 'subscription_expires_at' => null, + 'password_hash' => $passwordHash ?? password_hash('secret', PASSWORD_BCRYPT), + ]); + } + + public function test_handle_returns_token_dto_on_valid_credentials(): void + { + $entity = $this->buildEntity(); + + /** @var UserRepositroyInterface|MockInterface $userRepository */ + $userRepository = Mockery::mock(UserRepositroyInterface::class); + $userRepository->shouldReceive('findByEmail') + ->once() + ->andReturn($entity); + + /** @var TokenServiceInterface|MockInterface $tokenService */ + $tokenService = Mockery::mock(TokenServiceInterface::class); + $tokenService->shouldReceive('createToken') + ->once() + ->with($entity) + ->andReturn('plain-text-token'); + + $result = (new LoginUseCase($userRepository, $tokenService))->handle('john@example.com', 'secret'); + + $this->assertInstanceOf(AuthTokenDto::class, $result); + $this->assertSame('plain-text-token', $result->token); + $this->assertSame('Bearer', $result->tokenType); + } + + public function test_handle_throws_when_user_not_found(): void + { + /** @var UserRepositroyInterface|MockInterface $userRepository */ + $userRepository = Mockery::mock(UserRepositroyInterface::class); + $userRepository->shouldReceive('findByEmail') + ->once() + ->andReturn(null); + + /** @var TokenServiceInterface|MockInterface $tokenService */ + $tokenService = Mockery::mock(TokenServiceInterface::class); + $tokenService->shouldNotReceive('createToken'); + + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('Invalid credentials.'); + + (new LoginUseCase($userRepository, $tokenService))->handle('john@example.com', 'secret'); + } + + public function test_handle_throws_on_wrong_password(): void + { + $entity = $this->buildEntity(); + + /** @var UserRepositroyInterface|MockInterface $userRepository */ + $userRepository = Mockery::mock(UserRepositroyInterface::class); + $userRepository->shouldReceive('findByEmail') + ->once() + ->andReturn($entity); + + /** @var TokenServiceInterface|MockInterface $tokenService */ + $tokenService = Mockery::mock(TokenServiceInterface::class); + $tokenService->shouldNotReceive('createToken'); + + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('Invalid user data. Please try again.'); + + (new LoginUseCase($userRepository, $tokenService))->handle('john@example.com', 'wrong-password'); + } +} diff --git a/src/app/Packages/Features/Tests/LoginTest.php b/src/app/Packages/Features/Tests/LoginTest.php new file mode 100644 index 0000000..5559375 --- /dev/null +++ b/src/app/Packages/Features/Tests/LoginTest.php @@ -0,0 +1,109 @@ +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(string $email, string $password): User + { + return User::create([ + 'first_name' => 'John', + 'last_name' => 'Doe', + 'email' => $email, + 'password' => bcrypt($password), + 'age_range' => '20s', + 'subscription_tier' => 'free', + 'subscription_expires_at' => null, + ]); + } + + public function test_login_returns_200_with_token_on_valid_credentials(): void + { + $this->seedUser('john@example.com', 'secret123'); + + $response = $this->postJson('/api/v1/user/login', [ + 'email' => 'john@example.com', + 'password' => 'secret123', + ]); + + $response->assertStatus(200) + ->assertJsonStructure([ + 'status', + 'data' => ['token', 'token_type'], + ]) + ->assertJsonFragment([ + 'status' => 'success', + 'token_type' => 'Bearer', + ]); + } + + public function test_login_returns_401_when_user_not_found(): void + { + $response = $this->postJson('/api/v1/user/login', [ + 'email' => 'notfound@example.com', + 'password' => 'secret123', + ]); + + $response->assertStatus(401) + ->assertJsonFragment(['status' => 'error']); + } + + public function test_login_returns_401_on_wrong_password(): void + { + $this->seedUser('john@example.com', 'secret123'); + + $response = $this->postJson('/api/v1/user/login', [ + 'email' => 'john@example.com', + 'password' => 'wrong-password', + ]); + + $response->assertStatus(401) + ->assertJsonFragment(['status' => 'error']); + } + + public function test_login_returns_500_on_unexpected_error(): void + { + $this->mock(LoginUseCase::class) + ->shouldReceive('handle') + ->andThrow(new RuntimeException('Unexpected error')); + + $response = $this->postJson('/api/v1/user/login', [ + 'email' => 'john@example.com', + 'password' => 'secret123', + ]); + + $response->assertStatus(500) + ->assertJsonFragment([ + 'status' => 'error', + 'message' => 'Internal Server Error', + ]); + } +} diff --git a/src/app/Providers/AppServiceProvider.php b/src/app/Providers/AppServiceProvider.php index f49b0e4..a12b9fe 100644 --- a/src/app/Providers/AppServiceProvider.php +++ b/src/app/Providers/AppServiceProvider.php @@ -3,6 +3,8 @@ namespace App\Providers; use Algolia\AlgoliaSearch\Api\SearchClient; +use App\Packages\Domains\User\Interface\TokenServiceInterface; +use App\Packages\Domains\User\Service\SanctumTokenService; use App\Packages\Domains\WorldHeritage\Adapter\AlgoliaWorldHeritageSearchAdapter; use App\Packages\Domains\WorldHeritage\Ports\WorldHeritageSearchPort; use Google\Cloud\Storage\StorageClient; @@ -22,6 +24,8 @@ public function register(): void { // We bind via a factory closure (instead of a simple class binding) because: // - The adapter needs runtime configuration (Algolia app id / API key / index name) + $this->app->bind(TokenServiceInterface::class, SanctumTokenService::class); + $this->app->bind(WorldHeritageSearchPort::class, function () { $client = SearchClient::create(config('algolia.algolia_app_id'), config('algolia.algolia_search_api_key')); diff --git a/src/composer.json b/src/composer.json index 49f6da7..c82bc29 100644 --- a/src/composer.json +++ b/src/composer.json @@ -12,6 +12,7 @@ "algolia/algoliasearch-client-php": "^4.0", "google/cloud-storage": "^1", "laravel/framework": "^11.31", + "laravel/sanctum": "^4.3", "laravel/tinker": "^2.9", "league/flysystem-google-cloud-storage": "^3", "symfony/intl": "^7.4" diff --git a/src/composer.lock b/src/composer.lock index 1aabe4a..68421a8 100644 --- a/src/composer.lock +++ b/src/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "68110e920a7d7e2ac88297daac555d38", + "content-hash": "3720ffca039e682d60884862ba8486cd", "packages": [ { "name": "algolia/algoliasearch-client-php", @@ -1935,6 +1935,69 @@ }, "time": "2026-02-06T12:17:10+00:00" }, + { + "name": "laravel/sanctum", + "version": "v4.3.2", + "source": { + "type": "git", + "url": "https://github.com/laravel/sanctum.git", + "reference": "2a9bccc18e9907808e0018dd15fa643937886b1e" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/laravel/sanctum/zipball/2a9bccc18e9907808e0018dd15fa643937886b1e", + "reference": "2a9bccc18e9907808e0018dd15fa643937886b1e", + "shasum": "" + }, + "require": { + "ext-json": "*", + "illuminate/console": "^11.0|^12.0|^13.0", + "illuminate/contracts": "^11.0|^12.0|^13.0", + "illuminate/database": "^11.0|^12.0|^13.0", + "illuminate/support": "^11.0|^12.0|^13.0", + "php": "^8.2", + "symfony/console": "^7.0|^8.0" + }, + "require-dev": { + "mockery/mockery": "^1.6", + "orchestra/testbench": "^9.15|^10.8|^11.0", + "phpstan/phpstan": "^1.10" + }, + "type": "library", + "extra": { + "laravel": { + "providers": [ + "Laravel\\Sanctum\\SanctumServiceProvider" + ] + } + }, + "autoload": { + "psr-4": { + "Laravel\\Sanctum\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Taylor Otwell", + "email": "taylor@laravel.com" + } + ], + "description": "Laravel Sanctum provides a featherweight authentication system for SPAs and simple APIs.", + "keywords": [ + "auth", + "laravel", + "sanctum" + ], + "support": { + "issues": "https://github.com/laravel/sanctum/issues", + "source": "https://github.com/laravel/sanctum" + }, + "time": "2026-04-30T11:46:25+00:00" + }, { "name": "laravel/serializable-closure", "version": "v2.0.10", diff --git a/src/config/sanctum.php b/src/config/sanctum.php new file mode 100644 index 0000000..ff9ca03 --- /dev/null +++ b/src/config/sanctum.php @@ -0,0 +1,87 @@ + explode(',', env('SANCTUM_STATEFUL_DOMAINS', sprintf( + '%s%s', + 'localhost,localhost:3000,127.0.0.1,127.0.0.1:8000,::1', + Sanctum::currentApplicationUrlWithPort(), + // Sanctum::currentRequestHost(), + ))), + + /* + |-------------------------------------------------------------------------- + | Sanctum Guards + |-------------------------------------------------------------------------- + | + | This array contains the authentication guards that will be checked when + | Sanctum is trying to authenticate a request. If none of these guards + | are able to authenticate the request, Sanctum will use the bearer + | token that's present on an incoming request for authentication. + | + */ + + 'guard' => ['web'], + + /* + |-------------------------------------------------------------------------- + | Expiration Minutes + |-------------------------------------------------------------------------- + | + | This value controls the number of minutes until an issued token will be + | considered expired. This will override any values set in the token's + | "expires_at" attribute, but first-party sessions are not affected. + | + */ + + 'expiration' => 1440, + + /* + |-------------------------------------------------------------------------- + | Token Prefix + |-------------------------------------------------------------------------- + | + | Sanctum can prefix new tokens in order to take advantage of numerous + | security scanning initiatives maintained by open source platforms + | that notify developers if they commit tokens into repositories. + | + | See: https://docs.github.com/en/code-security/secret-scanning/about-secret-scanning + | + */ + + 'token_prefix' => env('SANCTUM_TOKEN_PREFIX', ''), + + /* + |-------------------------------------------------------------------------- + | Sanctum Middleware + |-------------------------------------------------------------------------- + | + | When authenticating your first-party SPA with Sanctum you may need to + | customize some of the middleware Sanctum uses while processing the + | request. You may change the middleware listed below as required. + | + */ + + 'middleware' => [ + 'authenticate_session' => AuthenticateSession::class, + 'encrypt_cookies' => EncryptCookies::class, + 'validate_csrf_token' => ValidateCsrfToken::class, + ], + +]; diff --git a/src/database/migrations/2026_07_13_091936_create_personal_access_tokens_table.php b/src/database/migrations/2026_07_13_091936_create_personal_access_tokens_table.php new file mode 100644 index 0000000..40ff706 --- /dev/null +++ b/src/database/migrations/2026_07_13_091936_create_personal_access_tokens_table.php @@ -0,0 +1,33 @@ +id(); + $table->morphs('tokenable'); + $table->text('name'); + $table->string('token', 64)->unique(); + $table->text('abilities')->nullable(); + $table->timestamp('last_used_at')->nullable(); + $table->timestamp('expires_at')->nullable()->index(); + $table->timestamps(); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('personal_access_tokens'); + } +}; diff --git a/src/mago.toml b/src/mago.toml new file mode 100644 index 0000000..1914325 --- /dev/null +++ b/src/mago.toml @@ -0,0 +1,186 @@ +[source] +paths = ["app/Packages"] + +[linter] +excludes = ["database/migrations"] + +# --- 無効化: スタイル・冗長性系(厳しすぎるため外す) --- +[linter.rules.strict-types] +enabled = false + +[linter.rules.no-else-clause] +enabled = false + +[linter.rules.no-redundant-write-visibility] +enabled = false + +[linter.rules.no-redundant-method-override] +enabled = false + +[linter.rules.no-redundant-parentheses] +enabled = false + +[linter.rules.no-redundant-string-concat] +enabled = false + +[linter.rules.no-redundant-isset] +enabled = false + +[linter.rules.no-redundant-final] +enabled = false + +[linter.rules.no-redundant-readonly] +enabled = false + +[linter.rules.no-redundant-block] +enabled = false + +[linter.rules.no-redundant-continue] +enabled = false + +[linter.rules.no-redundant-use] +enabled = false + +[linter.rules.no-redundant-nullsafe] +enabled = false + +[linter.rules.no-redundant-label] +enabled = false + +[linter.rules.no-redundant-literal-return] +enabled = false + +[linter.rules.prefer-arrow-function] +enabled = false + +[linter.rules.prefer-early-continue] +enabled = false + +[linter.rules.prefer-static-closure] +enabled = false + +[linter.rules.inline-variable-return] +enabled = false + +[linter.rules.no-boolean-flag-parameter] +enabled = false + +[linter.rules.no-isset] +enabled = false + +[linter.rules.halstead] +enabled = false + +[linter.rules.readable-literal] +enabled = false + +[linter.rules.literal-named-argument] +enabled = false + +[linter.rules.no-shorthand-ternary] +enabled = false + +[linter.rules.kan-defect] +enabled = false + +[linter.rules.sensitive-parameter] +enabled = false + +[linter.rules.explicit-nullable-param] +enabled = false + +[linter.rules.tagged-todo] +enabled = false + +[linter.rules.tagged-fixme] +enabled = false + +[linter.rules.valid-docblock] +enabled = false + +[linter.rules.no-trailing-space] +enabled = false + +[linter.rules.no-closing-tag] +enabled = false + +[linter.rules.no-php-tag-terminator] +enabled = false + +[linter.rules.prefer-first-class-callable] +enabled = false + +[linter.rules.no-nested-ternary] +enabled = false + +[linter.rules.no-empty] +enabled = false + +[linter.rules.block-statement] +enabled = false + +[linter.rules.class-name] +enabled = false + +# --- 有効化: バグ・セキュリティ系 --- +[linter.rules.no-eval] +enabled = true +level = "Error" + +[linter.rules.no-global] +enabled = true +level = "Error" + +[linter.rules.no-error-control-operator] +enabled = true +level = "Error" + +[linter.rules.no-empty-catch-clause] +enabled = true +level = "Error" + +[linter.rules.no-insecure-comparison] +enabled = true +level = "Error" + +[linter.rules.no-literal-password] +enabled = false + +[linter.rules.no-shell-execute-string] +enabled = true +level = "Error" + +[linter.rules.no-debug-symbols] +enabled = true +level = "Warning" + +[linter.rules.loop-does-not-iterate] +enabled = true +level = "Warning" + +[linter.rules.no-self-assignment] +enabled = true +level = "Warning" + +[linter.rules.no-assign-in-condition] +enabled = true +level = "Warning" + +[linter.rules.identity-comparison] +enabled = true +level = "Warning" + +[linter.rules.cyclomatic-complexity] +enabled = true +level = "Error" +threshold = 60 + +[linter.rules.excessive-parameter-list] +enabled = true +level = "Warning" +threshold = 25 + +[linter.rules.too-many-methods] +enabled = true +level = "Warning" +threshold = 30 diff --git a/src/routes/api.php b/src/routes/api.php index d39dc95..d499e71 100644 --- a/src/routes/api.php +++ b/src/routes/api.php @@ -1,17 +1,24 @@ group(function (): void { - Route::get('/heritages', [WorldHeritageController::class, 'getWorldHeritages']); - Route::get('/heritages/search', [WorldHeritageController::class, 'searchWorldHeritages']); - Route::get('heritages/region-count', [WorldHeritageController::class, 'getWorldHeritagesCountByRegion']); - Route::get('/heritages/{id}', [WorldHeritageController::class, 'getWorldHeritageById']); + Route::controller(WorldHeritageController::class)->prefix('heritages')->group(function (): void { + Route::get('/', 'getWorldHeritages'); + Route::get('/search', 'searchWorldHeritages'); + Route::get('/region-count', 'getWorldHeritagesCountByRegion'); + Route::get('/{id}', 'getWorldHeritageById'); + }); - Route::get('/users/{id}', [UserController::class, 'getUserById']); - Route::patch('/users/{id}', [UserController::class, 'updateUser']); - Route::delete('/users/{id}', [UserController::class, 'deleteUser']); Route::post('/user/create', [UserController::class, 'createUser']); + Route::post('/user/login', [AuthController::class, 'login']); + + Route::controller(UserController::class)->prefix('users')->group(function (): void { + Route::get('/{id}', 'getUserById'); + Route::patch('/{id}', 'updateUser'); + Route::delete('/{id}', 'deleteUser'); + }); }); \ No newline at end of file