markTestSkipped('Spec 405 JSONB hardening is PostgreSQL-only.'); } }); it('converts every reviewed legacy json column to jsonb', function (): void { $columnTypes = spec405ColumnTypes(); foreach (spec405ConvertedJsonColumns() as [$table, $column]) { $qualifiedColumn = "{$table}.{$column}"; expect($columnTypes) ->toHaveKey($qualifiedColumn) ->and($columnTypes[$qualifiedColumn])->toBe('jsonb'); } $remainingJsonColumns = DB::table('information_schema.columns') ->where('table_schema', 'public') ->where('data_type', 'json') ->orderBy('table_name') ->orderBy('column_name') ->get(['table_name', 'column_name']) ->map(fn (object $column): string => "{$column->table_name}.{$column->column_name}") ->all(); expect($remainingJsonColumns)->toBeEmpty(); }); it('does not introduce speculative gin indexes for converted payload columns', function (): void { $convertedTables = collect(spec405ConvertedJsonColumns()) ->pluck(0) ->unique() ->values() ->all(); $convertedGinIndexes = DB::table('pg_indexes') ->where('schemaname', 'public') ->whereIn('tablename', $convertedTables) ->where('indexdef', 'ilike', '%USING gin%') ->orderBy('tablename') ->orderBy('indexname') ->get(['tablename', 'indexname']) ->map(fn (object $index): string => "{$index->tablename}.{$index->indexname}") ->all(); expect($convertedGinIndexes)->toBeEmpty(); }); it('preserves existing json rows attributes constraints indexes and rollback query compatibility', function (): void { $migration = spec405Migration(); $columnContractsBefore = spec405ColumnContracts(); $indexDefinitionsBefore = spec405IndexDefinitionsForConvertedTables(); $constraintDefinitionsBefore = spec405ConstraintDefinitionsForConvertedTables(); $migration->down(); try { $jsonColumnContracts = spec405ColumnContracts(); foreach (spec405ConvertedJsonColumns() as [$table, $column]) { $qualifiedColumn = "{$table}.{$column}"; expect($jsonColumnContracts[$qualifiedColumn]['data_type'])->toBe('json') ->and($jsonColumnContracts[$qualifiedColumn]['nullable'])->toBe($columnContractsBefore[$qualifiedColumn]['nullable']) ->and($jsonColumnContracts[$qualifiedColumn]['default'])->toBe($columnContractsBefore[$qualifiedColumn]['default']); } $ids = spec405CreateRepresentativePayloadRows(); $payloadsBefore = spec405RepresentativePayloads($ids); expect(BackupItem::withAssignments()->pluck('id')->map(fn (int $id): int => $id)->all()) ->toContain($ids['backup_item_id']); $migration->up(); expect(spec405ColumnContracts())->toBe($columnContractsBefore) ->and(spec405IndexDefinitionsForConvertedTables())->toBe($indexDefinitionsBefore) ->and(spec405ConstraintDefinitionsForConvertedTables())->toBe($constraintDefinitionsBefore) ->and(spec405RepresentativePayloads($ids))->toMatchArray($payloadsBefore); expect(BackupItem::withAssignments()->pluck('id')->map(fn (int $id): int => $id)->all()) ->toContain($ids['backup_item_id']); $migration->down(); expect(spec405ColumnTypes()['backup_items.assignments'])->toBe('json') ->and(BackupItem::withAssignments()->pluck('id')->map(fn (int $id): int => $id)->all()) ->toContain($ids['backup_item_id']); } finally { if ((spec405ColumnTypes()['backup_items.assignments'] ?? null) !== 'jsonb') { $migration->up(); } } }); it('preserves representative jsonb read write and cast behavior', function (): void { $tenant = ManagedEnvironment::factory()->create([ 'metadata' => ['source' => 'spec405', 'nested' => ['enabled' => true]], ]); DB::table('managed_environments') ->where('id', (int) $tenant->getKey()) ->update([ 'rbac_canary_results' => json_encode(['status' => 'pass', 'checks' => ['read' => true]], JSON_THROW_ON_ERROR), 'rbac_last_warnings' => json_encode(['warnings' => ['scope review required']], JSON_THROW_ON_ERROR), ]); $tenant->refresh(); expect($tenant->metadata)->toMatchArray(['nested' => ['enabled' => true], 'source' => 'spec405']) ->and($tenant->rbac_canary_results)->toMatchArray(['checks' => ['read' => true], 'status' => 'pass']) ->and($tenant->rbac_last_warnings)->toMatchArray(['warnings' => ['scope review required']]); $backupSet = BackupSet::factory()->create([ 'managed_environment_id' => (int) $tenant->getKey(), 'workspace_id' => (int) $tenant->workspace_id, 'metadata' => ['source' => 'spec405'], ]); $policy = Policy::factory()->create([ 'managed_environment_id' => (int) $tenant->getKey(), 'workspace_id' => (int) $tenant->workspace_id, 'metadata' => ['platform_family' => 'windows'], ]); $backupItem = BackupItem::factory()->create([ 'managed_environment_id' => (int) $tenant->getKey(), 'workspace_id' => (int) $tenant->workspace_id, 'backup_set_id' => (int) $backupSet->getKey(), 'policy_id' => (int) $policy->getKey(), 'payload' => ['id' => 'policy-jsonb', 'settings' => [['name' => 'mode', 'value' => 'audit']]], 'metadata' => ['scope_tag_names' => ['Default', 'Security']], 'assignments' => [['id' => 'assignment-1', 'target' => ['groupId' => 'group-jsonb']]], ]); expect($backupItem->refresh()->payload)->toMatchArray(['id' => 'policy-jsonb', 'settings' => [['name' => 'mode', 'value' => 'audit']]]) ->and($backupItem->metadata)->toMatchArray(['scope_tag_names' => ['Default', 'Security']]) ->and($backupItem->assignments)->toBe([['id' => 'assignment-1', 'target' => ['groupId' => 'group-jsonb']]]); $policyVersion = PolicyVersion::factory()->create([ 'managed_environment_id' => (int) $tenant->getKey(), 'workspace_id' => (int) $tenant->workspace_id, 'policy_id' => (int) $policy->getKey(), 'snapshot' => ['displayName' => 'Spec 405 policy', 'settings' => [['id' => 'setting-1']]], 'metadata' => ['source' => 'unit'], 'assignments' => [['target' => ['groupId' => 'group-jsonb']]], 'scope_tags' => ['0', 'security'], 'secret_fingerprints' => ['snapshot' => [], 'assignments' => ['hash']], ]); expect($policyVersion->refresh()->snapshot)->toMatchArray(['displayName' => 'Spec 405 policy', 'settings' => [['id' => 'setting-1']]]) ->and($policyVersion->metadata)->toMatchArray(['source' => 'unit']) ->and($policyVersion->assignments)->toBe([['target' => ['groupId' => 'group-jsonb']]]) ->and($policyVersion->scope_tags)->toBe(['0', 'security']) ->and($policyVersion->secret_fingerprints)->toMatchArray(['assignments' => ['hash'], 'snapshot' => []]); $restoreRun = RestoreRun::factory()->create([ 'managed_environment_id' => (int) $tenant->getKey(), 'workspace_id' => (int) $tenant->workspace_id, 'backup_set_id' => (int) $backupSet->getKey(), 'requested_items' => [['policy_identifier' => 'policy-jsonb']], 'preview' => [['action' => 'update', 'policy_identifier' => 'policy-jsonb']], 'results' => ['items' => [['status' => 'applied']]], 'metadata' => ['dry_run' => false], 'group_mapping' => ['old-group' => 'new-group'], ]); expect($restoreRun->refresh()->requested_items)->toBe([['policy_identifier' => 'policy-jsonb']]) ->and($restoreRun->preview)->toBe([['action' => 'update', 'policy_identifier' => 'policy-jsonb']]) ->and($restoreRun->results)->toMatchArray(['items' => [['status' => 'applied']]]) ->and($restoreRun->metadata)->toMatchArray(['dry_run' => false]) ->and($restoreRun->group_mapping)->toMatchArray(['old-group' => 'new-group']); $auditLog = AuditLog::query()->create([ 'workspace_id' => (int) $tenant->workspace_id, 'managed_environment_id' => (int) $tenant->getKey(), 'action' => 'spec405.jsonb.regression', 'resource_type' => 'policy', 'resource_id' => 'policy-jsonb', 'status' => 'success', 'metadata' => ['_dedupe_key' => 'spec405-jsonb', 'reason' => 'schema regression'], 'recorded_at' => now(), ]); expect($auditLog->refresh()->metadata)->toHaveKey('_dedupe_key', 'spec405-jsonb'); $metadataLookupExists = AuditLog::query() ->whereRaw("metadata ->> '_dedupe_key' = ?", ['spec405-jsonb']) ->exists(); expect($metadataLookupExists)->toBeTrue(); $schedule = BackupSchedule::query()->create([ 'workspace_id' => (int) $tenant->workspace_id, 'managed_environment_id' => (int) $tenant->getKey(), 'name' => 'Spec 405 weekly backup', 'frequency' => 'weekly', 'time_of_day' => '03:00:00', 'days_of_week' => [1, 3, 5], 'policy_types' => ['settingsCatalogPolicy', 'deviceConfiguration'], ]); expect($schedule->refresh()->days_of_week)->toBe([1, 3, 5]) ->and($schedule->policy_types)->toBe(['settingsCatalogPolicy', 'deviceConfiguration']); $alertRule = AlertRule::factory()->create([ 'workspace_id' => (int) $tenant->workspace_id, 'tenant_allowlist' => [(int) $tenant->getKey()], ]); $alertDelivery = AlertDelivery::factory()->create([ 'workspace_id' => (int) $tenant->workspace_id, 'managed_environment_id' => (int) $tenant->getKey(), 'alert_rule_id' => (int) $alertRule->getKey(), 'payload' => ['title' => 'Spec 405', 'context' => ['severity' => 'high']], ]); $permission = ManagedEnvironmentPermission::query()->create([ 'workspace_id' => (int) $tenant->workspace_id, 'managed_environment_id' => (int) $tenant->getKey(), 'permission_key' => 'DeviceManagementConfiguration.Read.All', 'status' => 'granted', 'details' => ['source' => 'graph', 'roles' => ['reader']], 'last_checked_at' => now(), ]); $onboardingSession = ManagedEnvironmentOnboardingSession::factory()->create([ 'workspace_id' => (int) $tenant->workspace_id, 'state' => ['entra_tenant_id' => 'tenant-jsonb', 'step' => 'verify'], ]); $tenantSetting = TenantSetting::factory()->create([ 'workspace_id' => (int) $tenant->workspace_id, 'managed_environment_id' => (int) $tenant->getKey(), 'value' => ['keep_last' => 45], ]); $workspaceSetting = WorkspaceSetting::factory()->create([ 'workspace_id' => (int) $tenant->workspace_id, 'value' => ['keep_last' => 30], ]); expect($backupSet->refresh()->metadata)->toMatchArray(['source' => 'spec405']) ->and($policy->refresh()->metadata)->toMatchArray(['platform_family' => 'windows']) ->and($alertRule->refresh()->tenant_allowlist)->toBe([(int) $tenant->getKey()]) ->and($alertDelivery->refresh()->payload)->toMatchArray(['context' => ['severity' => 'high'], 'title' => 'Spec 405']) ->and($permission->refresh()->details)->toMatchArray(['roles' => ['reader'], 'source' => 'graph']) ->and($onboardingSession->refresh()->state)->toMatchArray(['entra_tenant_id' => 'tenant-jsonb']) ->and($tenantSetting->refresh()->value)->toMatchArray(['keep_last' => 45]) ->and($workspaceSetting->refresh()->value)->toMatchArray(['keep_last' => 30]); }); it('keeps backup item assignment filtering compatible with jsonb columns', function (): void { BackupItem::factory()->create(['assignments' => null]); BackupItem::factory()->create(['assignments' => []]); $withAssignments = BackupItem::factory()->create([ 'assignments' => [ ['id' => 'assignment-1', 'target' => ['groupId' => 'group-1']], ], ]); $resultIds = BackupItem::withAssignments() ->pluck('id') ->map(fn (int $id): int => (int) $id) ->all(); expect($resultIds)->toBe([(int) $withAssignments->getKey()]); }); /** * @return list */ function spec405ConvertedJsonColumns(): array { return [ ['alert_deliveries', 'payload'], ['alert_rules', 'tenant_allowlist'], ['audit_logs', 'metadata'], ['backup_items', 'assignments'], ['backup_items', 'metadata'], ['backup_items', 'payload'], ['backup_schedules', 'days_of_week'], ['backup_schedules', 'policy_types'], ['backup_sets', 'metadata'], ['managed_environment_onboarding_sessions', 'state'], ['managed_environment_permissions', 'details'], ['managed_environments', 'metadata'], ['managed_environments', 'rbac_canary_results'], ['managed_environments', 'rbac_last_warnings'], ['policies', 'metadata'], ['policy_versions', 'assignments'], ['policy_versions', 'metadata'], ['policy_versions', 'scope_tags'], ['policy_versions', 'secret_fingerprints'], ['policy_versions', 'snapshot'], ['restore_runs', 'group_mapping'], ['restore_runs', 'metadata'], ['restore_runs', 'preview'], ['restore_runs', 'requested_items'], ['restore_runs', 'results'], ['tenant_settings', 'value'], ['workspace_settings', 'value'], ]; } function spec405Migration(): Migration { return require database_path('migrations/2026_06_23_000405_convert_trust_payload_json_columns_to_jsonb.php'); } /** * @return list */ function spec405ConvertedTables(): array { return collect(spec405ConvertedJsonColumns()) ->pluck(0) ->unique() ->values() ->all(); } /** * @return array */ function spec405ColumnContracts(): array { return DB::table('information_schema.columns') ->where('table_schema', 'public') ->whereIn('table_name', spec405ConvertedTables()) ->orderBy('table_name') ->orderBy('column_name') ->get(['table_name', 'column_name', 'data_type', 'is_nullable', 'column_default']) ->mapWithKeys(fn (object $column): array => [ "{$column->table_name}.{$column->column_name}" => [ 'data_type' => (string) $column->data_type, 'nullable' => (string) $column->is_nullable, 'default' => $column->column_default === null ? null : (string) $column->column_default, ], ]) ->only(collect(spec405ConvertedJsonColumns())->map(fn (array $column): string => "{$column[0]}.{$column[1]}")->all()) ->all(); } /** * @return array */ function spec405IndexDefinitionsForConvertedTables(): array { return DB::table('pg_indexes') ->where('schemaname', 'public') ->whereIn('tablename', spec405ConvertedTables()) ->orderBy('tablename') ->orderBy('indexname') ->get(['tablename', 'indexname', 'indexdef']) ->mapWithKeys(fn (object $index): array => [ "{$index->tablename}.{$index->indexname}" => (string) $index->indexdef, ]) ->all(); } /** * @return array */ function spec405ConstraintDefinitionsForConvertedTables(): array { return DB::table('pg_constraint') ->selectRaw('conrelid::regclass::text as table_name, conname, pg_get_constraintdef(oid) as definition') ->whereRaw("connamespace = 'public'::regnamespace") ->whereIn(DB::raw('conrelid::regclass::text'), spec405ConvertedTables()) ->orderBy('table_name') ->orderBy('conname') ->get() ->mapWithKeys(fn (object $constraint): array => [ "{$constraint->table_name}.{$constraint->conname}" => (string) $constraint->definition, ]) ->all(); } /** * @return array */ function spec405CreateRepresentativePayloadRows(): array { $tenant = ManagedEnvironment::factory()->create([ 'metadata' => ['source' => 'spec405', 'nested' => ['enabled' => true]], ]); DB::table('managed_environments') ->where('id', (int) $tenant->getKey()) ->update([ 'rbac_canary_results' => json_encode(['status' => 'pass', 'checks' => ['read' => true]], JSON_THROW_ON_ERROR), 'rbac_last_warnings' => json_encode(['warnings' => ['scope review required']], JSON_THROW_ON_ERROR), ]); $backupSet = BackupSet::factory()->create([ 'managed_environment_id' => (int) $tenant->getKey(), 'workspace_id' => (int) $tenant->workspace_id, 'metadata' => ['source' => 'spec405'], ]); $policy = Policy::factory()->create([ 'managed_environment_id' => (int) $tenant->getKey(), 'workspace_id' => (int) $tenant->workspace_id, 'metadata' => ['platform_family' => 'windows'], ]); $backupItem = BackupItem::factory()->create([ 'managed_environment_id' => (int) $tenant->getKey(), 'workspace_id' => (int) $tenant->workspace_id, 'backup_set_id' => (int) $backupSet->getKey(), 'policy_id' => (int) $policy->getKey(), 'payload' => ['id' => 'policy-jsonb', 'settings' => [['name' => 'mode', 'value' => 'audit']]], 'metadata' => ['scope_tag_names' => ['Default', 'Security']], 'assignments' => [['id' => 'assignment-1', 'target' => ['groupId' => 'group-jsonb']]], ]); $policyVersion = PolicyVersion::factory()->create([ 'managed_environment_id' => (int) $tenant->getKey(), 'workspace_id' => (int) $tenant->workspace_id, 'policy_id' => (int) $policy->getKey(), 'snapshot' => ['displayName' => 'Spec 405 policy', 'settings' => [['id' => 'setting-1']]], 'metadata' => ['source' => 'unit'], 'assignments' => [['target' => ['groupId' => 'group-jsonb']]], 'scope_tags' => ['0', 'security'], 'secret_fingerprints' => ['snapshot' => [], 'assignments' => ['hash']], ]); $restoreRun = RestoreRun::factory()->create([ 'managed_environment_id' => (int) $tenant->getKey(), 'workspace_id' => (int) $tenant->workspace_id, 'backup_set_id' => (int) $backupSet->getKey(), 'requested_items' => [['policy_identifier' => 'policy-jsonb']], 'preview' => [['action' => 'update', 'policy_identifier' => 'policy-jsonb']], 'results' => ['items' => [['status' => 'applied']]], 'metadata' => ['dry_run' => false], 'group_mapping' => ['old-group' => 'new-group'], ]); $auditLog = AuditLog::query()->create([ 'workspace_id' => (int) $tenant->workspace_id, 'managed_environment_id' => (int) $tenant->getKey(), 'action' => 'spec405.jsonb.regression', 'resource_type' => 'policy', 'resource_id' => 'policy-jsonb', 'status' => 'success', 'metadata' => ['_dedupe_key' => 'spec405-jsonb', 'reason' => 'schema regression'], 'recorded_at' => now(), ]); $schedule = BackupSchedule::query()->create([ 'workspace_id' => (int) $tenant->workspace_id, 'managed_environment_id' => (int) $tenant->getKey(), 'name' => 'Spec 405 weekly backup', 'frequency' => 'weekly', 'time_of_day' => '03:00:00', 'days_of_week' => [1, 3, 5], 'policy_types' => ['settingsCatalogPolicy', 'deviceConfiguration'], ]); $alertRule = AlertRule::factory()->create([ 'workspace_id' => (int) $tenant->workspace_id, 'tenant_allowlist' => [(int) $tenant->getKey()], ]); $alertDelivery = AlertDelivery::factory()->create([ 'workspace_id' => (int) $tenant->workspace_id, 'managed_environment_id' => (int) $tenant->getKey(), 'alert_rule_id' => (int) $alertRule->getKey(), 'payload' => ['title' => 'Spec 405', 'context' => ['severity' => 'high']], ]); $permission = ManagedEnvironmentPermission::query()->create([ 'workspace_id' => (int) $tenant->workspace_id, 'managed_environment_id' => (int) $tenant->getKey(), 'permission_key' => 'DeviceManagementConfiguration.Read.All', 'status' => 'granted', 'details' => ['source' => 'graph', 'roles' => ['reader']], 'last_checked_at' => now(), ]); $onboardingSession = ManagedEnvironmentOnboardingSession::factory()->create([ 'workspace_id' => (int) $tenant->workspace_id, 'state' => ['entra_tenant_id' => 'tenant-jsonb', 'step' => 'verify'], ]); $tenantSetting = TenantSetting::factory()->create([ 'workspace_id' => (int) $tenant->workspace_id, 'managed_environment_id' => (int) $tenant->getKey(), 'value' => ['keep_last' => 45], ]); $workspaceSetting = WorkspaceSetting::factory()->create([ 'workspace_id' => (int) $tenant->workspace_id, 'value' => ['keep_last' => 30], ]); return [ 'alert_delivery_id' => (int) $alertDelivery->getKey(), 'alert_rule_id' => (int) $alertRule->getKey(), 'audit_log_id' => (int) $auditLog->getKey(), 'backup_item_id' => (int) $backupItem->getKey(), 'backup_schedule_id' => (int) $schedule->getKey(), 'backup_set_id' => (int) $backupSet->getKey(), 'managed_environment_id' => (int) $tenant->getKey(), 'managed_environment_onboarding_session_id' => (int) $onboardingSession->getKey(), 'managed_environment_permission_id' => (int) $permission->getKey(), 'policy_id' => (int) $policy->getKey(), 'policy_version_id' => (int) $policyVersion->getKey(), 'restore_run_id' => (int) $restoreRun->getKey(), 'tenant_setting_id' => (int) $tenantSetting->getKey(), 'workspace_setting_id' => (int) $workspaceSetting->getKey(), ]; } /** * @param array $ids * @return array */ function spec405RepresentativePayloads(array $ids): array { $alertDelivery = AlertDelivery::query()->findOrFail($ids['alert_delivery_id']); $alertRule = AlertRule::query()->findOrFail($ids['alert_rule_id']); $auditLog = AuditLog::query()->findOrFail($ids['audit_log_id']); $backupItem = BackupItem::query()->findOrFail($ids['backup_item_id']); $backupSchedule = BackupSchedule::query()->findOrFail($ids['backup_schedule_id']); $backupSet = BackupSet::query()->findOrFail($ids['backup_set_id']); $tenant = ManagedEnvironment::query()->findOrFail($ids['managed_environment_id']); $onboardingSession = ManagedEnvironmentOnboardingSession::query()->findOrFail($ids['managed_environment_onboarding_session_id']); $permission = ManagedEnvironmentPermission::query()->findOrFail($ids['managed_environment_permission_id']); $policy = Policy::query()->findOrFail($ids['policy_id']); $policyVersion = PolicyVersion::query()->findOrFail($ids['policy_version_id']); $restoreRun = RestoreRun::query()->findOrFail($ids['restore_run_id']); $tenantSetting = TenantSetting::query()->findOrFail($ids['tenant_setting_id']); $workspaceSetting = WorkspaceSetting::query()->findOrFail($ids['workspace_setting_id']); return [ 'alert_deliveries.payload' => $alertDelivery->payload, 'alert_rules.tenant_allowlist' => $alertRule->tenant_allowlist, 'audit_logs.metadata' => $auditLog->metadata, 'backup_items.assignments' => $backupItem->assignments, 'backup_items.metadata' => $backupItem->metadata, 'backup_items.payload' => $backupItem->payload, 'backup_schedules.days_of_week' => $backupSchedule->days_of_week, 'backup_schedules.policy_types' => $backupSchedule->policy_types, 'backup_sets.metadata' => $backupSet->metadata, 'managed_environment_onboarding_sessions.state' => $onboardingSession->state, 'managed_environment_permissions.details' => $permission->details, 'managed_environments.metadata' => $tenant->metadata, 'managed_environments.rbac_canary_results' => $tenant->rbac_canary_results, 'managed_environments.rbac_last_warnings' => $tenant->rbac_last_warnings, 'policies.metadata' => $policy->metadata, 'policy_versions.assignments' => $policyVersion->assignments, 'policy_versions.metadata' => $policyVersion->metadata, 'policy_versions.scope_tags' => $policyVersion->scope_tags, 'policy_versions.secret_fingerprints' => $policyVersion->secret_fingerprints, 'policy_versions.snapshot' => $policyVersion->snapshot, 'restore_runs.group_mapping' => $restoreRun->group_mapping, 'restore_runs.metadata' => $restoreRun->metadata, 'restore_runs.preview' => $restoreRun->preview, 'restore_runs.requested_items' => $restoreRun->requested_items, 'restore_runs.results' => $restoreRun->results, 'tenant_settings.value' => $tenantSetting->value, 'workspace_settings.value' => $workspaceSetting->value, ]; } /** * @return array */ function spec405ColumnTypes(): array { $tables = collect(spec405ConvertedJsonColumns()) ->pluck(0) ->unique() ->values() ->all(); return DB::table('information_schema.columns') ->where('table_schema', 'public') ->whereIn('table_name', $tables) ->get(['table_name', 'column_name', 'data_type']) ->mapWithKeys(fn (object $column): array => [ "{$column->table_name}.{$column->column_name}" => (string) $column->data_type, ]) ->all(); }