diff --git a/backend/CLAUDE.md b/backend/CLAUDE.md index 60342a4..aa998f8 100644 --- a/backend/CLAUDE.md +++ b/backend/CLAUDE.md @@ -8,6 +8,8 @@ MVP: `identity`, `community`, `branding`, `cfp`, `program`, `ticketing`, `paymen Fase 2/3 (scaffold vazio, sem lógica ainda): `sponsors`, `suppliers`, `finance`, `tasks`, `engagement`. Ver `docs/produto.md` na raiz do monorepo para a responsabilidade de cada um. +`identity` é o único com implementação real até agora: `users` (UUID, ver ADR 0007), cadastro com verificação de e-mail, login/logout via Sanctum, `GET /me`. Tenant (edição, papéis) é dos módulos Community (#9/#14), ainda não implementados — `/me` devolve `roles: []` até lá. + ## Estrutura de cada módulo (`app-modules//src/`) | Pasta | O que vai lá | @@ -31,6 +33,7 @@ Ver `docs/produto.md` na raiz do monorepo para a responsabilidade de cada um. - Rota sensível (login, recuperação de senha, checkout, webhook) usa `throttle:` com um dos limiters nomeados em `App\Providers\SecurityServiceProvider` (`login`, `password-reset`, `checkout`, `webhooks`), configurável via `.env` (`RATE_LIMIT_*`). - `Log::info/error/...` nunca precisa esconder segredo na mão — `App\Logging\RedactSensitiveDataProcessor` redige `password`/`token`/`api_key`/`secret`/`authorization`/cartão automaticamente em todo log (JSON estruturado, ver `config/logging.php`). - Segredo nunca vai pro repositório — ver `docs/adr/0006-politica-de-segredos.md`. +- Macro/bind que uma rota de módulo usa (ex.: `Route::apiV1()`) tem que ser registrada no `register()` do provider, nunca no `boot()` — o `internachi/modular` carrega as rotas de cada módulo num hook `booting()` que dispara antes do `boot()` de qualquer provider (inclusive do `AppServiceProvider`). ## Comandos diff --git a/backend/database/factories/UserFactory.php b/backend/app-modules/identity/database/factories/UserFactory.php similarity index 76% rename from backend/database/factories/UserFactory.php rename to backend/app-modules/identity/database/factories/UserFactory.php index fdbddeb..6194c2e 100644 --- a/backend/database/factories/UserFactory.php +++ b/backend/app-modules/identity/database/factories/UserFactory.php @@ -2,26 +2,23 @@ declare(strict_types=1); -namespace Database\Factories; +namespace PHPeste\Identity\Database\Factories; -use App\Models\User; use Illuminate\Database\Eloquent\Factories\Factory; use Illuminate\Support\Facades\Hash; use Illuminate\Support\Str; +use PHPeste\Identity\Models\User; /** * @extends Factory */ class UserFactory extends Factory { - /** - * The current password being used by the factory. - */ + protected $model = User::class; + protected static ?string $password; /** - * Define the model's default state. - * * @return array */ public function definition(): array @@ -35,9 +32,6 @@ public function definition(): array ]; } - /** - * Indicate that the model's email address should be unverified. - */ public function unverified(): static { return $this->state(fn (array $attributes): array => [ diff --git a/backend/database/migrations/0001_01_01_000000_create_users_table.php b/backend/app-modules/identity/database/migrations/0001_01_01_000000_create_users_table.php similarity index 63% rename from backend/database/migrations/0001_01_01_000000_create_users_table.php rename to backend/app-modules/identity/database/migrations/0001_01_01_000000_create_users_table.php index 28df0a8..f7e7d91 100644 --- a/backend/database/migrations/0001_01_01_000000_create_users_table.php +++ b/backend/app-modules/identity/database/migrations/0001_01_01_000000_create_users_table.php @@ -8,17 +8,16 @@ return new class extends Migration { - /** - * Run the migrations. - */ public function up(): void { Schema::create('users', function (Blueprint $table): void { - $table->id(); + $table->uuid('id')->primary(); $table->string('name'); $table->string('email')->unique(); $table->timestamp('email_verified_at')->nullable(); $table->string('password'); + $table->string('locale', 8)->default('pt_BR'); + $table->string('photo_path')->nullable(); $table->rememberToken(); $table->timestamps(); }); @@ -28,24 +27,11 @@ public function up(): void $table->string('token'); $table->timestamp('created_at')->nullable(); }); - - Schema::create('sessions', function (Blueprint $table): void { - $table->string('id')->primary(); - $table->foreignId('user_id')->nullable()->index(); - $table->string('ip_address', 45)->nullable(); - $table->text('user_agent')->nullable(); - $table->longText('payload'); - $table->integer('last_activity')->index(); - }); } - /** - * Reverse the migrations. - */ public function down(): void { - Schema::dropIfExists('users'); Schema::dropIfExists('password_reset_tokens'); - Schema::dropIfExists('sessions'); + Schema::dropIfExists('users'); } }; diff --git a/backend/app-modules/identity/database/seeders/UserSeeder.php b/backend/app-modules/identity/database/seeders/UserSeeder.php new file mode 100644 index 0000000..6b16f3d --- /dev/null +++ b/backend/app-modules/identity/database/seeders/UserSeeder.php @@ -0,0 +1,19 @@ +create([ + 'name' => 'Test User', + 'email' => 'test@example.com', + ]); + } +} diff --git a/backend/app-modules/identity/routes/identity-routes.php b/backend/app-modules/identity/routes/identity-routes.php index 174d7fd..f45a6c5 100644 --- a/backend/app-modules/identity/routes/identity-routes.php +++ b/backend/app-modules/identity/routes/identity-routes.php @@ -1,3 +1,35 @@ group(function (): void { + Route::post('/register', [AuthController::class, 'register']) + ->middleware('throttle:login') + ->name('auth.register'); + + Route::post('/login', [AuthController::class, 'login']) + ->middleware('throttle:login') + ->name('auth.login'); + + Route::post('/logout', [AuthController::class, 'logout']) + ->middleware('auth:sanctum') + ->name('auth.logout'); + + Route::post('/logout-all', [AuthController::class, 'logoutAll']) + ->middleware('auth:sanctum') + ->name('auth.logout-all'); + + Route::get('/email/verify/{id}/{hash}', [AuthController::class, 'verifyEmail']) + ->middleware('signed') + ->name('verification.verify'); + + Route::post('/email/verification-notification', [AuthController::class, 'resendVerification']) + ->middleware(['auth:sanctum', 'throttle:password-reset']) + ->name('verification.send'); +}); + +Route::apiV1()->middleware('auth:sanctum')->get('/me', MeController::class)->name('me'); diff --git a/backend/app-modules/identity/src/Actions/AuthenticateUser.php b/backend/app-modules/identity/src/Actions/AuthenticateUser.php new file mode 100644 index 0000000..56bd50b --- /dev/null +++ b/backend/app-modules/identity/src/Actions/AuthenticateUser.php @@ -0,0 +1,22 @@ +first(); + + throw_if(!$user || !Hash::check($password, $user->password), AuthenticationException::class, 'Credenciais inválidas.'); + + return $user->createToken($deviceName); + } +} diff --git a/backend/app-modules/identity/src/Actions/RegisterUser.php b/backend/app-modules/identity/src/Actions/RegisterUser.php new file mode 100644 index 0000000..04b7c1b --- /dev/null +++ b/backend/app-modules/identity/src/Actions/RegisterUser.php @@ -0,0 +1,26 @@ + $name, + 'email' => $email, + 'password' => $password, + 'locale' => app()->getLocale(), + ]); + + event(new Registered($user)); + $user->sendEmailVerificationNotification(); + + return $user; + } +} diff --git a/backend/app-modules/identity/src/Http/Controllers/AuthController.php b/backend/app-modules/identity/src/Http/Controllers/AuthController.php new file mode 100644 index 0000000..c0e7b4a --- /dev/null +++ b/backend/app-modules/identity/src/Http/Controllers/AuthController.php @@ -0,0 +1,99 @@ +handle( + $request->string('name')->toString(), + $request->string('email')->toString(), + $request->string('password')->toString(), + ); + + return new UserResource($user)->response()->setStatusCode(201); + } + + public function login(LoginRequest $request, AuthenticateUser $authenticateUser): JsonResponse + { + $deviceName = $request->string('device_name')->toString() ?: ($request->userAgent() ?? 'desconhecido'); + + $token = $authenticateUser->handle( + $request->string('email')->toString(), + $request->string('password')->toString(), + $deviceName, + ); + + /** @var User $user */ + $user = $token->accessToken->tokenable; + + return response()->json([ + 'user' => new UserResource($user)->resolve($request), + 'token' => $token->plainTextToken, + ]); + } + + public function logout(Request $request): JsonResponse + { + /** @var User $user */ + $user = $request->user(); + + /** @var PersonalAccessToken $token */ + $token = $user->currentAccessToken(); + $token->delete(); + + return response()->json(['loggedOut' => true]); + } + + public function logoutAll(Request $request): JsonResponse + { + /** @var User $user */ + $user = $request->user(); + $user->tokens()->delete(); + + return response()->json(['loggedOut' => true]); + } + + /** + * A assinatura da URL (id + hash + expiração) já foi conferida pelo + * middleware `signed` antes de chegar aqui. + */ + public function verifyEmail(Request $request): JsonResponse + { + /** @var User $user */ + $user = User::findOrFail($request->route('id')); + + if (!$user->hasVerifiedEmail()) { + $user->markEmailAsVerified(); + event(new Verified($user)); + } + + return response()->json(['verified' => true]); + } + + public function resendVerification(Request $request): JsonResponse + { + /** @var User $user */ + $user = $request->user(); + + if (!$user->hasVerifiedEmail()) { + $user->sendEmailVerificationNotification(); + } + + return response()->json(['sent' => true]); + } +} diff --git a/backend/app-modules/identity/src/Http/Controllers/MeController.php b/backend/app-modules/identity/src/Http/Controllers/MeController.php new file mode 100644 index 0000000..f79edfc --- /dev/null +++ b/backend/app-modules/identity/src/Http/Controllers/MeController.php @@ -0,0 +1,28 @@ +user(); + + return response()->json([ + 'user' => new UserResource($user)->resolve($request), + 'roles' => [], + ]); + } +} diff --git a/backend/app-modules/identity/src/Http/Requests/LoginRequest.php b/backend/app-modules/identity/src/Http/Requests/LoginRequest.php new file mode 100644 index 0000000..6cbb74f --- /dev/null +++ b/backend/app-modules/identity/src/Http/Requests/LoginRequest.php @@ -0,0 +1,22 @@ +> + */ + public function rules(): array + { + return [ + 'email' => ['required', 'string', 'email'], + 'password' => ['required', 'string'], + 'device_name' => ['sometimes', 'string', 'max:255'], + ]; + } +} diff --git a/backend/app-modules/identity/src/Http/Requests/RegisterRequest.php b/backend/app-modules/identity/src/Http/Requests/RegisterRequest.php new file mode 100644 index 0000000..1949681 --- /dev/null +++ b/backend/app-modules/identity/src/Http/Requests/RegisterRequest.php @@ -0,0 +1,23 @@ +> + */ + public function rules(): array + { + return [ + 'name' => ['required', 'string', 'max:255'], + 'email' => ['required', 'string', 'email', 'max:255', 'unique:users,email'], + 'password' => ['required', 'confirmed', Password::min(10)->uncompromised()], + ]; + } +} diff --git a/backend/app-modules/identity/src/Http/Resources/UserResource.php b/backend/app-modules/identity/src/Http/Resources/UserResource.php new file mode 100644 index 0000000..e1682be --- /dev/null +++ b/backend/app-modules/identity/src/Http/Resources/UserResource.php @@ -0,0 +1,34 @@ + + */ + public function toArray(Request $request): array + { + $user = $this->resource; + + if (!$user instanceof User) { + return []; + } + + return [ + 'id' => $user->id, + 'name' => $user->name, + 'email' => $user->email, + 'avatar_url' => $user->photo_path ? asset('storage/'.$user->photo_path) : null, + 'locale' => $user->locale, + 'email_verified_at' => $this->isoDate($user->email_verified_at), + 'created_at' => $this->isoDate($user->created_at), + ]; + } +} diff --git a/backend/app-modules/identity/src/Models/User.php b/backend/app-modules/identity/src/Models/User.php new file mode 100644 index 0000000..ce94384 --- /dev/null +++ b/backend/app-modules/identity/src/Models/User.php @@ -0,0 +1,54 @@ + */ + use HasFactory; + + use HasUuids; + use MustVerifyEmail; + use Notifiable; + + /** + * @return array + */ + protected function casts(): array + { + return [ + 'email_verified_at' => 'datetime', + 'password' => 'hashed', + ]; + } +} diff --git a/backend/app-modules/identity/tests/Feature/LoginTest.php b/backend/app-modules/identity/tests/Feature/LoginTest.php new file mode 100644 index 0000000..2234b7b --- /dev/null +++ b/backend/app-modules/identity/tests/Feature/LoginTest.php @@ -0,0 +1,65 @@ +create(['password' => 'umasenhabemforte123']); + + $response = $this->postJson('/api/v1/auth/login', [ + 'email' => $user->email, + 'password' => 'umasenhabemforte123', + 'device_name' => 'pest', + ]); + + $response->assertOk() + ->assertJsonPath('user.email', $user->email) + ->assertJsonStructure(['user' => ['id', 'name', 'email'], 'token']); + + $this->assertDatabaseHas('personal_access_tokens', [ + 'tokenable_id' => $user->id, + 'name' => 'pest', + ]); +}); + +it('usa o user agent como nome do dispositivo quando device_name não é enviado', function (): void { + $user = User::factory()->create(['password' => 'umasenhabemforte123']); + + $this->withHeader('User-Agent', 'TesteAgent/1.0') + ->postJson('/api/v1/auth/login', ['email' => $user->email, 'password' => 'umasenhabemforte123']) + ->assertOk(); + + $this->assertDatabaseHas('personal_access_tokens', [ + 'tokenable_id' => $user->id, + 'name' => 'TesteAgent/1.0', + ]); +}); + +it('rejeita senha errada com 401, sem criar token', function (): void { + $user = User::factory()->create(['password' => 'umasenhabemforte123']); + + $this->postJson('/api/v1/auth/login', ['email' => $user->email, 'password' => 'senhaerrada']) + ->assertStatus(401) + ->assertJson(['code' => 'unauthenticated']); + + $this->assertDatabaseMissing('personal_access_tokens', ['tokenable_id' => $user->id]); +}); + +it('rejeita e-mail inexistente com 401', function (): void { + $this->postJson('/api/v1/auth/login', ['email' => 'ninguem@phpeste.dev', 'password' => 'qualquercoisa']) + ->assertStatus(401); +}); + +it('bloqueia depois de várias tentativas erradas seguidas', function (): void { + $user = User::factory()->create(['password' => 'umasenhabemforte123']); + $attempts = (int) config('security.rate_limits.login.attempts'); + + for ($i = 0; $i < $attempts; $i++) { + $this->postJson('/api/v1/auth/login', ['email' => $user->email, 'password' => 'errada']) + ->assertStatus(401); + } + + $this->postJson('/api/v1/auth/login', ['email' => $user->email, 'password' => 'umasenhabemforte123']) + ->assertStatus(429); +}); diff --git a/backend/app-modules/identity/tests/Feature/LogoutTest.php b/backend/app-modules/identity/tests/Feature/LogoutTest.php new file mode 100644 index 0000000..fdf71e7 --- /dev/null +++ b/backend/app-modules/identity/tests/Feature/LogoutTest.php @@ -0,0 +1,51 @@ +create(); + $token = $user->createToken('pest')->plainTextToken; + + $this->withToken($token) + ->postJson('/api/v1/auth/logout') + ->assertOk() + ->assertJson(['loggedOut' => true]); + + forgetResolvedAuthGuard(); + + $this->withToken($token) + ->getJson('/api/v1/me') + ->assertStatus(401); +}); + +it('logout-all revoga todos os tokens do usuário', function (): void { + $user = User::factory()->create(); + $tokenA = $user->createToken('dispositivo-a')->plainTextToken; + $tokenB = $user->createToken('dispositivo-b')->plainTextToken; + + $this->withToken($tokenA) + ->postJson('/api/v1/auth/logout-all') + ->assertOk(); + + forgetResolvedAuthGuard(); + $this->withToken($tokenA)->getJson('/api/v1/me')->assertStatus(401); + + forgetResolvedAuthGuard(); + $this->withToken($tokenB)->getJson('/api/v1/me')->assertStatus(401); +}); + +it('exige autenticação pra deslogar', function (): void { + $this->postJson('/api/v1/auth/logout')->assertStatus(401); +}); diff --git a/backend/app-modules/identity/tests/Feature/MeTest.php b/backend/app-modules/identity/tests/Feature/MeTest.php new file mode 100644 index 0000000..81e514b --- /dev/null +++ b/backend/app-modules/identity/tests/Feature/MeTest.php @@ -0,0 +1,22 @@ +create(); + $token = $user->createToken('pest')->plainTextToken; + + $this->withToken($token) + ->getJson('/api/v1/me') + ->assertOk() + ->assertJson([ + 'user' => ['id' => $user->id, 'email' => $user->email], + 'roles' => [], + ]); +}); + +it('exige autenticação', function (): void { + $this->getJson('/api/v1/me')->assertStatus(401); +}); diff --git a/backend/app-modules/identity/tests/Feature/PasswordNeverLeaksTest.php b/backend/app-modules/identity/tests/Feature/PasswordNeverLeaksTest.php new file mode 100644 index 0000000..8a40799 --- /dev/null +++ b/backend/app-modules/identity/tests/Feature/PasswordNeverLeaksTest.php @@ -0,0 +1,27 @@ +postJson('/api/v1/auth/register', [ + 'name' => 'Ada Lovelace', + 'email' => 'ada@phpeste.dev', + 'password' => 'umasenhabemforte123', + 'password_confirmation' => 'umasenhabemforte123', + ]); + $register->assertJsonMissingPath('data.password'); + + expect($register->getContent())->not->toContain('umasenhabemforte123'); + + $login = $this->postJson('/api/v1/auth/login', [ + 'email' => 'ada@phpeste.dev', + 'password' => 'umasenhabemforte123', + ]); + $login->assertJsonMissingPath('user.password'); + + expect($login->getContent())->not->toContain('umasenhabemforte123'); + + $token = $login->json('token'); + $me = $this->withToken($token)->getJson('/api/v1/me'); + $me->assertJsonMissingPath('user.password'); +}); diff --git a/backend/app-modules/identity/tests/Feature/RegisterTest.php b/backend/app-modules/identity/tests/Feature/RegisterTest.php new file mode 100644 index 0000000..46e14db --- /dev/null +++ b/backend/app-modules/identity/tests/Feature/RegisterTest.php @@ -0,0 +1,88 @@ +postJson('/api/v1/auth/register', [ + 'name' => 'Ada Lovelace', + 'email' => 'ada@phpeste.dev', + 'password' => 'umasenhabemforte123', + 'password_confirmation' => 'umasenhabemforte123', + ]); + + $response->assertCreated() + ->assertJsonPath('data.email', 'ada@phpeste.dev') + ->assertJsonPath('data.email_verified_at', null) + ->assertJsonMissingPath('data.password'); + + $user = User::where('email', 'ada@phpeste.dev')->firstOrFail(); + + expect($user->hasVerifiedEmail())->toBeFalse(); + + Notification::assertSentTo($user, VerifyEmail::class); +}); + +it('rejeita senha fraca', function (): void { + $this->postJson('/api/v1/auth/register', [ + 'name' => 'Ada Lovelace', + 'email' => 'ada@phpeste.dev', + 'password' => '123', + 'password_confirmation' => '123', + ])->assertStatus(422) + ->assertJsonValidationErrors(['password']); +}); + +it('rejeita e-mail duplicado', function (): void { + User::factory()->create(['email' => 'ada@phpeste.dev']); + + $this->postJson('/api/v1/auth/register', [ + 'name' => 'Outra Ada', + 'email' => 'ada@phpeste.dev', + 'password' => 'umasenhabemforte123', + 'password_confirmation' => 'umasenhabemforte123', + ])->assertStatus(422) + ->assertJsonValidationErrors(['email']); +}); + +it('verifica o e-mail com um link assinado válido', function (): void { + $user = User::factory()->unverified()->create(); + + $url = URL::temporarySignedRoute( + 'verification.verify', + now()->addMinutes(60), + ['id' => $user->id, 'hash' => sha1($user->getEmailForVerification())], + ); + + $this->getJson($url)->assertOk()->assertJson(['verified' => true]); + + expect($user->fresh()->hasVerifiedEmail())->toBeTrue(); +}); + +it('rejeita link de verificação com assinatura inválida', function (): void { + $user = User::factory()->unverified()->create(); + + $this->getJson("/api/v1/auth/email/verify/{$user->id}/hash-errado?expires=9999999999&signature=invalida") + ->assertStatus(403); + + expect($user->fresh()->hasVerifiedEmail())->toBeFalse(); +}); + +it('reenvia o e-mail de verificação pra quem está autenticado e ainda não verificou', function (): void { + $user = User::factory()->unverified()->create(); + + $this->actingAs($user, 'sanctum') + ->postJson('/api/v1/auth/email/verification-notification') + ->assertOk() + ->assertJson(['sent' => true]); + + Notification::assertSentTo($user, VerifyEmail::class); +}); diff --git a/backend/app/Models/User.php b/backend/app/Models/User.php deleted file mode 100644 index 8ecf9cf..0000000 --- a/backend/app/Models/User.php +++ /dev/null @@ -1,37 +0,0 @@ - */ - use HasFactory; - - use Notifiable; - - /** - * Get the attributes that should be cast. - * - * @return array - */ - protected function casts(): array - { - return [ - 'email_verified_at' => 'datetime', - 'password' => 'hashed', - ]; - } -} diff --git a/backend/app/Providers/AppServiceProvider.php b/backend/app/Providers/AppServiceProvider.php index 856e0fd..d61ea1f 100644 --- a/backend/app/Providers/AppServiceProvider.php +++ b/backend/app/Providers/AppServiceProvider.php @@ -4,9 +4,9 @@ namespace App\Providers; -use App\Models\User; use Dedoc\Scramble\Scramble; use Dedoc\Scramble\Support\RouteInfo; +use Illuminate\Contracts\Auth\Authenticatable; use Illuminate\Routing\RouteRegistrar; use Illuminate\Support\Facades\Gate; use Illuminate\Support\Facades\Route; @@ -16,19 +16,21 @@ class AppServiceProvider extends ServiceProvider { /** - * Register any application services. + * `internachi/modular` carrega as rotas de cada módulo num hook + * `booting()` disparado antes do `boot()` de qualquer provider — por + * isso a macro `apiV1` precisa existir já no `register()`, não no + * `boot()`, senão os módulos tentam usar uma macro que ainda não existe. */ - public function register(): void {} + public function register(): void + { + $this->registerApiRouteMacro(); + } - /** - * Bootstrap any application services. - */ public function boot(): void { - $this->registerApiRouteMacro(); $this->registerApiDocsGroupingByModule(); - Gate::define('viewApiDocs', fn (?User $user = null): bool => $user instanceof User); + Gate::define('viewApiDocs', fn (?Authenticatable $user = null): bool => $user instanceof Authenticatable); } private function registerApiRouteMacro(): void diff --git a/backend/app/Providers/HorizonServiceProvider.php b/backend/app/Providers/HorizonServiceProvider.php index 173fa2c..087259f 100644 --- a/backend/app/Providers/HorizonServiceProvider.php +++ b/backend/app/Providers/HorizonServiceProvider.php @@ -4,7 +4,8 @@ namespace App\Providers; -use App\Models\User; +use Illuminate\Contracts\Auth\Authenticatable; +use Illuminate\Database\Eloquent\Model; use Illuminate\Support\Facades\Gate; use Laravel\Horizon\HorizonApplicationServiceProvider; @@ -29,8 +30,12 @@ public function boot(): void */ protected function gate(): void { - Gate::define('viewHorizon', fn (?User $user = null): bool => in_array($user?->getAttribute('email'), [ + Gate::define('viewHorizon', function (?Authenticatable $user = null): bool { + $email = $user instanceof Model ? $user->getAttribute('email') : null; - ])); + return in_array($email, [ + + ]); + }); } } diff --git a/backend/config/auth.php b/backend/config/auth.php index 27d52ba..89cd0e3 100644 --- a/backend/config/auth.php +++ b/backend/config/auth.php @@ -2,7 +2,7 @@ declare(strict_types=1); -use App\Models\User; +use PHPeste\Identity\Models\User; return [ diff --git a/backend/config/sanctum.php b/backend/config/sanctum.php index 7b5e7cb..1b0e21e 100644 --- a/backend/config/sanctum.php +++ b/backend/config/sanctum.php @@ -52,7 +52,7 @@ | */ - 'expiration' => null, + 'expiration' => env('SANCTUM_EXPIRATION_MINUTES'), /* |-------------------------------------------------------------------------- diff --git a/backend/database/migrations/0001_01_01_000000_create_sessions_table.php b/backend/database/migrations/0001_01_01_000000_create_sessions_table.php new file mode 100644 index 0000000..ca2264e --- /dev/null +++ b/backend/database/migrations/0001_01_01_000000_create_sessions_table.php @@ -0,0 +1,27 @@ +string('id')->primary(); + $table->uuid('user_id')->nullable()->index(); + $table->string('ip_address', 45)->nullable(); + $table->text('user_agent')->nullable(); + $table->longText('payload'); + $table->integer('last_activity')->index(); + }); + } + + public function down(): void + { + Schema::dropIfExists('sessions'); + } +}; diff --git a/backend/database/migrations/2026_11_03_202439_create_personal_access_tokens_table.php b/backend/database/migrations/2026_11_03_202439_create_personal_access_tokens_table.php index 7fa15ff..546a00a 100644 --- a/backend/database/migrations/2026_11_03_202439_create_personal_access_tokens_table.php +++ b/backend/database/migrations/2026_11_03_202439_create_personal_access_tokens_table.php @@ -15,7 +15,7 @@ public function up(): void { Schema::create('personal_access_tokens', function (Blueprint $table): void { $table->id(); - $table->morphs('tokenable'); + $table->uuidMorphs('tokenable'); $table->text('name'); $table->string('token', 64)->unique(); $table->text('abilities')->nullable(); diff --git a/backend/database/seeders/DatabaseSeeder.php b/backend/database/seeders/DatabaseSeeder.php index fda4692..e519f5c 100644 --- a/backend/database/seeders/DatabaseSeeder.php +++ b/backend/database/seeders/DatabaseSeeder.php @@ -4,24 +4,16 @@ namespace Database\Seeders; -use App\Models\User; use Illuminate\Database\Console\Seeds\WithoutModelEvents; use Illuminate\Database\Seeder; +use PHPeste\Identity\Database\Seeders\UserSeeder; class DatabaseSeeder extends Seeder { use WithoutModelEvents; - /** - * Seed the application's database. - */ public function run(): void { - // User::factory(10)->create(); - - User::factory()->create([ - 'name' => 'Test User', - 'email' => 'test@example.com', - ]); + $this->call(UserSeeder::class); } } diff --git a/backend/phpstan.neon b/backend/phpstan.neon index 7aabc84..af74cd9 100644 --- a/backend/phpstan.neon +++ b/backend/phpstan.neon @@ -1,4 +1,5 @@ includes: + - vendor/larastan/larastan/extension.neon - phpstan.modules.php parameters: diff --git a/backend/tests/Arch/ModuleIsolationTest.php b/backend/tests/Arch/ModuleIsolationTest.php index b3d125a..7834334 100644 --- a/backend/tests/Arch/ModuleIsolationTest.php +++ b/backend/tests/Arch/ModuleIsolationTest.php @@ -2,18 +2,39 @@ declare(strict_types=1); -arch('módulos não dependem de App\\') - ->expect('PHPeste') - ->not->toUse('App'); - -arch('módulos não têm débito técnico (dd, dump, var_dump)') - ->expect('PHPeste') - ->not->toUse(['dd', 'dump', 'var_dump', 'die']); +// `expect('PHPeste\')` sozinho não desce para as subpastas de forma +// confiável neste setup (cada módulo é um pacote Composer com path-repository +// próprio, symlinkado — não um único root PSR-4 "PHPeste\"). Testado na prática: +// `not->toUse()` no namespace raiz do módulo não pega violação em Models/, +// mas pega normalmente quando aplicado a uma subpasta específica. Por isso +// iteramos módulo × subpasta real (as mesmas de backend/CLAUDE.md) abaixo. +$moduleSubfolders = ['Models', 'Actions', 'Data', 'Events', 'Listeners', 'Contracts', 'Http', 'Providers']; foreach (glob(__DIR__.'/../../app-modules/*', GLOB_ONLYDIR) as $modulePath) { $module = str(basename($modulePath))->studly(); $namespace = "PHPeste\\{$module}"; + foreach ($moduleSubfolders as $subfolder) { + if (!is_dir("{$modulePath}/src/{$subfolder}")) { + continue; + } + + $subNamespace = "{$namespace}\\{$subfolder}"; + + // App\Http\Resources, App\Http\Middleware, App\Support e App\Logging + // são kernel compartilhado (ApiResource, ApiError, middlewares de + // request) — todo módulo pode depender deles, como depende do + // próprio Laravel. App\Models, App\Http\Controllers e App\Providers + // são específicos da app raiz; nenhum módulo deveria importar daí. + arch("{$module}\\{$subfolder} não depende de App\\Models, App\\Http\\Controllers ou App\\Providers") + ->expect($subNamespace) + ->not->toUse(['App\Models', 'App\Http\Controllers', 'App\Providers']); + + arch("{$module}\\{$subfolder} não tem débito técnico (dd, dump, var_dump)") + ->expect($subNamespace) + ->not->toUse(['dd', 'dump', 'var_dump', 'die']); + } + arch("Models de {$module} só são usados dentro do próprio módulo") ->expect("{$namespace}\\Models") ->toOnlyBeUsedIn([$namespace, 'Tests']); diff --git a/backend/tests/Feature/Api/DocsCoverageTest.php b/backend/tests/Feature/Api/DocsCoverageTest.php index 1cef2ec..960c9a0 100644 --- a/backend/tests/Feature/Api/DocsCoverageTest.php +++ b/backend/tests/Feature/Api/DocsCoverageTest.php @@ -2,12 +2,12 @@ declare(strict_types=1); -use App\Models\User; use Illuminate\Support\Facades\Route; use Illuminate\Support\Str; +use PHPeste\Identity\Models\User; it('documenta toda rota de /api/v1 no OpenAPI gerado pelo Scramble', function (): void { - $user = new User(['id' => 1, 'name' => 'Teste', 'email' => 'teste@phpeste.dev']); + $user = new User(['id' => (string) Str::uuid(), 'name' => 'Teste', 'email' => 'teste@phpeste.dev']); $response = $this->actingAs($user)->getJson('/docs/api.json'); diff --git a/docs/adr/0001-esqueleto-laravel-13.md b/docs/adr/0001-esqueleto-laravel-13.md index d71eff7..69cbb9c 100644 --- a/docs/adr/0001-esqueleto-laravel-13.md +++ b/docs/adr/0001-esqueleto-laravel-13.md @@ -20,3 +20,7 @@ Todo o resto do backend (módulos, API, filas) depende de um projeto Laravel lim ## Consequências - Qualquer pessoa clona o repo, roda `composer install && cp .env.example .env && php artisan key:generate && php artisan migrate`, ou `infra/dev.sh up`, e tem a API rodando. + +## Correção (B06): Larastan rodava sem a extensão do Laravel + +Da B01 até a B06, `phpstan.neon` nunca incluía `vendor/larastan/larastan/extension.neon` — só o `bootstrap.php` (usado pelo Rector). Na prática isso significava PHPStan quase puro, sem a maior parte das regras específicas de Laravel/Eloquent: `Model::create()` não resolvia, `JsonResource`/`@mixin` de Eloquent não eram entendidos. Corrigido com `includes: [vendor/larastan/larastan/extension.neon, ...]`; nenhum erro novo apareceu no código já existente, mas o nível 8 agora analisa de verdade. diff --git a/docs/adr/0002-modularizacao-internachi-modular.md b/docs/adr/0002-modularizacao-internachi-modular.md index 05d5281..5772d59 100644 --- a/docs/adr/0002-modularizacao-internachi-modular.md +++ b/docs/adr/0002-modularizacao-internachi-modular.md @@ -23,3 +23,7 @@ A plataforma tem muitos domínios (identidade, CFP, ingressos, pagamentos, etc.) - Criar um módulo novo é `php artisan make:module ` seguido de `composer update phpeste/`. - Se algum módulo tentar usar `Model` de outro módulo diretamente, o CI quebra no `pest --group=arch`. + +## Correção (B06): `expect('PHPeste')` sozinho não pegava nada + +A regra original de "módulo não depende de `App\`" usava `arch('...')->expect('PHPeste')->not->toUse('App')` — e nunca funcionou de verdade: testado na prática (violação real inserida de propósito), o Pest arch não resolve o namespace raiz `PHPeste` através dos múltiplos path-repositories symlinkados. A regra de isolamento de `Models/` (que já usa `"PHPeste\\{Módulo}\\Models"`, uma subpasta real) sempre funcionou. Corrigido iterando módulo × subpasta real (`Models`, `Actions`, `Http`, etc.) — ver `tests/Arch/ModuleIsolationTest.php`. Permitido depender de `App\Http\Resources`, `App\Http\Middleware`, `App\Support` e `App\Logging` (kernel compartilhado); bloqueado `App\Models`, `App\Http\Controllers` e `App\Providers` (específicos da app raiz).