diff --git a/app/Http/Controllers/PersonController.php b/app/Http/Controllers/PersonController.php new file mode 100644 index 0000000..ac72061 --- /dev/null +++ b/app/Http/Controllers/PersonController.php @@ -0,0 +1,87 @@ +get('tenant_id'); + + $person = $this->repository->createOrUpdate($tenantId, [ + 'email_plain' => $request->input('email_plain'), + 'phone_plain' => $request->input('phone_plain'), + 'note_enc' => $request->input('note_enc'), + ]); + + return (new PersonResource($person)) + ->response() + ->setStatusCode(Response::HTTP_CREATED); + } + + /** + * Find a Person by email. + * + * GET /v1/tenants/{tenant}/persons/by-email?email=... + * + * @param Request $request Must contain 'email' query parameter + * @return JsonResponse Person (200) or not found (404) + */ + public function byEmail(Request $request): JsonResponse + { + $email = $request->query('email'); + + if (! $email) { + return response()->json([ + 'error' => 'Email query parameter is required', + ], Response::HTTP_BAD_REQUEST); + } + + /** @var int $tenantId */ + $tenantId = $request->get('tenant_id'); + + $person = $this->repository->findByEmail($tenantId, $email); + + if (! $person) { + return response()->json([ + 'error' => 'Person not found', + ], Response::HTTP_NOT_FOUND); + } + + return (new PersonResource($person))->response(); + } +} diff --git a/app/Http/Requests/StorePersonRequest.php b/app/Http/Requests/StorePersonRequest.php new file mode 100644 index 0000000..b2e72de --- /dev/null +++ b/app/Http/Requests/StorePersonRequest.php @@ -0,0 +1,58 @@ +> + */ + public function rules(): array + { + return [ + 'email_plain' => ['required', 'email'], + 'phone_plain' => ['nullable', 'string'], + 'note_enc' => ['nullable', 'string'], + ]; + } + + /** + * Get custom messages for validator errors. + * + * @return array + */ + public function messages(): array + { + return [ + 'email_plain.required' => 'Email address is required', + 'email_plain.email' => 'Email address must be valid', + ]; + } +} diff --git a/app/Http/Resources/PersonResource.php b/app/Http/Resources/PersonResource.php new file mode 100644 index 0000000..c227121 --- /dev/null +++ b/app/Http/Resources/PersonResource.php @@ -0,0 +1,45 @@ + + */ + public function toArray(Request $request): array + { + return [ + 'id' => $this->id, + 'tenant_id' => $this->tenant_id, + 'created_at' => $this->created_at->toIso8601String(), + 'updated_at' => $this->updated_at->toIso8601String(), + ]; + } +} diff --git a/app/Models/User.php b/app/Models/User.php index c974a18..d23497d 100644 --- a/app/Models/User.php +++ b/app/Models/User.php @@ -10,11 +10,12 @@ use Illuminate\Foundation\Auth\User as Authenticatable; use Illuminate\Notifications\Notifiable; use Laravel\Sanctum\HasApiTokens; +use Spatie\Permission\Traits\HasRoles; class User extends Authenticatable { /** @use HasFactory<\Database\Factories\UserFactory> */ - use HasApiTokens, HasFactory, Notifiable; + use HasApiTokens, HasFactory, HasRoles, Notifiable; /** * The attributes that are mass assignable. diff --git a/bootstrap/app.php b/bootstrap/app.php index 237de01..c46a6e2 100644 --- a/bootstrap/app.php +++ b/bootstrap/app.php @@ -16,6 +16,8 @@ ->withMiddleware(function (Middleware $middleware): void { $middleware->alias([ 'tenant' => \App\Http\Middleware\SetTenant::class, + 'permission' => \Spatie\Permission\Middleware\PermissionMiddleware::class, + 'role' => \Spatie\Permission\Middleware\RoleMiddleware::class, ]); }) ->withExceptions(function (Exceptions $exceptions): void { diff --git a/database/migrations/2025_11_01_210213_change_bytea_columns_to_varchar.php b/database/migrations/2025_11_01_210213_change_bytea_columns_to_varchar.php index 7f0b54f..fff47b9 100644 --- a/database/migrations/2025_11_01_210213_change_bytea_columns_to_varchar.php +++ b/database/migrations/2025_11_01_210213_change_bytea_columns_to_varchar.php @@ -29,10 +29,10 @@ public function up(): void Schema::table('person', function (Blueprint $table) { // Base64 of 32-byte HMAC-SHA256 = 44 chars $table->string('email_idx', 64)->change(); - $table->string('phone_idx', 64)->change(); + $table->string('phone_idx', 64)->nullable()->change(); // Encrypted fields also need TEXT (Laravel encrypted cast generates variable-length base64) $table->text('email_enc')->change(); - $table->text('phone_enc')->change(); + $table->text('phone_enc')->nullable()->change(); }); } diff --git a/phpstan.neon b/phpstan.neon index 0060dcf..dada707 100644 --- a/phpstan.neon +++ b/phpstan.neon @@ -51,22 +51,38 @@ parameters: message: '#Cannot (call method|access property) .* on (App\\Models\\User|Laravel\\Sanctum\\PersonalAccessToken)\|null#' path: tests/Feature/* - # PEST dynamic properties: $this->tenant is set in beforeEach() but PHPStan cannot infer + # PEST dynamic properties: $this->tenant, $this->user, $this->token set in beforeEach() - - message: '#Access to an undefined property PHPUnit\\Framework\\TestCase::\$tenant#' - path: tests/Feature/PersonTest.php + message: '#Access to an undefined property PHPUnit\\Framework\\TestCase::\$(tenant|user|token|testPerson)#' + paths: + - tests/Feature/PersonTest.php + - tests/Feature/PersonApiTest.php - - message: '#Cannot access property .* on App\\Models\\Person\|null#' - path: tests/Feature/PersonTest.php + message: '#Cannot access property .* on (App\\Models\\Person|App\\Models\\TenantKey|mixed)\|null#' + paths: + - tests/Feature/PersonTest.php + - tests/Feature/PersonApiTest.php - message: '#Cannot access property \$id on mixed#' - path: tests/Feature/PersonTest.php + paths: + - tests/Feature/PersonTest.php + - tests/Feature/PersonApiTest.php - message: '#Property App\\Models\\Person::\$tenant_id \(int\) does not accept mixed#' - path: tests/Feature/PersonTest.php + paths: + - tests/Feature/PersonTest.php + - tests/Feature/PersonApiTest.php - - message: '#Parameter \#1 \$tenantId of method App\\Repositories\\PersonRepository::(findByEmail|findByPhone|createOrUpdate)\(\) expects int, mixed given#' - path: tests/Feature/PersonTest.php + message: '#Parameter .* expects .*, mixed given#' + paths: + - tests/Feature/PersonTest.php + - tests/Feature/PersonApiTest.php + - + message: '#Part .* of encapsed string cannot be cast to string#' + path: tests/Feature/PersonApiTest.php + - + message: '#Call to an undefined method PHPUnit\\Framework\\TestCase::withToken\(\)#' + path: tests/Feature/PersonApiTest.php # Laravel Eloquent BelongsTo covariance limitation - known PHPStan issue with template types - diff --git a/routes/api.php b/routes/api.php index 6c2b51c..8d61376 100644 --- a/routes/api.php +++ b/routes/api.php @@ -4,6 +4,7 @@ // SPDX-License-Identifier: AGPL-3.0-or-later use App\Http\Controllers\AuthController; +use App\Http\Controllers\PersonController; use Illuminate\Support\Facades\Route; /* @@ -36,5 +37,13 @@ Route::post('/auth/logout', [AuthController::class, 'logout']); Route::post('/auth/logout-all', [AuthController::class, 'logoutAll']); Route::get('/me', [AuthController::class, 'me']); + + // Tenant-scoped Person endpoints + Route::prefix('tenants/{tenant}')->middleware('tenant')->group(function () { + Route::post('/persons', [PersonController::class, 'store']) + ->middleware('permission:person.write'); + Route::get('/persons/by-email', [PersonController::class, 'byEmail']) + ->middleware('permission:person.read'); + }); }); }); diff --git a/tests/Feature/PersonApiTest.php b/tests/Feature/PersonApiTest.php new file mode 100644 index 0000000..880cae7 --- /dev/null +++ b/tests/Feature/PersonApiTest.php @@ -0,0 +1,273 @@ +tenant = TenantKey::create($keys); + + // Create test user with token + $this->user = User::factory()->create(); + $this->token = $this->user->createToken('test-device')->plainTextToken; + + // Create global permissions (not team-scoped for this test) + Permission::create(['name' => 'person.write', 'guard_name' => 'web']); + Permission::create(['name' => 'person.read', 'guard_name' => 'web']); +}); + +afterEach(function (): void { + cleanupTestKekFile(); + TenantKey::setKekPath(null); +}); + +describe('POST /v1/tenants/{tenant}/persons', function () { + test('returns 401 when not authenticated', function (): void { + $response = $this->postJson("/api/v1/tenants/{$this->tenant->id}/persons", [ + 'email_plain' => 'test@example.com', + ]); + + $response->assertStatus(401); + }); + + test('returns 403 when user lacks person.write permission', function (): void { + $response = $this->withToken($this->token) + ->postJson("/api/v1/tenants/{$this->tenant->id}/persons", [ + 'email_plain' => 'test@example.com', + ]); + + $response->assertStatus(403); + }); + + test('returns 422 when email_plain is missing', function (): void { + givePermissionWithTenant($this->user, $this->tenant->id, 'person.write'); + + $response = $this->withToken($this->token) + ->postJson("/api/v1/tenants/{$this->tenant->id}/persons", [ + 'phone_plain' => '+49 123 456789', + ]); + + $response->assertStatus(422) + ->assertJsonValidationErrors(['email_plain']); + }); + + test('returns 422 when email_plain is invalid', function (): void { + givePermissionWithTenant($this->user, $this->tenant->id, 'person.write'); + + $response = $this->withToken($this->token) + ->postJson("/api/v1/tenants/{$this->tenant->id}/persons", [ + 'email_plain' => 'not-an-email', + ]); + + $response->assertStatus(422) + ->assertJsonValidationErrors(['email_plain']); + }); + + test('creates Person with valid data and returns 201', function (): void { + givePermissionWithTenant($this->user, $this->tenant->id, 'person.write'); + + $response = $this->withToken($this->token) + ->postJson("/api/v1/tenants/{$this->tenant->id}/persons", [ + 'email_plain' => 'test@example.com', + 'phone_plain' => '+49 123 456789', + 'note_enc' => 'Test note', + ]); + + $response->assertStatus(201) + ->assertJsonStructure([ + 'id', + 'tenant_id', + 'created_at', + 'updated_at', + ]) + ->assertJsonFragment([ + 'tenant_id' => $this->tenant->id, + ]); + + // Verify Person was created in database + expect(Person::where('tenant_id', $this->tenant->id)->count())->toBe(1); + + $person = Person::first(); + expect($person->email_enc)->not->toBeNull(); + expect($person->phone_enc)->not->toBeNull(); + expect($person->note_enc)->not->toBeNull(); + }); + + test('does not expose encrypted fields or blind indexes in response', function (): void { + givePermissionWithTenant($this->user, $this->tenant->id, 'person.write'); + + $response = $this->withToken($this->token) + ->postJson("/api/v1/tenants/{$this->tenant->id}/persons", [ + 'email_plain' => 'test@example.com', + 'phone_plain' => '+49 123 456789', + ]); + + $response->assertStatus(201); + $json = $response->json(); + + expect($json)->not->toHaveKey('email_enc'); + expect($json)->not->toHaveKey('email_idx'); + expect($json)->not->toHaveKey('phone_enc'); + expect($json)->not->toHaveKey('phone_idx'); + expect($json)->not->toHaveKey('note_enc'); + }); + + test('creates Person with only required fields', function (): void { + givePermissionWithTenant($this->user, $this->tenant->id, 'person.write'); + + $response = $this->withToken($this->token) + ->postJson("/api/v1/tenants/{$this->tenant->id}/persons", [ + 'email_plain' => 'minimal@example.com', + ]); + + $response->assertStatus(201); + + $person = Person::where('tenant_id', $this->tenant->id)->first(); + expect($person->email_enc)->not->toBeNull(); + expect($person->phone_enc)->toBeNull(); + expect($person->note_enc)->toBeNull(); + }); + + test('returns 404 when tenant does not exist', function (): void { + givePermissionWithTenant($this->user, $this->tenant->id, 'person.write'); + $nonExistentTenantId = 99999; + + $response = $this->withToken($this->token) + ->postJson("/api/v1/tenants/{$nonExistentTenantId}/persons", [ + 'email_plain' => 'test@example.com', + ]); + + $response->assertStatus(404); + }); +}); + +describe('GET /v1/tenants/{tenant}/persons/by-email', function () { + beforeEach(function (): void { + // Create a test Person + $this->testPerson = new Person; + $this->testPerson->tenant_id = $this->tenant->id; + $this->testPerson->email_plain = 'search@example.com'; + $this->testPerson->phone_plain = '+49 123 456789'; + $this->testPerson->note_enc = 'Test note'; + $this->testPerson->save(); + }); + + test('returns 401 when not authenticated', function (): void { + $response = $this->getJson("/api/v1/tenants/{$this->tenant->id}/persons/by-email?email=search@example.com"); + + $response->assertStatus(401); + }); + + test('returns 403 when user lacks person.read permission', function (): void { + $response = $this->withToken($this->token) + ->getJson("/api/v1/tenants/{$this->tenant->id}/persons/by-email?email=search@example.com"); + + $response->assertStatus(403); + }); + + test('`GET /v1/tenants/{tenant}/persons/by-email` → returns 400 when email query parameter is missing', function (): void { + givePermissionWithTenant($this->user, $this->tenant->id, 'person.read'); + + $response = $this->withToken($this->token) + ->getJson("/api/v1/tenants/{$this->tenant->id}/persons/by-email"); + + $response->assertStatus(400) + ->assertJson([ + 'error' => 'Email query parameter is required', + ]); + }); + + test('returns 404 when Person not found', function (): void { + givePermissionWithTenant($this->user, $this->tenant->id, 'person.read'); + + $response = $this->withToken($this->token) + ->getJson("/api/v1/tenants/{$this->tenant->id}/persons/by-email?email=notfound@example.com"); + + $response->assertStatus(404) + ->assertJson([ + 'error' => 'Person not found', + ]); + }); + + test('finds Person by email and returns 200', function (): void { + givePermissionWithTenant($this->user, $this->tenant->id, 'person.read'); + + $response = $this->withToken($this->token) + ->getJson("/api/v1/tenants/{$this->tenant->id}/persons/by-email?email=search@example.com"); + + $response->assertStatus(200) + ->assertJsonStructure([ + 'id', + 'tenant_id', + 'created_at', + 'updated_at', + ]) + ->assertJsonFragment([ + 'id' => $this->testPerson->id, + 'tenant_id' => $this->tenant->id, + ]); + }); + + test('search is case-insensitive', function (): void { + givePermissionWithTenant($this->user, $this->tenant->id, 'person.read'); + + $response = $this->withToken($this->token) + ->getJson("/api/v1/tenants/{$this->tenant->id}/persons/by-email?email=SEARCH@EXAMPLE.COM"); + + $response->assertStatus(200) + ->assertJsonFragment([ + 'id' => $this->testPerson->id, + ]); + }); + + test('does not expose encrypted fields or blind indexes in response', function (): void { + givePermissionWithTenant($this->user, $this->tenant->id, 'person.read'); + + $response = $this->withToken($this->token) + ->getJson("/api/v1/tenants/{$this->tenant->id}/persons/by-email?email=search@example.com"); + + $response->assertStatus(200); + $json = $response->json(); + + expect($json)->not->toHaveKey('email_enc'); + expect($json)->not->toHaveKey('email_idx'); + expect($json)->not->toHaveKey('phone_enc'); + expect($json)->not->toHaveKey('phone_idx'); + expect($json)->not->toHaveKey('note_enc'); + }); + + test('enforces tenant isolation', function (): void { + // Create another tenant and person + $keys2 = TenantKey::generateEnvelopeKeys(); + $tenant2 = TenantKey::create($keys2); + + $person2 = new Person; + $person2->tenant_id = $tenant2->id; + $person2->email_plain = 'other@example.com'; + $person2->save(); + + givePermissionWithTenant($this->user, $this->tenant->id, 'person.read'); + + // Try to find tenant2's person using tenant1's endpoint + $response = $this->withToken($this->token) + ->getJson("/api/v1/tenants/{$this->tenant->id}/persons/by-email?email=other@example.com"); + + $response->assertStatus(404); + }); +}); diff --git a/tests/Feature/PersonTest.php b/tests/Feature/PersonTest.php index b36ef34..3142280 100644 --- a/tests/Feature/PersonTest.php +++ b/tests/Feature/PersonTest.php @@ -15,17 +15,9 @@ uses(RefreshDatabase::class); -/** - * Get process-specific KEK path for parallel test execution. - */ -function getPersonTestKekPath(): string -{ - return storage_path('app/keys/kek-test-'.getmypid().'.key'); -} - beforeEach(function (): void { // Use process-specific KEK file for parallel test isolation - TenantKey::setKekPath(getPersonTestKekPath()); + TenantKey::setKekPath(getTestKekPath()); // generateKek() will create the directory if needed TenantKey::generateKek(); @@ -385,9 +377,6 @@ function getPersonTestKekPath(): string afterEach(function (): void { // Cleanup process-specific KEK file - $kekPath = getPersonTestKekPath(); - if (file_exists($kekPath)) { - unlink($kekPath); - } + cleanupTestKekFile(); TenantKey::setKekPath(null); }); diff --git a/tests/Feature/TenantKeyTest.php b/tests/Feature/TenantKeyTest.php index b89e568..90c8d40 100644 --- a/tests/Feature/TenantKeyTest.php +++ b/tests/Feature/TenantKeyTest.php @@ -14,33 +14,14 @@ uses(RefreshDatabase::class); -/** - * Get process-specific KEK path for parallel test execution. - */ -function getProcessKekPath(): string -{ - return storage_path('app/keys/kek-test-'.getmypid().'.key'); -} - -/** - * Helper function to clean up the KEK file. - */ -function cleanupKekFile(): void -{ - $kekPath = getProcessKekPath(); - if (file_exists($kekPath)) { - unlink($kekPath); - } -} - // Clean up KEK file before each test for isolation beforeEach(function (): void { - cleanupKekFile(); - TenantKey::setKekPath(getProcessKekPath()); + cleanupTestKekFile(); + TenantKey::setKekPath(getTestKekPath()); }); afterEach(function (): void { - cleanupKekFile(); + cleanupTestKekFile(); TenantKey::setKekPath(null); }); @@ -59,7 +40,7 @@ function cleanupKekFile(): void test('throws exception when KEK file is missing', function (): void { // Explicitly remove KEK file to ensure clean state - cleanupKekFile(); + cleanupTestKekFile(); expect(fn () => TenantKey::generateEnvelopeKeys()) ->toThrow(RuntimeException::class, 'KEK file not found'); diff --git a/tests/Pest.php b/tests/Pest.php index 7ceb217..78f424b 100644 --- a/tests/Pest.php +++ b/tests/Pest.php @@ -39,3 +39,35 @@ | global functions to help you to reduce the number of lines of code in your test files. | */ + +/** + * Get process-specific KEK path for parallel test execution. + * Centralized helper to avoid duplication across test files. + */ +function getTestKekPath(): string +{ + return storage_path('app/keys/kek-test-'.getmypid().'.key'); +} + +/** + * Clean up the KEK file for the current process. + */ +function cleanupTestKekFile(): void +{ + $kekPath = getTestKekPath(); + if (file_exists($kekPath)) { + unlink($kekPath); + } +} + +/** + * Assign permissions to a user with proper tenant context. + * Sets Spatie Permission team ID, assigns permission, then resets team ID. + */ +function givePermissionWithTenant(\App\Models\User $user, int $tenantId, string $permission): void +{ + $registrar = app(\Spatie\Permission\PermissionRegistrar::class); + $registrar->setPermissionsTeamId($tenantId); + $user->givePermissionTo($permission); + $registrar->setPermissionsTeamId(null); +}