diff --git a/.github/workflows/php-ci.yml b/.github/workflows/php-ci.yml index 8316eb6..e94b34c 100644 --- a/.github/workflows/php-ci.yml +++ b/.github/workflows/php-ci.yml @@ -24,4 +24,55 @@ jobs: test: name: PEST Tests - uses: SecPal/.github/.github/workflows/reusable-php-test.yml@main + runs-on: ubuntu-latest + if: ${{ !startsWith(github.head_ref, 'spike/') && !startsWith(github.ref, 'refs/heads/spike/') }} + + services: + postgres: + image: postgres:17-alpine + env: + POSTGRES_DB: db + POSTGRES_USER: db + POSTGRES_PASSWORD: db + options: >- + --health-cmd pg_isready + --health-interval 10s + --health-timeout 5s + --health-retries 5 + ports: + - 5432:5432 + + steps: + - name: Checkout repository + uses: actions/checkout@v5 + + - name: Setup PHP + uses: shivammathur/setup-php@v2 + with: + php-version: "8.4" + extensions: mbstring, xml, ctype, iconv, intl, pdo, pdo_pgsql + coverage: xdebug + + - name: Get Composer Cache Directory + id: composer-cache + run: echo "dir=$(composer config cache-files-dir)" >> "$GITHUB_OUTPUT" + + - name: Cache Composer dependencies + uses: actions/cache@v4 + with: + path: ${{ steps.composer-cache.outputs.dir }} + key: ${{ runner.os }}-composer-${{ hashFiles('**/composer.lock') }} + restore-keys: ${{ runner.os }}-composer- + + - name: Install dependencies + run: composer install --prefer-dist --no-progress --no-interaction + + - name: Run tests + env: + DB_CONNECTION: pgsql + DB_HOST: localhost + DB_PORT: 5432 + DB_DATABASE: db + DB_USERNAME: db + DB_PASSWORD: db + run: php artisan test diff --git a/.github/workflows/quality.yml b/.github/workflows/quality.yml index a9659df..4427c92 100644 --- a/.github/workflows/quality.yml +++ b/.github/workflows/quality.yml @@ -44,7 +44,56 @@ jobs: pest: name: PEST Tests - uses: SecPal/.github/.github/workflows/reusable-php-test.yml@main - with: - php-version: "8.4" - test-command: "./vendor/bin/pest" + runs-on: ubuntu-latest + # Skip tests for spike branches (exploration/prototyping) + # Spike branches are for experimentation and cannot be merged to main + # See: CONTRIBUTING.md - Spike Branch Policy + if: ${{ !startsWith(github.head_ref, 'spike/') && !startsWith(github.ref, 'refs/heads/spike/') }} + services: + postgres: + image: postgres:17-alpine + env: + POSTGRES_DB: testing + POSTGRES_USER: testing + POSTGRES_PASSWORD: testing + options: >- + --health-cmd="pg_isready -U testing" + --health-interval=10s + --health-timeout=5s + --health-retries=5 + ports: + - 5432:5432 + steps: + - name: Checkout repository + uses: actions/checkout@v5 + + - name: Setup PHP + uses: shivammathur/setup-php@v2 + with: + php-version: "8.4" + extensions: mbstring, xml, ctype, iconv, intl, pdo_pgsql + coverage: xdebug + + - name: Get Composer Cache Directory + id: composer-cache + run: echo "dir=$(composer config cache-files-dir)" >> "$GITHUB_OUTPUT" + + - name: Cache Composer dependencies + uses: actions/cache@v4 + with: + path: ${{ steps.composer-cache.outputs.dir }} + key: ${{ runner.os }}-composer-${{ hashFiles('**/composer.lock') }} + restore-keys: ${{ runner.os }}-composer- + + - name: Install dependencies + run: composer install --prefer-dist --no-progress --no-interaction + + - name: Run Tests + env: + DB_CONNECTION: pgsql + DB_HOST: localhost + DB_PORT: 5432 + DB_DATABASE: testing + DB_USERNAME: testing + DB_PASSWORD: testing + run: ./vendor/bin/pest diff --git a/composer.json b/composer.json index d9d219c..9e27d72 100644 --- a/composer.json +++ b/composer.json @@ -11,7 +11,9 @@ "require": { "php": "^8.4", "laravel/framework": "^12.36", - "laravel/tinker": "^2.10.1" + "laravel/sanctum": "^4.2", + "laravel/tinker": "^2.10.1", + "spatie/laravel-permission": "^6.22" }, "require-dev": { "fakerphp/faker": "^1.23", diff --git a/composer.lock b/composer.lock index 9e2dfa4..d7c0c2d 100644 --- a/composer.lock +++ b/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": "bc4e2c9929c273c34ede98c3b2d1bed7", + "content-hash": "8d74c4e4f05036508492120903d1e85f", "packages": [ { "name": "brick/math", @@ -1331,6 +1331,70 @@ }, "time": "2025-09-19T13:47:56+00:00" }, + { + "name": "laravel/sanctum", + "version": "v4.2.0", + "source": { + "type": "git", + "url": "https://github.com/laravel/sanctum.git", + "reference": "fd6df4f79f48a72992e8d29a9c0ee25422a0d677" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/laravel/sanctum/zipball/fd6df4f79f48a72992e8d29a9c0ee25422a0d677", + "reference": "fd6df4f79f48a72992e8d29a9c0ee25422a0d677", + "shasum": "" + }, + "require": { + "ext-json": "*", + "illuminate/console": "^11.0|^12.0", + "illuminate/contracts": "^11.0|^12.0", + "illuminate/database": "^11.0|^12.0", + "illuminate/support": "^11.0|^12.0", + "php": "^8.2", + "symfony/console": "^7.0" + }, + "require-dev": { + "mockery/mockery": "^1.6", + "orchestra/testbench": "^9.0|^10.0", + "phpstan/phpstan": "^1.10", + "phpunit/phpunit": "^11.3" + }, + "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": "2025-07-09T19:45:24+00:00" + }, { "name": "laravel/serializable-closure", "version": "v2.0.6", @@ -3278,6 +3342,89 @@ }, "time": "2025-09-04T20:59:21+00:00" }, + { + "name": "spatie/laravel-permission", + "version": "6.22.0", + "source": { + "type": "git", + "url": "https://github.com/spatie/laravel-permission.git", + "reference": "8c87966ddc21893bfda54b792047473703992625" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/spatie/laravel-permission/zipball/8c87966ddc21893bfda54b792047473703992625", + "reference": "8c87966ddc21893bfda54b792047473703992625", + "shasum": "" + }, + "require": { + "illuminate/auth": "^8.12|^9.0|^10.0|^11.0|^12.0", + "illuminate/container": "^8.12|^9.0|^10.0|^11.0|^12.0", + "illuminate/contracts": "^8.12|^9.0|^10.0|^11.0|^12.0", + "illuminate/database": "^8.12|^9.0|^10.0|^11.0|^12.0", + "php": "^8.0" + }, + "require-dev": { + "laravel/passport": "^11.0|^12.0", + "laravel/pint": "^1.0", + "orchestra/testbench": "^6.23|^7.0|^8.0|^9.0|^10.0", + "phpunit/phpunit": "^9.4|^10.1|^11.5" + }, + "type": "library", + "extra": { + "laravel": { + "providers": [ + "Spatie\\Permission\\PermissionServiceProvider" + ] + }, + "branch-alias": { + "dev-main": "6.x-dev", + "dev-master": "6.x-dev" + } + }, + "autoload": { + "files": [ + "src/helpers.php" + ], + "psr-4": { + "Spatie\\Permission\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Freek Van der Herten", + "email": "freek@spatie.be", + "homepage": "https://spatie.be", + "role": "Developer" + } + ], + "description": "Permission handling for Laravel 8.0 and up", + "homepage": "https://github.com/spatie/laravel-permission", + "keywords": [ + "acl", + "laravel", + "permission", + "permissions", + "rbac", + "roles", + "security", + "spatie" + ], + "support": { + "issues": "https://github.com/spatie/laravel-permission/issues", + "source": "https://github.com/spatie/laravel-permission/tree/6.22.0" + }, + "funding": [ + { + "url": "https://github.com/spatie", + "type": "github" + } + ], + "time": "2025-10-27T21:58:45+00:00" + }, { "name": "symfony/clock", "version": "v7.3.0", diff --git a/database/migrations/2025_11_01_165633_create_tenant_keys_table.php b/database/migrations/2025_11_01_165633_create_tenant_keys_table.php new file mode 100644 index 0000000..ab44406 --- /dev/null +++ b/database/migrations/2025_11_01_165633_create_tenant_keys_table.php @@ -0,0 +1,38 @@ +id(); + $table->binary('dek_wrapped'); + $table->binary('dek_nonce'); + $table->binary('idx_wrapped'); + $table->binary('idx_nonce'); + $table->integer('key_version')->default(1); + $table->timestamp('created_at')->useCurrent(); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('tenant_keys'); + } +}; diff --git a/database/migrations/2025_11_01_165901_create_person_table.php b/database/migrations/2025_11_01_165901_create_person_table.php new file mode 100644 index 0000000..70e6d0c --- /dev/null +++ b/database/migrations/2025_11_01_165901_create_person_table.php @@ -0,0 +1,49 @@ +id(); + $table->foreignId('tenant_id')->constrained('tenant_keys')->onDelete('cascade'); + $table->binary('email_enc'); + $table->binary('email_idx'); + $table->binary('phone_enc'); + $table->binary('phone_idx'); + $table->text('note_enc')->nullable(); + $table->timestamps(); + + $table->index(['tenant_id', 'email_idx']); + $table->index(['tenant_id', 'phone_idx']); + }); + + // PostgreSQL-specific full-text search support + if (DB::connection()->getDriverName() === 'pgsql') { + DB::statement('ALTER TABLE person ADD COLUMN note_tsv tsvector'); + DB::statement('CREATE INDEX person_note_tsv_idx ON person USING GIN (note_tsv)'); + } + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('person'); + } +}; diff --git a/phpunit.xml b/phpunit.xml index 3d22d0e..e27f3b5 100644 --- a/phpunit.xml +++ b/phpunit.xml @@ -30,8 +30,8 @@ SPDX-License-Identifier: CC0-1.0 - - + + diff --git a/tests/Feature/PersonSchemaTest.php b/tests/Feature/PersonSchemaTest.php new file mode 100644 index 0000000..4083796 --- /dev/null +++ b/tests/Feature/PersonSchemaTest.php @@ -0,0 +1,119 @@ +toBeTrue(); +}); + +test('person has correct columns', function (): void { + expect(Schema::hasColumns('person', [ + 'id', + 'tenant_id', + 'email_enc', + 'email_idx', + 'phone_enc', + 'phone_idx', + 'note_enc', + 'note_tsv', + 'created_at', + 'updated_at', + ]))->toBeTrue(); +}); + +test('person binary columns have bytea type', function (): void { + $columns = DB::select(" + SELECT column_name, data_type + FROM information_schema.columns + WHERE table_name = 'person' + AND column_name IN ('email_enc', 'email_idx', 'phone_enc', 'phone_idx') + "); + + expect($columns)->toHaveCount(4); + + foreach ($columns as $column) { + expect($column)->toBeObject(); + expect($column->data_type)->toBe('bytea'); // @phpstan-ignore property.nonObject + } +}); + +test('person note_enc column exists and has text type', function (): void { + $column = DB::selectOne(" + SELECT data_type + FROM information_schema.columns + WHERE table_name = 'person' + AND column_name = 'note_enc' + "); + + expect($column)->toBeObject(); + expect($column->data_type)->toBe('text'); // @phpstan-ignore property.nonObject +}); + +test('person note_tsv has tsvector type', function (): void { + $column = DB::selectOne(" + SELECT data_type + FROM information_schema.columns + WHERE table_name = 'person' + AND column_name = 'note_tsv' + "); + + expect($column)->toBeObject(); + expect($column->data_type)->toBe('tsvector'); // @phpstan-ignore property.nonObject +}); + +test('person has tenant_id email_idx composite index', function (): void { + $index = DB::selectOne(" + SELECT indexname + FROM pg_indexes + WHERE tablename = 'person' + AND indexdef LIKE '%tenant_id%email_idx%' + "); + + expect($index)->not->toBeNull(); +}); + +test('person has tenant_id phone_idx composite index', function (): void { + $index = DB::selectOne(" + SELECT indexname + FROM pg_indexes + WHERE tablename = 'person' + AND indexdef LIKE '%tenant_id%phone_idx%' + "); + + expect($index)->not->toBeNull(); +}); + +test('person has GIN index on note_tsv', function (): void { + $index = DB::selectOne(" + SELECT indexname, indexdef + FROM pg_indexes + WHERE tablename = 'person' + AND indexname = 'person_note_tsv_idx' + "); + + expect($index)->not->toBeNull(); + expect($index)->toBeObject(); + expect($index->indexdef)->toContain('USING gin'); // @phpstan-ignore property.nonObject +}); + +test('person has foreign key to tenant_keys', function (): void { + $fk = DB::selectOne(" + SELECT constraint_name + FROM information_schema.table_constraints + WHERE table_name = 'person' + AND constraint_type = 'FOREIGN KEY' + AND constraint_name LIKE '%tenant_id%' + "); + + expect($fk)->not->toBeNull(); +}); diff --git a/tests/Feature/TenantKeysSchemaTest.php b/tests/Feature/TenantKeysSchemaTest.php new file mode 100644 index 0000000..f93221e --- /dev/null +++ b/tests/Feature/TenantKeysSchemaTest.php @@ -0,0 +1,57 @@ +toBeTrue(); +}); + +test('tenant_keys has correct columns', function (): void { + expect(Schema::hasColumns('tenant_keys', [ + 'id', + 'dek_wrapped', + 'dek_nonce', + 'idx_wrapped', + 'idx_nonce', + 'key_version', + 'created_at', + ]))->toBeTrue(); +}); + +test('tenant_keys binary columns have bytea type', function (): void { + $columns = DB::select(" + SELECT column_name, data_type + FROM information_schema.columns + WHERE table_name = 'tenant_keys' + AND column_name IN ('dek_wrapped', 'dek_nonce', 'idx_wrapped', 'idx_nonce') + "); + + expect($columns)->toHaveCount(4); + + foreach ($columns as $column) { + expect($column)->toBeObject(); + expect($column->data_type)->toBe('bytea'); // @phpstan-ignore property.nonObject + } +}); + +test('tenant_keys key_version has integer type', function (): void { + $column = DB::selectOne(" + SELECT data_type + FROM information_schema.columns + WHERE table_name = 'tenant_keys' + AND column_name = 'key_version' + "); + + expect($column)->toBeObject(); + expect($column->data_type)->toBe('integer'); // @phpstan-ignore property.nonObject +});