Implemented sync capture backup operation semantics as requested. Co-authored-by: Ahmed Darrazi <ahmed.darrazi@live.de> Reviewed-on: #433
244 lines
9.0 KiB
PHP
244 lines
9.0 KiB
PHP
<?php
|
|
|
|
declare(strict_types=1);
|
|
|
|
use App\Models\InventoryItem;
|
|
use App\Models\ManagedEnvironment;
|
|
use App\Models\OperationRun;
|
|
use App\Services\AdapterRunReconciler;
|
|
use App\Support\Inventory\InventoryCoverage;
|
|
use App\Support\OperationRunLinks;
|
|
use App\Support\OperationRunOutcome;
|
|
use App\Support\OperationRunStatus;
|
|
use Illuminate\Foundation\Testing\RefreshDatabase;
|
|
|
|
uses(RefreshDatabase::class);
|
|
|
|
it('reconciles legacy inventory sync runs from full current-scope coverage in Spec362', function (): void {
|
|
[, $tenant] = createUserWithTenant(role: 'owner');
|
|
|
|
$run = OperationRun::factory()->forTenant($tenant)->create([
|
|
'type' => 'inventory_sync',
|
|
'status' => OperationRunStatus::Queued->value,
|
|
'outcome' => OperationRunOutcome::Pending->value,
|
|
'created_at' => now()->subMinutes(20),
|
|
'context' => [
|
|
'policy_types' => ['conditionalAccessPolicy', 'deviceConfiguration'],
|
|
'include_foundations' => false,
|
|
'inventory' => [
|
|
'coverage' => InventoryCoverage::buildPayload([
|
|
'conditionalAccessPolicy' => 'succeeded',
|
|
'deviceConfiguration' => 'succeeded',
|
|
], []),
|
|
],
|
|
],
|
|
]);
|
|
|
|
InventoryItem::factory()->create([
|
|
'managed_environment_id' => (int) $tenant->getKey(),
|
|
'policy_type' => 'conditionalAccessPolicy',
|
|
'last_seen_operation_run_id' => (int) $run->getKey(),
|
|
]);
|
|
InventoryItem::factory()->create([
|
|
'managed_environment_id' => (int) $tenant->getKey(),
|
|
'policy_type' => 'deviceConfiguration',
|
|
'last_seen_operation_run_id' => (int) $run->getKey(),
|
|
]);
|
|
|
|
$result = app(AdapterRunReconciler::class)->reconcile([
|
|
'type' => 'inventory_sync',
|
|
'managed_environment_id' => (int) $tenant->getKey(),
|
|
'older_than_minutes' => 10,
|
|
'limit' => 10,
|
|
'dry_run' => false,
|
|
]);
|
|
|
|
expect($result['candidates'] ?? null)->toBe(1)
|
|
->and($result['reconciled'] ?? null)->toBe(1);
|
|
|
|
$run->refresh();
|
|
|
|
expect($run->status)->toBe(OperationRunStatus::Completed->value)
|
|
->and($run->outcome)->toBe(OperationRunOutcome::Succeeded->value)
|
|
->and($run->reconciliationDecision())->toBe('reconciled_succeeded')
|
|
->and($run->reconciliationAdapter())->toBe('inventory_sync')
|
|
->and($run->summary_counts)->toMatchArray([
|
|
'total' => 2,
|
|
'processed' => 2,
|
|
'succeeded' => 2,
|
|
'failed' => 0,
|
|
'skipped' => 0,
|
|
'items' => 2,
|
|
'updated' => 2,
|
|
])
|
|
->and(InventoryItem::query()->where('last_seen_operation_run_id', (int) $run->getKey())->count())->toBe(2);
|
|
|
|
expect(array_keys(OperationRunLinks::related($run->fresh(), $tenant)))
|
|
->toContain('Inventory', 'Inventory Coverage');
|
|
});
|
|
|
|
it('marks inventory sync runs partially succeeded when usable coverage exists alongside blocked proof in Spec362', function (): void {
|
|
[, $tenant] = createUserWithTenant(role: 'owner');
|
|
|
|
$run = OperationRun::factory()->forTenant($tenant)->create([
|
|
'type' => 'inventory.sync',
|
|
'status' => OperationRunStatus::Queued->value,
|
|
'outcome' => OperationRunOutcome::Pending->value,
|
|
'created_at' => now()->subMinutes(20),
|
|
'context' => [
|
|
'policy_types' => ['conditionalAccessPolicy', 'deviceConfiguration'],
|
|
'include_foundations' => false,
|
|
'inventory' => [
|
|
'coverage' => InventoryCoverage::buildPayload([
|
|
'conditionalAccessPolicy' => 'succeeded',
|
|
'deviceConfiguration' => 'skipped',
|
|
], []),
|
|
],
|
|
'result' => [
|
|
'error_codes' => ['concurrency_limit_tenant'],
|
|
],
|
|
],
|
|
]);
|
|
|
|
$change = app(AdapterRunReconciler::class)->reconcileOperationRun($run, false);
|
|
|
|
expect($change['applied'] ?? null)->toBeTrue();
|
|
|
|
$run->refresh();
|
|
|
|
expect($run->outcome)->toBe(OperationRunOutcome::PartiallySucceeded->value)
|
|
->and($run->reconciliationDecision())->toBe('reconciled_partially_succeeded')
|
|
->and($run->summary_counts)->toMatchArray([
|
|
'total' => 2,
|
|
'succeeded' => 1,
|
|
'failed' => 0,
|
|
'skipped' => 1,
|
|
]);
|
|
});
|
|
|
|
it('marks inventory sync runs blocked when no canonical coverage was written and lock contention is the only proof in Spec362', function (): void {
|
|
[, $tenant] = createUserWithTenant(role: 'owner');
|
|
|
|
$run = OperationRun::factory()->forTenant($tenant)->create([
|
|
'type' => 'inventory.sync',
|
|
'status' => OperationRunStatus::Queued->value,
|
|
'outcome' => OperationRunOutcome::Pending->value,
|
|
'created_at' => now()->subMinutes(20),
|
|
'context' => [
|
|
'policy_types' => ['deviceConfiguration'],
|
|
'include_foundations' => false,
|
|
'result' => [
|
|
'error_codes' => ['lock_contended'],
|
|
],
|
|
],
|
|
]);
|
|
|
|
$change = app(AdapterRunReconciler::class)->reconcileOperationRun($run, false);
|
|
|
|
expect($change['applied'] ?? null)->toBeTrue();
|
|
|
|
$run->refresh();
|
|
|
|
expect($run->outcome)->toBe(OperationRunOutcome::Blocked->value)
|
|
->and($run->reconciliationDecision())->toBe('blocked')
|
|
->and(data_get($run->failure_summary, '0.message'))->toContain('same selection lock');
|
|
});
|
|
|
|
it('fails closed when inventory proof leaks outside the queued tenant scope in Spec362', function (): void {
|
|
[, $tenant] = createUserWithTenant(role: 'owner');
|
|
$foreignTenant = ManagedEnvironment::factory()->create([
|
|
'workspace_id' => (int) $tenant->workspace_id,
|
|
]);
|
|
|
|
$run = OperationRun::factory()->forTenant($tenant)->create([
|
|
'type' => 'inventory.sync',
|
|
'status' => OperationRunStatus::Queued->value,
|
|
'outcome' => OperationRunOutcome::Pending->value,
|
|
'created_at' => now()->subMinutes(20),
|
|
'context' => [
|
|
'policy_types' => ['deviceConfiguration'],
|
|
'include_foundations' => false,
|
|
'inventory' => [
|
|
'coverage' => InventoryCoverage::buildPayload([
|
|
'deviceConfiguration' => 'succeeded',
|
|
], []),
|
|
],
|
|
],
|
|
]);
|
|
|
|
InventoryItem::factory()->create([
|
|
'managed_environment_id' => (int) $tenant->getKey(),
|
|
'policy_type' => 'deviceConfiguration',
|
|
'last_seen_operation_run_id' => (int) $run->getKey(),
|
|
]);
|
|
InventoryItem::factory()->create([
|
|
'managed_environment_id' => (int) $foreignTenant->getKey(),
|
|
'policy_type' => 'deviceConfiguration',
|
|
'last_seen_operation_run_id' => (int) $run->getKey(),
|
|
]);
|
|
|
|
$change = app(AdapterRunReconciler::class)->reconcileOperationRun($run, true);
|
|
|
|
expect($change['applied'] ?? null)->toBeFalse()
|
|
->and($change['decision'] ?? null)->toBe('not_reconciled')
|
|
->and((string) ($change['reason_message'] ?? ''))->toContain('outside the run scope');
|
|
|
|
$run->refresh();
|
|
|
|
expect($run->status)->toBe(OperationRunStatus::Queued->value)
|
|
->and($run->outcome)->toBe(OperationRunOutcome::Pending->value);
|
|
});
|
|
|
|
it('keeps selected-family reconciliation idempotent once a run is finalized in Spec362', function (): void {
|
|
[, $tenant] = createUserWithTenant(role: 'owner');
|
|
|
|
$run = OperationRun::factory()->forTenant($tenant)->create([
|
|
'type' => 'inventory_sync',
|
|
'status' => OperationRunStatus::Queued->value,
|
|
'outcome' => OperationRunOutcome::Pending->value,
|
|
'created_at' => now()->subMinutes(20),
|
|
'context' => [
|
|
'policy_types' => ['deviceConfiguration'],
|
|
'include_foundations' => false,
|
|
'inventory' => [
|
|
'coverage' => InventoryCoverage::buildPayload([
|
|
'deviceConfiguration' => 'succeeded',
|
|
], []),
|
|
],
|
|
],
|
|
]);
|
|
|
|
InventoryItem::factory()->create([
|
|
'managed_environment_id' => (int) $tenant->getKey(),
|
|
'policy_type' => 'deviceConfiguration',
|
|
'last_seen_operation_run_id' => (int) $run->getKey(),
|
|
]);
|
|
|
|
$reconciler = app(AdapterRunReconciler::class);
|
|
|
|
$first = $reconciler->reconcile([
|
|
'type' => 'inventory_sync',
|
|
'managed_environment_id' => (int) $tenant->getKey(),
|
|
'older_than_minutes' => 10,
|
|
'limit' => 10,
|
|
'dry_run' => false,
|
|
]);
|
|
|
|
$second = $reconciler->reconcile([
|
|
'type' => 'inventory_sync',
|
|
'managed_environment_id' => (int) $tenant->getKey(),
|
|
'older_than_minutes' => 10,
|
|
'limit' => 10,
|
|
'dry_run' => false,
|
|
]);
|
|
|
|
$run->refresh();
|
|
|
|
expect($first['reconciled'] ?? null)->toBe(1)
|
|
->and($second['candidates'] ?? null)->toBe(0)
|
|
->and($second['reconciled'] ?? null)->toBe(0)
|
|
->and(data_get($run->context, 'reconciliation.adapter'))->toBe('inventory_sync')
|
|
->and(data_get($run->context, 'reconciliation.previous_status'))->toBe(OperationRunStatus::Queued->value)
|
|
->and(data_get($run->context, 'reconciliation.reason_code'))->toBe('run.adapter_out_of_sync');
|
|
});
|