chore: add pgsql FK isolation checks
This commit is contained in:
parent
c905f211a5
commit
f45e0f5cf1
@ -55,6 +55,9 @@
|
||||
"@php artisan config:clear --ansi",
|
||||
"@php artisan test"
|
||||
],
|
||||
"test:pgsql": [
|
||||
"@php vendor/bin/pest -c phpunit.pgsql.xml"
|
||||
],
|
||||
"post-autoload-dump": [
|
||||
"Illuminate\\Foundation\\ComposerScripts::postAutoloadDump",
|
||||
"@php artisan package:discover --ansi",
|
||||
|
||||
@ -0,0 +1,102 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
/**
|
||||
* @return array<int, string>
|
||||
*/
|
||||
private function tenantOwnedTables(): array
|
||||
{
|
||||
return [
|
||||
'policies',
|
||||
'policy_versions',
|
||||
'backup_sets',
|
||||
'backup_items',
|
||||
'restore_runs',
|
||||
'backup_schedules',
|
||||
'inventory_items',
|
||||
'inventory_links',
|
||||
'entra_groups',
|
||||
'findings',
|
||||
'entra_role_definitions',
|
||||
'tenant_permissions',
|
||||
];
|
||||
}
|
||||
|
||||
private function ensureWorkspaceForeignKey(string $tableName, string $constraintName): void
|
||||
{
|
||||
// Add as NOT VALID (fast) then VALIDATE (safe) — Postgres best practice.
|
||||
DB::unprepared(<<<SQL
|
||||
DO $$
|
||||
BEGIN
|
||||
IF NOT EXISTS (
|
||||
SELECT 1
|
||||
FROM pg_constraint
|
||||
WHERE conname = '{$constraintName}'
|
||||
) THEN
|
||||
EXECUTE format(
|
||||
'ALTER TABLE %I ADD CONSTRAINT %I FOREIGN KEY (workspace_id) REFERENCES workspaces(id) ON DELETE CASCADE NOT VALID',
|
||||
'{$tableName}',
|
||||
'{$constraintName}'
|
||||
);
|
||||
END IF;
|
||||
|
||||
IF EXISTS (
|
||||
SELECT 1
|
||||
FROM pg_constraint
|
||||
WHERE conname = '{$constraintName}'
|
||||
AND convalidated = false
|
||||
) THEN
|
||||
EXECUTE format(
|
||||
'ALTER TABLE %I VALIDATE CONSTRAINT %I',
|
||||
'{$tableName}',
|
||||
'{$constraintName}'
|
||||
);
|
||||
END IF;
|
||||
END
|
||||
$$;
|
||||
SQL);
|
||||
}
|
||||
|
||||
public function up(): void
|
||||
{
|
||||
if (DB::getDriverName() !== 'pgsql') {
|
||||
return;
|
||||
}
|
||||
|
||||
foreach ($this->tenantOwnedTables() as $tableName) {
|
||||
$constraintName = sprintf('%s_workspace_fk', $tableName);
|
||||
|
||||
$this->ensureWorkspaceForeignKey($tableName, $constraintName);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Reverse the migrations.
|
||||
*/
|
||||
public function down(): void
|
||||
{
|
||||
if (DB::getDriverName() !== 'pgsql') {
|
||||
return;
|
||||
}
|
||||
|
||||
foreach ($this->tenantOwnedTables() as $tableName) {
|
||||
$constraintName = sprintf('%s_workspace_fk', $tableName);
|
||||
|
||||
DB::unprepared(<<<SQL
|
||||
DO $$
|
||||
BEGIN
|
||||
EXECUTE format(
|
||||
'ALTER TABLE %I DROP CONSTRAINT IF EXISTS %I',
|
||||
'{$tableName}',
|
||||
'{$constraintName}'
|
||||
);
|
||||
END
|
||||
$$;
|
||||
SQL);
|
||||
}
|
||||
}
|
||||
};
|
||||
50
phpunit.pgsql.xml
Normal file
50
phpunit.pgsql.xml
Normal file
@ -0,0 +1,50 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<phpunit xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
|
||||
xsi:noNamespaceSchemaLocation="vendor/phpunit/phpunit/phpunit.xsd"
|
||||
bootstrap="vendor/autoload.php"
|
||||
colors="true"
|
||||
>
|
||||
<testsuites>
|
||||
<testsuite name="Unit">
|
||||
<directory>tests/Unit</directory>
|
||||
</testsuite>
|
||||
<testsuite name="Feature">
|
||||
<directory>tests/Feature</directory>
|
||||
</testsuite>
|
||||
</testsuites>
|
||||
|
||||
<source>
|
||||
<include>
|
||||
<directory>app</directory>
|
||||
</include>
|
||||
</source>
|
||||
|
||||
<php>
|
||||
<ini name="memory_limit" value="2048M"/>
|
||||
|
||||
<env name="APP_ENV" value="testing"/>
|
||||
<env name="APP_KEY" value="base64:z63PQuXp3rUOQ0L4o8xp76xeakrn5X3owja1qFX3ccY="/>
|
||||
<env name="INTUNE_TENANT_ID" value="" force="true"/>
|
||||
|
||||
<env name="APP_MAINTENANCE_DRIVER" value="file"/>
|
||||
<env name="BCRYPT_ROUNDS" value="4"/>
|
||||
<env name="BROADCAST_CONNECTION" value="null"/>
|
||||
<env name="CACHE_STORE" value="array"/>
|
||||
|
||||
<!-- Postgres-backed test configuration (for schema-level assertions, FK validation, etc.) -->
|
||||
<env name="DB_CONNECTION" value="pgsql"/>
|
||||
<env name="DB_HOST" value="pgsql"/>
|
||||
<env name="DB_PORT" value="5432"/>
|
||||
<env name="DB_DATABASE" value="tenantatlas_testing"/>
|
||||
<env name="DB_USERNAME" value="root"/>
|
||||
<env name="DB_PASSWORD" value="postgres"/>
|
||||
|
||||
<env name="MAIL_MAILER" value="array"/>
|
||||
<env name="QUEUE_CONNECTION" value="sync"/>
|
||||
<env name="SESSION_DRIVER" value="array"/>
|
||||
|
||||
<env name="PULSE_ENABLED" value="false"/>
|
||||
<env name="TELESCOPE_ENABLED" value="false"/>
|
||||
<env name="NIGHTWATCH_ENABLED" value="false"/>
|
||||
</php>
|
||||
</phpunit>
|
||||
@ -0,0 +1,48 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
it('enforces workspace_id foreign keys on tenant-owned tables', function () {
|
||||
if (DB::getDriverName() !== 'pgsql') {
|
||||
$this->markTestSkipped('Postgres-only: validates FK constraints via pg_constraint.');
|
||||
}
|
||||
|
||||
$tables = [
|
||||
'policies',
|
||||
'policy_versions',
|
||||
'backup_sets',
|
||||
'backup_items',
|
||||
'restore_runs',
|
||||
'backup_schedules',
|
||||
'inventory_items',
|
||||
'inventory_links',
|
||||
'entra_groups',
|
||||
'findings',
|
||||
'entra_role_definitions',
|
||||
'tenant_permissions',
|
||||
];
|
||||
|
||||
foreach ($tables as $table) {
|
||||
$sql = <<<'SQL'
|
||||
SELECT c.conname, c.convalidated
|
||||
FROM pg_constraint c
|
||||
JOIN pg_class rel ON rel.oid = c.conrelid
|
||||
JOIN pg_class ref ON ref.oid = c.confrelid
|
||||
JOIN pg_attribute att ON att.attrelid = rel.oid AND att.attnum = ANY(c.conkey)
|
||||
WHERE c.contype = 'f'
|
||||
AND rel.relname = ?
|
||||
AND ref.relname = 'workspaces'
|
||||
AND att.attname = 'workspace_id'
|
||||
SQL;
|
||||
|
||||
$constraints = DB::select(
|
||||
$sql,
|
||||
[$table],
|
||||
);
|
||||
|
||||
expect($constraints)->not->toBeEmpty();
|
||||
|
||||
$allValidated = collect($constraints)->every(fn ($c): bool => (bool) $c->convalidated);
|
||||
expect($allValidated)->toBeTrue();
|
||||
}
|
||||
});
|
||||
Loading…
Reference in New Issue
Block a user