From 9f980ce80eaa834613c33c1c026c40a8e1dc8a61 Mon Sep 17 00:00:00 2001 From: Ahmed Darrazi Date: Sat, 17 Jan 2026 23:14:20 +0100 Subject: [PATCH] =?UTF-8?q?feat(054):=20finalize=20docs=20=E2=80=94=20RBAC?= =?UTF-8?q?=20delegated=20group=20search=20+=20Restore=20DB-only=20mapping?= =?UTF-8?q?;=20constitution=20note?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .npmignore | 1 + .specify/memory/constitution.md | 39 ++- .specify/templates/plan-template.md | 3 +- .specify/templates/spec-template.md | 6 +- .specify/templates/tasks-template.md | 3 + Agents.md | 7 + ...otReconcileBackupScheduleOperationRuns.php | 219 +++++++++++++ app/Filament/Pages/DriftLanding.php | 108 +++++-- app/Filament/Pages/InventoryLanding.php | 63 +++- app/Filament/Pages/Monitoring/Operations.php | 125 ++++++++ .../Resources/BackupScheduleResource.php | 237 ++++++++++++-- .../BackupItemsRelationManager.php | 206 +++++++++--- .../Resources/BulkOperationRunResource.php | 15 + .../Pages/ListEntraGroups.php | 67 +++- .../Resources/EntraGroupSyncRunResource.php | 15 + .../Resources/InventorySyncRunResource.php | 15 + .../Resources/OperationRunResource.php | 245 ++++++++++++++ .../Pages/ListOperationRuns.php | 11 + .../Pages/ViewOperationRun.php | 50 +++ app/Filament/Resources/PolicyResource.php | 156 +++++++-- .../PolicyResource/Pages/ListPolicies.php | 124 +++++--- app/Filament/Resources/RestoreRunResource.php | 4 +- app/Filament/Resources/TenantResource.php | 134 +++++++- app/Jobs/AddPoliciesToBackupSetJob.php | 63 +++- app/Jobs/EntraGroupSyncJob.php | 41 ++- app/Jobs/GenerateDriftFindingsJob.php | 125 +++++--- app/Jobs/Middleware/TrackOperationRun.php | 61 ++++ app/Jobs/PruneOldOperationRunsJob.php | 31 ++ app/Jobs/RemovePoliciesFromBackupSetJob.php | 301 ++++++++++++++++++ app/Jobs/RunBackupScheduleJob.php | 267 +++++++++++++++- app/Jobs/RunInventorySyncJob.php | 81 ++++- app/Jobs/SyncPoliciesJob.php | 151 ++++++++- app/Listeners/SyncRestoreRunToOperation.php | 81 +++++ .../SyncRestoreRunToOperationRun.php | 88 +++++ app/Livewire/BackupSetPolicyPickerTable.php | 65 +++- app/Livewire/Monitoring/OperationsDetail.php | 28 ++ app/Models/OperationRun.php | 38 +++ app/Notifications/OperationRunCompleted.php | 46 +++ app/Notifications/OperationRunQueued.php | 46 +++ app/Observers/RestoreRunObserver.php | 32 ++ app/Policies/OperationRunPolicy.php | 39 +++ app/Providers/AppServiceProvider.php | 7 + app/Providers/Filament/AdminPanelProvider.php | 4 +- app/Services/OperationRunService.php | 282 ++++++++++++++++ app/Support/OperationRunLinks.php | 84 +++++ app/Support/OperationRunOutcome.php | 43 +++ app/Support/OperationRunStatus.php | 15 + app/Support/OperationRunType.php | 22 ++ config/tenantpilot.php | 2 + database/factories/OperationRunFactory.php | 37 +++ ..._16_180642_create_operation_runs_table.php | 46 +++ .../filament/pages/drift-landing.blade.php | 12 +- .../pages/monitoring/operations.blade.php | 3 + .../bulk-operation-progress.blade.php | 5 +- .../monitoring/operations-detail.blade.php | 60 ++++ routes/console.php | 6 + specs/046-inventory-sync-button/research.md | 2 +- .../research.md | 3 +- specs/053-unify-runs-monitoring/research.md | 2 +- .../checklists/requirements.md | 2 + .../checklists/review.md | 61 ++++ .../contracts/admin-pages.openapi.yaml | 7 +- .../contracts/routes.md | 14 +- .../contracts/service_interface.md | 9 +- specs/054-unify-runs-suitewide/data-model.md | 27 +- specs/054-unify-runs-suitewide/plan.md | 106 +++--- specs/054-unify-runs-suitewide/quickstart.md | 28 +- specs/054-unify-runs-suitewide/research.md | 26 +- specs/054-unify-runs-suitewide/spec.md | 112 ++++++- specs/054-unify-runs-suitewide/tasks.md | 243 +++++++++++--- .../RunBackupScheduleJobTest.php | 219 ++++++++++--- .../RunNowRetryActionsTest.php | 104 +++++- .../AddPoliciesStartSurfaceTest.php | 70 ++++ .../RemovePoliciesJobNotificationTest.php | 65 ++++ .../RemovePoliciesStartSurfaceTest.php | 60 ++++ ...BackupScheduleOperationRunsCommandTest.php | 88 +++++ .../StartSyncFromGroupsPageTest.php | 72 +++++ .../Drift/DriftGenerationDispatchTest.php | 51 ++- ...nerateDriftFindingsJobNotificationTest.php | 45 ++- .../Filament/BackupItemsBulkRemoveTest.php | 30 +- .../InventorySyncStartSurfaceTest.php | 58 ++++ .../Inventory/RunInventorySyncJobTest.php | 10 +- tests/Feature/MonitoringOperationsTest.php | 140 ++++++++ .../OperationRunNotificationTest.php | 131 ++++++++ tests/Feature/OperationRunServiceTest.php | 171 ++++++++++ tests/Feature/PolicySyncStartSurfaceTest.php | 181 +++++++++++ tests/Feature/RestoreAdapterTest.php | 93 ++++++ .../PruneOldOperationRunsScheduleTest.php | 15 + .../TrackOperationRunMiddlewareTest.php | 43 +++ 89 files changed, 5827 insertions(+), 526 deletions(-) create mode 100644 app/Console/Commands/TenantpilotReconcileBackupScheduleOperationRuns.php create mode 100644 app/Filament/Pages/Monitoring/Operations.php create mode 100644 app/Filament/Resources/OperationRunResource.php create mode 100644 app/Filament/Resources/OperationRunResource/Pages/ListOperationRuns.php create mode 100644 app/Filament/Resources/OperationRunResource/Pages/ViewOperationRun.php create mode 100644 app/Jobs/Middleware/TrackOperationRun.php create mode 100644 app/Jobs/PruneOldOperationRunsJob.php create mode 100644 app/Jobs/RemovePoliciesFromBackupSetJob.php create mode 100644 app/Listeners/SyncRestoreRunToOperation.php create mode 100644 app/Listeners/SyncRestoreRunToOperationRun.php create mode 100644 app/Livewire/Monitoring/OperationsDetail.php create mode 100644 app/Models/OperationRun.php create mode 100644 app/Notifications/OperationRunCompleted.php create mode 100644 app/Notifications/OperationRunQueued.php create mode 100644 app/Observers/RestoreRunObserver.php create mode 100644 app/Policies/OperationRunPolicy.php create mode 100644 app/Services/OperationRunService.php create mode 100644 app/Support/OperationRunLinks.php create mode 100644 app/Support/OperationRunOutcome.php create mode 100644 app/Support/OperationRunStatus.php create mode 100644 app/Support/OperationRunType.php create mode 100644 database/factories/OperationRunFactory.php create mode 100644 database/migrations/2026_01_16_180642_create_operation_runs_table.php create mode 100644 resources/views/filament/pages/monitoring/operations.blade.php create mode 100644 resources/views/livewire/monitoring/operations-detail.blade.php create mode 100644 specs/054-unify-runs-suitewide/checklists/review.md create mode 100644 tests/Feature/BackupSets/AddPoliciesStartSurfaceTest.php create mode 100644 tests/Feature/BackupSets/RemovePoliciesJobNotificationTest.php create mode 100644 tests/Feature/BackupSets/RemovePoliciesStartSurfaceTest.php create mode 100644 tests/Feature/Console/ReconcileBackupScheduleOperationRunsCommandTest.php create mode 100644 tests/Feature/DirectoryGroups/StartSyncFromGroupsPageTest.php create mode 100644 tests/Feature/Inventory/InventorySyncStartSurfaceTest.php create mode 100644 tests/Feature/MonitoringOperationsTest.php create mode 100644 tests/Feature/Notifications/OperationRunNotificationTest.php create mode 100644 tests/Feature/OperationRunServiceTest.php create mode 100644 tests/Feature/PolicySyncStartSurfaceTest.php create mode 100644 tests/Feature/RestoreAdapterTest.php create mode 100644 tests/Feature/Scheduling/PruneOldOperationRunsScheduleTest.php create mode 100644 tests/Feature/TrackOperationRunMiddlewareTest.php diff --git a/.npmignore b/.npmignore index e669c3f..204381c 100644 --- a/.npmignore +++ b/.npmignore @@ -2,6 +2,7 @@ dist/ build/ public/build/ node_modules/ +vendor/ *.log .env .env.* diff --git a/.specify/memory/constitution.md b/.specify/memory/constitution.md index 3df8c1e..dd44daa 100644 --- a/.specify/memory/constitution.md +++ b/.specify/memory/constitution.md @@ -1,16 +1,11 @@ @if($runs->isNotEmpty())
diff --git a/resources/views/livewire/monitoring/operations-detail.blade.php b/resources/views/livewire/monitoring/operations-detail.blade.php new file mode 100644 index 0000000..aebedd5 --- /dev/null +++ b/resources/views/livewire/monitoring/operations-detail.blade.php @@ -0,0 +1,60 @@ +
+
+
+

Summary

+
+
Type:
+
{{ $run->type }}
+ +
Status:
+
{{ $run->status }}
+ +
Outcome:
+
{{ $run->outcome }}
+ +
Initiator:
+
{{ $run->initiator_name }}
+
+
+ +
+

Timing

+
+
Created:
+
{{ $run->created_at }}
+ +
Started:
+
{{ $run->started_at ?? '-' }}
+ +
Completed:
+
{{ $run->completed_at ?? '-' }}
+
+
+
+ + @if(!empty($run->summary_counts)) +
+

Counts

+
{{ json_encode($run->summary_counts, JSON_PRETTY_PRINT) }}
+
+ @endif + + @if(!empty($run->failure_summary)) +
+

Failures

+
+ @foreach($run->failure_summary as $failure) +
+
{{ $failure['code'] ?? 'UNKNOWN' }}
+
{{ $failure['message'] ?? 'Unknown error' }}
+
+ @endforeach +
+
+ @endif + +
+

Context

+
{{ json_encode($run->context, JSON_PRETTY_PRINT) }}
+
+
diff --git a/routes/console.php b/routes/console.php index 49eb3f5..de19a60 100644 --- a/routes/console.php +++ b/routes/console.php @@ -1,5 +1,6 @@ everyMinute(); Schedule::command('tenantpilot:directory-groups:dispatch')->everyMinute(); + +Schedule::job(new PruneOldOperationRunsJob) + ->daily() + ->name(PruneOldOperationRunsJob::class) + ->withoutOverlapping(); diff --git a/specs/046-inventory-sync-button/research.md b/specs/046-inventory-sync-button/research.md index f9ca3eb..f95f247 100644 --- a/specs/046-inventory-sync-button/research.md +++ b/specs/046-inventory-sync-button/research.md @@ -54,7 +54,7 @@ ### Decision: Default selection = “full inventory” ### Decision: Attribute initiator on run record and audit trail - **Chosen**: Store initiator identity on `InventorySyncRun` and also emit an audit record. -- **Rationale**: Improves traceability and aligns with constitution principle “Automation must be Idempotent & Observable”. +- **Rationale**: Improves traceability and aligns with constitution principle “Operations / Run Observability Standard”. - **Alternatives considered**: - Audit log only — rejected (you chose C). diff --git a/specs/049-backup-restore-job-orchestration/research.md b/specs/049-backup-restore-job-orchestration/research.md index f22e7c9..344a2f5 100644 --- a/specs/049-backup-restore-job-orchestration/research.md +++ b/specs/049-backup-restore-job-orchestration/research.md @@ -42,7 +42,7 @@ ### 3) Idempotency & de-duplication - Behavior: if an identical run is `queued`/`running`, reuse it and return/link to it; allow a new run only after terminal. **Rationale:** -- Matches the constitution (“Automation must be Idempotent & Observable”) and aligns with existing patterns (inventory selection hash + schedule locks). +- Matches the constitution (“Operations / Run Observability Standard”) and aligns with existing patterns (inventory selection hash + schedule locks). **Alternatives considered:** - **Cache-only locks** (`Cache::lock(...)`) without persisted keys. @@ -75,4 +75,3 @@ ## Clarifications resolved - **SC-003 includes “canceled”** while Phase 1 explicitly has “no cancel”. - Resolution for Phase 1 planning: treat “canceled” as out-of-scope (Phase 2+) and map “aborted” (if present) into the `failed` bucket for SC accounting. - diff --git a/specs/053-unify-runs-monitoring/research.md b/specs/053-unify-runs-monitoring/research.md index 1bc4cce..3afd57a 100644 --- a/specs/053-unify-runs-monitoring/research.md +++ b/specs/053-unify-runs-monitoring/research.md @@ -80,7 +80,7 @@ ### 6) Idempotency & de-duplication - Race reduction: rely on the existing partial unique index for active runs and handle collisions by finding and reusing the existing run. **Rationale:** -- Aligns with the constitution (“Automation must be Idempotent & Observable”). +- Aligns with the constitution (“Operations / Run Observability Standard”). - Durable across restarts and observable in the database. **Alternatives considered:** diff --git a/specs/054-unify-runs-suitewide/checklists/requirements.md b/specs/054-unify-runs-suitewide/checklists/requirements.md index 2886bac..70ccd84 100644 --- a/specs/054-unify-runs-suitewide/checklists/requirements.md +++ b/specs/054-unify-runs-suitewide/checklists/requirements.md @@ -6,6 +6,8 @@ ## Phase 1 Adoption Set - [x] `directory_groups.sync` (Directory → Groups “Sync groups”) covered in spec - [x] `drift.generate` (Drift “Generate drift now”) covered in spec - [x] `backup_set.add_policies` (Backup Sets “Add selected”) covered in spec +- [x] `backup_schedule.run_now` (Backup Schedules “Run now”) covered in implementation +- [x] `backup_schedule.retry` (Backup Schedules “Retry”) covered in implementation - [x] `restore.execute` (adapter mode) covered in spec ## Critical Clarifications (Pinned) diff --git a/specs/054-unify-runs-suitewide/checklists/review.md b/specs/054-unify-runs-suitewide/checklists/review.md new file mode 100644 index 0000000..0e20552 --- /dev/null +++ b/specs/054-unify-runs-suitewide/checklists/review.md @@ -0,0 +1,61 @@ +# Spec Review Checklist: Unified Operations Runs Suitewide (054) + +**Purpose**: Validate that `spec.md` is PR-reviewable and implementable by checking requirement quality (clarity, completeness, consistency, and testability), with emphasis on Audit-only vs OperationRun boundaries and tenant isolation/privacy/sanitization. +**Created**: 2026-01-17 +**Feature**: `specs/054-unify-runs-suitewide/spec.md` + +**Note**: This checklist is generated by the `/speckit.checklist` command based on feature context and requirements. + +## Requirement Completeness + +- [x] CHK001 Are Phase 1 adoption-set operations explicitly enumerated and scoped? [Completeness] Evidence: `spec.md` §Scope & Assumptions (“Phase 1 adoption set”) +- [x] CHK002 Are required Phase 1 run types explicitly listed and stable (including restore adapter and backup schedules)? [Completeness] Evidence: `spec.md` §FR-003 +- [x] CHK003 Are mandatory run record fields specified (initiator, type, status/timestamps, outcome, counts, failures, identity, context)? [Completeness] Evidence: `spec.md` §FR-001 +- [x] CHK004 Are lifecycle states and outcome buckets defined with allowed values? [Completeness] Evidence: `spec.md` §FR-011–FR-012 +- [x] CHK005 Are idempotency identity rules specified for each Phase 1 run type (effective inputs included/excluded)? [Completeness] Evidence: `spec.md` §FR-010 +- [x] CHK006 Are role/permission requirements defined for viewing runs vs starting operations? [Completeness] Evidence: `spec.md` §FR-018 + §User Story 1 (Scenario 5) + §User Story 2 (Scenario 2) +- [x] CHK007 Are notification requirements defined for queued and terminal outcomes (recipient, summary, “View run” link)? [Completeness] Evidence: `spec.md` §FR-015 + §User Story 2 (Scenario 3) + +## Audit-only vs OperationRun Boundaries + +- [x] CHK008 Is the eligibility criteria for Audit-only actions fully specified (DB-only, ≤2s, no remote calls, no background work)? [Clarity] Evidence: `spec.md` §FR-019 +- [x] CHK009 Is it unambiguous when an action may remain Audit-only versus must be an OperationRun (e.g., operational relevance vs queued/remote)? [Clarity] Evidence: `spec.md` §FR-019 + §Rule (Run vs Audit-only) +- [x] CHK010 Are “security-relevant” / “operational behavior change” triggers for mandatory AuditLog entries defined beyond examples (so classification is reviewable)? [Clarity] Evidence: `spec.md` §FR-019 (“Trigger guidance”) +- [x] CHK011 Are required AuditLog fields complete and unambiguous (tenant, actor, stable action ID, target, before/after or diff, timestamp)? [Completeness] Evidence: `spec.md` §FR-019 +- [x] CHK012 Does the spec define sanitization expectations for `before/after/diff` in AuditLog (what must be excluded) rather than assuming it? [Privacy] Evidence: `spec.md` §FR-019 (“Sanitization (AuditLog before/after/diff)”) +- [x] CHK013 Does the adoption matrix cover the required feature areas and assign a stable `run_type` or audit action id for each? [Completeness] Evidence: `spec.md` §Run vs Audit-only Adoption Matrix (Phase 1) +- [x] CHK014 Are the adoption matrix audit action identifiers consistent with the “stable action identifier” requirement for AuditLog entries? [Consistency] Evidence: `spec.md` §FR-019 + §Run vs Audit-only Adoption Matrix +- [x] CHK015 Are FR-019 acceptance checks sufficient and non-contradictory (no OperationRun; exactly one AuditLog; tenant-scoped; cross-tenant forbidden)? [Acceptance Criteria] Evidence: `spec.md` §FR-019 (Acceptance checks) + +## Tenant Isolation & Privacy / Sanitization + +- [x] CHK016 Are tenant isolation requirements explicitly stated for run list access and run detail access? [Completeness] Evidence: `spec.md` §FR-016 + §User Story 1 (Scenarios 1, 6) +- [x] CHK017 Is “cross-tenant access is denied without disclosing run details” sufficiently specified to be reviewable (what must not be exposed)? [Clarity] Evidence: `spec.md` §FR-016 + §User Story 1 (Scenario 6) +- [x] CHK018 Are start-surface authorization requirements explicit enough to prevent unauthorized run creation (especially Readonly)? [Completeness] Evidence: `spec.md` §FR-007 + §FR-018 + §User Story 2 (Scenario 2) +- [x] CHK019 Are persisted failure requirements explicit about what MUST NOT be stored (tokens/credentials/PII/raw payload dumps)? [Clarity] Evidence: `spec.md` §FR-013 + §SC-004 +- [x] CHK020 Are “stable reason codes” and “short sanitized messages” defined enough to be objectively reviewable (format expectations or examples)? [Clarity] Evidence: `spec.md` §FR-013 (Reason codes + Messages) +- [x] CHK021 Does the spec define what may be stored in run `context` and require it to be safe/sanitized (no secrets/PII)? [Privacy] Evidence: `spec.md` §FR-001 (“Context safety”) +- [x] CHK022 Are Monitoring render-time constraints explicit (DB-only; no external calls; no remote fetches at render time)? [Completeness] Evidence: `spec.md` §FR-017 + §FR-008 + +## Requirement Consistency + +- [x] CHK023 Are the terms “status” and “outcome bucket” used consistently (and are queued/running treated consistently across the doc)? [Consistency] Evidence: `spec.md` §FR-001 (Status/Outcome semantics) + §FR-011 (Run state presentation) +- [x] CHK024 Are run type naming rules consistent across taxonomy, Phase 1 run list, and the adoption matrix (spelling/casing)? [Consistency] Evidence: `spec.md` §FR-002 + §FR-003 + §Run vs Audit-only Adoption Matrix +- [x] CHK025 Is restore adapter behavior described consistently across Clarifications, Scope (“Restore visibility”), FR-003, and Key Entities? [Consistency] Evidence: `spec.md` §Clarifications (restore) + §Scope & Assumptions (“Restore visibility”) + §FR-003 + §Key Entities +- [x] CHK026 Are retention and default monitoring window expectations consistent (retention vs default list time range)? [Consistency] Evidence: `spec.md` §Clarifications (retention) + §Assumptions + §FR-004 + +## Acceptance Criteria Quality + +- [x] CHK027 Are success criteria measurable and objectively verifiable without implementation details? [Measurability] Evidence: `spec.md` §SC-001–SC-004 +- [x] CHK028 Is the ≥99% dedupe target defined with a measurement scope (what counts as an attempt; “normal conditions” definition)? [Clarity] Evidence: `spec.md` §SC-003 (Measurement scope) + §FR-009 +- [x] CHK029 Is “no secrets/PII” defined with an explicit boundary sufficient for reviewers to validate completeness? [Clarity] Evidence: `spec.md` §SC-004 + §FR-013 + +## Scenario Coverage + +- [x] CHK030 Are primary, forbidden, and “background unavailable” scenarios covered with explicit, testable outcomes (including “must not claim queued”)? [Coverage] Evidence: `spec.md` §User Stories 1–3 + §User Story 2 (Scenario 4) + §Edge Cases + +## Notes + +- Check items off as completed: `[x]` +- Add findings inline (e.g., under a checklist item) with links to the relevant spec section +- This checklist evaluates requirements quality, not implementation correctness diff --git a/specs/054-unify-runs-suitewide/contracts/admin-pages.openapi.yaml b/specs/054-unify-runs-suitewide/contracts/admin-pages.openapi.yaml index c3d5238..28315b7 100644 --- a/specs/054-unify-runs-suitewide/contracts/admin-pages.openapi.yaml +++ b/specs/054-unify-runs-suitewide/contracts/admin-pages.openapi.yaml @@ -12,7 +12,7 @@ servers: - url: / paths: - /admin/t/{tenantExternalId}/bulk-operation-runs: + /admin/t/{tenantExternalId}/operations: get: operationId: monitoringOperationsIndex summary: Monitoring → Operations (tenant-scoped) @@ -28,7 +28,7 @@ paths: '302': description: Redirect to login when unauthenticated. - /admin/t/{tenantExternalId}/bulk-operation-runs/{bulkOperationRunId}: + /admin/t/{tenantExternalId}/operations/{operationRunId}: get: operationId: monitoringOperationsView summary: Operation run detail (tenant-scoped) @@ -38,7 +38,7 @@ paths: required: true schema: type: string - - name: bulkOperationRunId + - name: operationRunId in: path required: true schema: @@ -52,4 +52,3 @@ paths: description: Forbidden when attempting cross-tenant access. components: {} - diff --git a/specs/054-unify-runs-suitewide/contracts/routes.md b/specs/054-unify-runs-suitewide/contracts/routes.md index f139181..ec1a27d 100644 --- a/specs/054-unify-runs-suitewide/contracts/routes.md +++ b/specs/054-unify-runs-suitewide/contracts/routes.md @@ -3,16 +3,12 @@ # Routes & URLs ## Monitoring UI ### List Operations -- **Route**: `tenant.monitoring.operations.index` -- **URL**: `/tenants/{tenant}/monitoring/operations` -- **Controller**: Livewire Component (`App\Livewire\Monitoring\OperationsList`) +- **URL**: `/admin/t/{tenantExternalId}/operations` +- **Surface**: Filament Resource `App\Filament\Resources\OperationRunResource` (index) ### View Operation -- **Route**: `tenant.monitoring.operations.show` -- **URL**: `/tenants/{tenant}/monitoring/operations/{run}` -- **Controller**: Livewire Component (`App\Livewire\Monitoring\OperationsDetail`) +- **URL**: `/admin/t/{tenantExternalId}/operations/{operationRunId}` +- **Surface**: Filament Resource `App\Filament\Resources\OperationRunResource` (view) ## Deep Links -- **Drift**: `/tenants/{tenant}/drift/history/{id}` -- **Inventory**: `/tenants/{tenant}/inventory` (General, or specific timestamp if supported) -- **Restore**: `/tenants/{tenant}/restore/{id}` \ No newline at end of file +- Use Filament URL helpers (`Resource::getUrl(...)`, `Page::getUrl(...)`) to generate tenant-scoped links back to owning feature surfaces/results. diff --git a/specs/054-unify-runs-suitewide/contracts/service_interface.md b/specs/054-unify-runs-suitewide/contracts/service_interface.md index b3db982..8540b5d 100644 --- a/specs/054-unify-runs-suitewide/contracts/service_interface.md +++ b/specs/054-unify-runs-suitewide/contracts/service_interface.md @@ -20,6 +20,10 @@ ### `ensureRun` 3. If found, return it. 4. If not found, create new `queued` run. 5. Return run. + 6. If an existing active run is returned (dedupe), the initiator (`user_id`, `initiator_name`) MUST NOT be replaced. + +- **Dispatch failure**: + - If queue dispatch fails after a run was created, the system MUST NOT leave misleading queued runs; instead complete the run immediately as `failed` (e.g., failure code `queue.dispatch_failed`) and show a clear UI message. ### `updateRun` Updates the status/outcome of a run. @@ -44,5 +48,6 @@ ### `failRun` ## `App\Jobs\Middleware\TrackOperationRun` Middleware for Jobs to automatically handle `running` -> `completed`/`failed` transitions if bound to a run. -## `App\Listeners\SyncRestoreRunToOperation` -Listener for `RestoreRun` events to update the shadow `OperationRun`. \ No newline at end of file +## `App\Listeners\SyncRestoreRunToOperationRun` +Listener for `RestoreRun` events to update the shadow `OperationRun`. +The adapter row is created/visible only once a restore run reaches `previewed` (or later). diff --git a/specs/054-unify-runs-suitewide/data-model.md b/specs/054-unify-runs-suitewide/data-model.md index 589e6ac..bc18a04 100644 --- a/specs/054-unify-runs-suitewide/data-model.md +++ b/specs/054-unify-runs-suitewide/data-model.md @@ -16,7 +16,7 @@ ### `OperationRun` | `outcome` | String | Yes | Result bucket: `pending`, `succeeded`, `partially_succeeded`, `failed`, `cancelled`. | | `run_identity_hash` | String | Yes | Deterministic hash for idempotency. | | `summary_counts` | JSONB | No | `{ "total": 10, "success": 8, "failed": 2, "skipped": 0 }` | -| `failure_summary` | JSONB | No | List of sanitized errors: `[{ "code": "GraphError", "message": "Throttled", "count": 1 }]` | +| `failure_summary` | JSONB | No | List of sanitized errors: `[{ "code": "graph.throttled", "message": "Throttled (retrying)", "count": 1 }]` | | `context` | JSONB | No | Run-specific metadata. e.g., `{ "restore_run_id": 123, "selection": [...] }` | | `started_at` | Timestamp | No | When execution began. | | `completed_at` | Timestamp | No | When execution finished. | @@ -31,7 +31,9 @@ ### `OperationRun` ### `RestoreRun` (Existing) Remains the domain source of truth for Restore. - Linked via `OperationRun.context['restore_run_id']`. -- `OperationRun` mirrors `RestoreRun` status/outcome. +- Adapter row is created/visible only once `RestoreRunStatus=previewed` (or later). + - When `RestoreRunStatus=previewed`, the adapter uses `OperationRun.status=queued` and `OperationRun.outcome=pending`. +- `OperationRun` mirrors the restore execution lifecycle for Monitoring visibility (restore domain history remains owned by `RestoreRun`). ## Enums @@ -45,7 +47,26 @@ ### `OperationRunOutcome` - `succeeded` - `partially_succeeded` - `failed` -- `cancelled` +- `cancelled` (reserved/future; MUST NOT be produced by 054) + +**UI label mapping** (display-only): + +- `pending` → “Pending” +- `succeeded` → “Succeeded” +- `partially_succeeded` → “Partially succeeded” +- `failed` → “Failed” +- `cancelled` → “Cancelled” (reserved) + +## State Transitions + +`OperationRun.status` transitions: + +- `queued` → `running` → `completed` + +`OperationRun.outcome` transitions: + +- `pending` while `status` is `queued` or `running` +- one of `succeeded`, `partially_succeeded`, `failed`, `cancelled` when `status` is `completed` ## Relationships - `OperationRun` belongs to `Tenant`. diff --git a/specs/054-unify-runs-suitewide/plan.md b/specs/054-unify-runs-suitewide/plan.md index 0a09e67..3df874d 100644 --- a/specs/054-unify-runs-suitewide/plan.md +++ b/specs/054-unify-runs-suitewide/plan.md @@ -1,76 +1,98 @@ -# Implementation Plan: Unified Operations Runs Suitewide +# Implementation Plan: Unified Operations Runs Suitewide (054) -**Branch**: `feat/054-unify-operations-runs-suitewide` | **Date**: 2026-01-16 | **Spec**: [Spec Link](spec.md) -**Input**: Feature specification from `specs/054-unify-runs-suitewide/spec.md` +**Branch**: `feat/054-unify-operations-runs-suitewide` | **Date**: 2026-01-17 | **Spec**: `/Users/ahmeddarrazi/Documents/projects/TenantAtlas/specs/054-unify-runs-suitewide/spec.md` ([spec.md](spec.md)) +**Input**: Feature specification from `/Users/ahmeddarrazi/Documents/projects/TenantAtlas/specs/054-unify-runs-suitewide/spec.md` + +**Note**: This plan is filled in by the `/speckit.plan` command. See `.specify/scripts/` for helper scripts. ## Summary -This feature unifies long-running tenant operations (e.g., Inventory Sync, Drift Generation) into a single canonical `operation_runs` table. This enables a consistent "Monitoring -> Operations" view for all tenant activities. Legacy run tables will be maintained in parallel for now (Parallel Write Transition). `RestoreRun` remains a domain-specific record but will be mirrored into `operation_runs` via an adapter pattern. +Eliminate “run sprawl” by adopting a single tenant-scoped canonical run record (`operation_runs`) for long-running and +operationally relevant actions across the product, surfaced consistently in Monitoring → Operations (list + detail). + +Key behaviors: +- Start surfaces are enqueue-only: authorize → create/reuse canonical run (dedupe) → dispatch → confirm + “View run”. +- Legacy per-module run tables remain in parallel where they exist; Monitoring/Operations uses canonical runs. +- Restore remains a domain workflow record, but is mirrored into canonical runs via an adapter row (`restore.execute`) + created from `RestoreRunStatus=previewed` onward (`status=queued`, `outcome=pending` until execution begins). ## Technical Context -**Language/Version**: PHP 8.4 -**Primary Dependencies**: Filament v4, Laravel v12, Livewire v3 -**Storage**: PostgreSQL (`operation_runs` table + JSONB) -**Testing**: Pest v4 (Feature tests for Service, Livewire tests for UI) -**Target Platform**: Linux server (Docker/Dokploy) -**Project Type**: Web Application (Laravel Monolith) -**Performance Goals**: Start operation < 2s. List runs < 200ms. -**Constraints**: Tenant isolation is paramount. No cross-tenant data leakage. -**Scale/Scope**: ~50-100 runs/day per tenant. Retention 90 days. +**Language/Version**: PHP 8.4.15 (Laravel 12) +**Primary Dependencies**: Filament v4, Livewire v3 +**Storage**: PostgreSQL (`operation_runs` + JSONB for summary/failures/context; partial unique index for active-run dedupe) +**Testing**: Pest v4 (PHPUnit v12) +**Target Platform**: Web application (Sail-first locally, Dokploy-first deploy) +**Project Type**: web +**Performance Goals**: Start surfaces confirm within 2 seconds and provide a “View run” link; Monitoring/Operations list is usable with default last-30-days window and filters. +**Constraints**: Tenant isolation is non-negotiable; Monitoring is DB-only at render time; no remote work inline; failures are stable codes + sanitized messages (no secrets/tokens/raw payload dumps). +**Scale/Scope**: Tenant-scoped run history across modules; retention defaults to 90 days. ## Constitution Check *GATE: Must pass before Phase 0 research. Re-check after Phase 1 design.* -- [x] Inventory-first: N/A (this is about tracking operations, not inventory state itself) -- [x] Read/write separation: Monitoring is read-only. Starts are explicit writes. -- [x] Graph contract path: N/A (this feature tracks runs, doesn't call Graph directly) -- [x] Deterministic capabilities: N/A -- [x] Tenant isolation: `operation_runs` has `tenant_id`. Policies ensure scope. -- [x] Automation: Idempotency enforced via DB index. -- [x] Data minimization: No secrets in `failure_summary`. +- Inventory-first: PASS (Monitoring uses persisted run records; does not redefine Inventory semantics). +- Read/write separation: PASS (Monitoring/Operations is view-only; writes remain in their owning UIs with explicit confirmation/audit where applicable). +- Graph contract path: PASS (Monitoring makes no Graph calls; start surfaces MUST NOT perform remote work inline). +- Deterministic capabilities: N/A (no new capability resolver introduced in this feature). +- Tenant isolation: PASS (all run access is tenant-scoped; cross-tenant access is forbidden). +- Run observability: PASS (queued/remote/scheduled work is tracked as canonical runs and links to a single Monitoring hub). +- Automation: PASS (active-run de-duplication via deterministic identity + partial unique index). +- Data minimization: PASS (failure summaries are sanitized and stable; no secrets/tokens/raw payload dumps in persisted failures/notifications). + +**Gate status (pre-Phase 0)**: PASS (no violations). +**Gate status (post-Phase 1)**: PASS (design artifacts present: `research.md`, `data-model.md`, `contracts/`, `quickstart.md`). ## Project Structure ### Documentation (this feature) ```text -specs/054-unify-runs-suitewide/ -├── plan.md # This file -├── research.md # Research findings -├── data-model.md # Database schema -├── quickstart.md # Dev guide -├── contracts/ # Service interfaces & routes -└── tasks.md # Task breakdown +/Users/ahmeddarrazi/Documents/projects/TenantAtlas/specs/054-unify-runs-suitewide/ +├── plan.md # This file (/speckit.plan command output) +├── spec.md # Feature specification (input) +├── checklists/ +│ └── requirements.md # Spec quality checklist +├── research.md # Phase 0 output +├── data-model.md # Phase 1 output +├── quickstart.md # Phase 1 output +├── contracts/ # Phase 1 output +└── tasks.md # Phase 2 output (/speckit.tasks command - NOT created by /speckit.plan) ``` ### Source Code (repository root) ```text app/ -├── Models/ -│ └── OperationRun.php -├── Services/ -│ └── OperationRunService.php -├── Livewire/ -│ └── Monitoring/ -│ ├── OperationsList.php -│ └── OperationsDetail.php +├── Filament/ +│ ├── Pages/ +│ └── Resources/ ├── Jobs/ │ └── Middleware/ -│ └── TrackOperationRun.php -└── Listeners/ - └── SyncRestoreRunToOperation.php +├── Listeners/ +├── Models/ +├── Notifications/ +├── Observers/ +├── Policies/ +├── Services/ +└── Support/ -database/migrations/ -└── YYYY_MM_DD_create_operation_runs_table.php +database/ +└── migrations/ + +routes/ +└── console.php + +tests/ +├── Feature/ +└── Unit/ ``` -**Structure Decision**: Standard Laravel Service/Model/Livewire pattern. +**Structure Decision**: Laravel web application. Implement canonical runs via Eloquent (`OperationRun`) + a small service layer for idempotent creation and lifecycle updates, instrument background jobs via middleware, and surface runs in Filament Monitoring/Operations (tenant-scoped, view-only). ## Complexity Tracking | Violation | Why Needed | Simpler Alternative Rejected Because | |-----------|------------|-------------------------------------| -| None | | | \ No newline at end of file +| None | | | diff --git a/specs/054-unify-runs-suitewide/quickstart.md b/specs/054-unify-runs-suitewide/quickstart.md index 9a99384..eaf8e35 100644 --- a/specs/054-unify-runs-suitewide/quickstart.md +++ b/specs/054-unify-runs-suitewide/quickstart.md @@ -1,7 +1,7 @@ # Quickstart: Adding a New Operation ## 1. Register Run Type -Add your new type constant to `App\Enums\OperationRunType` (if using Enums) or just use the string convention `resource.action`. +Add your new type constant to `App\Support\OperationRunType` (if using Enums) or just use the string convention `resource.action`. ## 2. Implement Idempotency Inputs Define what makes a run "unique" for your feature. @@ -16,34 +16,36 @@ ## 3. Use `OperationRunService` // 2. Dispatch Job (if new) if ($run->wasRecentlyCreated) { - MyJob::dispatch($run, $inputs); + $service->dispatchOrFail($run, function () use ($run, $inputs): void { + MyJob::dispatch($run, $inputs); + }); } // 3. Return View Link -return redirect()->route('tenant.monitoring.operations.show', [$tenant, $run]); +return redirect(\App\Support\OperationRunLinks::viewUrl($tenant, $run)); ``` ## 4. Instrument Job In your Job: ```php -public function handle() +public function handle(\App\Services\OperationRunService $service) { - // Update to Running - $this->run->updateStatus(status: 'running'); - try { // ... do work ... // Success - $this->run->updateStatus( - status: 'completed', - outcome: 'succeeded', - summary: ['processed' => 100] - ); + $service->updateRun($this->run, status: 'completed', outcome: 'succeeded', summaryCounts: [ + 'processed' => 100, + ]); } catch (\Throwable $e) { // Failure - $this->run->fail($e); + $service->failRun($this->run, $e); } } ``` + +## Notes: Monitoring & Run-Standard (Graph safety) + +- **RBAC Wizard** (`TenantResource`): group search is **delegated-Graph-based** and the picker is **disabled without a delegated token**. +- **Restore Wizard** (`RestoreRunResource`): group mapping stays **DB-only** (Directory Cache / `entra_groups`) during the mapping phase — **no Graph calls** there; fallback helper text is always visible. diff --git a/specs/054-unify-runs-suitewide/research.md b/specs/054-unify-runs-suitewide/research.md index 679092c..0a50c51 100644 --- a/specs/054-unify-runs-suitewide/research.md +++ b/specs/054-unify-runs-suitewide/research.md @@ -4,9 +4,11 @@ ## 1. Technical Context & Unknowns **Unknowns Resolved**: - **Transition Strategy**: Parallel write. We will maintain existing legacy tables (e.g., `inventory_sync_runs`, `restore_runs`) for now but strictly use `operation_runs` for the Monitoring UI. -- **Restore Adapter**: `RestoreRun` remains the domain source of truth. An `OperationRun` record will be created as a "shadow" or "adapter" record. This requires hooking into `RestoreRun` lifecycle events or the service layer to keep them in sync. +- **Restore Adapter**: `RestoreRun` remains the domain source of truth. An `OperationRun` adapter row will be created once a restore run reaches `previewed` (and later statuses), and will be kept in sync via `RestoreRun` lifecycle events or service-layer wrapping. - **Run Logic Location**: Existing jobs like `RunInventorySyncJob` will be updated to manage the `OperationRun` state. - **Concurrency**: Enforced by partial unique index on `(tenant_id, run_identity_hash)` where status is active (`queued`, `running`). +- **Dispatch Failure Semantics**: If queue dispatch fails, the system will immediately complete the run as `failed` (e.g., `queue.dispatch_failed`) and show a clear UI message (never leaving misleading queued runs). +- **Notifications on Dedupe**: Only the original initiator (`operation_runs.user_id`) receives queued/terminal notifications; reusers of an active run do not get additional notifications. ## 2. Technology Choices @@ -22,16 +24,24 @@ ## 3. Implementation Patterns ### Canonical Run Lifecycle 1. **Start Request**: - Compute `run_identity_hash` from inputs. - - Attempt `INSERT` into `operation_runs` (ignore conflict if active). - - If active run exists, return it (Idempotency). - - If new, dispatch Job. + - Attempt `INSERT` into `operation_runs` (idempotent; enforced by partial unique index for active runs). + - If an active run exists, return it (Idempotency). + - If new, dispatch the background Job. + - If dispatch fails, immediately mark the run `status=completed`, `outcome=failed` with a safe failure code such as `queue.dispatch_failed`. 2. **Job Execution**: - Update status to `running`. - Perform work. - - Update status to `succeeded`/`failed`. + - Update status to `completed` and set terminal outcome (`succeeded` / `partially_succeeded` / `failed` / `cancelled`). 3. **Restore Adapter**: - - When `RestoreRun` is created, create `OperationRun` (queued/running). - - When `RestoreRun` updates (status change), update `OperationRun`. + - Create the adapter row only once `RestoreRunStatus=previewed` (or later) is reached. + - Map `RestoreRunStatus=previewed` to `OperationRun.status=queued` and `OperationRun.outcome=pending`. + - Keep the adapter updated as the restore progresses: + - `queued` → `status=queued`, `outcome=pending` + - `running` → `status=running`, `outcome=pending` + - `completed` → `status=completed`, `outcome=succeeded` + - `partial` → `status=completed`, `outcome=partially_succeeded` + - `failed` → `status=completed`, `outcome=failed` + - `cancelled` → `status=completed`, `outcome=cancelled` ### Data Model ```sql @@ -63,3 +73,5 @@ ## 4. Risks & Mitigations - **Mitigation**: Use model observers or service-layer wrapping to ensure atomic-like updates, or accept slight eventual consistency (Monitoring might lag ms behind Restore UI). - **Risk**: Legacy runs not appearing. - **Mitigation**: We are NOT backfilling legacy runs. Only new runs after deployment will appear in the new Monitoring UI. This is acceptable for "Phase 1". +- **Risk**: Confusion about `queued` for restore `previewed`. + - **Mitigation**: Document that `restore.execute` appears from `previewed` onward and uses `queued/pending` until execution begins; Monitoring remains view-only and links to the restore domain detail. diff --git a/specs/054-unify-runs-suitewide/spec.md b/specs/054-unify-runs-suitewide/spec.md index b54f7a3..c97330f 100644 --- a/specs/054-unify-runs-suitewide/spec.md +++ b/specs/054-unify-runs-suitewide/spec.md @@ -12,9 +12,19 @@ ### Session 2026-01-16 - Q: Welche Default-Retention soll 054 für canonical Operation Runs festlegen? → A: 90 days - Q: Transition-Strategie in 054: schreiben wir canonical Runs parallel zu Legacy-Run-Tabellen, oder ersetzen wir sofort? → A: Parallel write (canonical + legacy) - Q: For `restore.execute`, the spec mentions it acts as an "adapter entry" linking to the restore domain record. How should this be implemented? → A: Physical Row (Create a physical row in `operation_runs` that points to the restore record). -- Q: How should concurrency and deduplication (FR-009) be enforced at the database level? → A: Partial Unique Index (unique constraint on `tenant_id, run_identity_hash` where outcome is `queued` or `running`). +- Q: How should concurrency and deduplication (FR-009) be enforced at the database level? → A: Partial Unique Index (unique constraint on `tenant_id, run_identity_hash` where status is `queued` or `running`). - Q: How should the `initiator` be modeled to support both users and system processes (FR-001)? → A: Nullable FK + Name Snapshot (`user_id` nullable FK + required `initiator_name` string). +### Session 2026-01-17 + +- Q: Sollen `backup_schedule.run_now` und `backup_schedule.retry` in 054 zur Phase-1-Adoption (must be implemented) gehören? → A: Yes — both are Phase 1 in 054 (OperationRun producers + worker tracking). +- Q: Wenn Queue-Dispatch fehlschlägt (Background Processing unavailable), sollen wir trotzdem einen `OperationRun` anlegen und ihn sofort als fehlgeschlagen abschließen? → A: Yes — create an `OperationRun` and immediately complete it as `failed` (e.g., failure code `queue.dispatch_failed`); show a clear error and MAY include a “View run” link. +- Q: Wenn ein Start deduped wird (Run wird wiederverwendet), wer soll die In‑App Notifications (“queued” + terminal outcome) bekommen? → A: Only the original initiator (`operation_runs.user_id`); no additional notifications are sent to the second starter on reuse. +- Q: Für `restore.execute`: In welchen `RestoreRunStatus`-Phasen soll überhaupt ein `OperationRun`-Adapter‑Row erzeugt/angezeigt werden? → A: From `previewed` onwards (previewed + execution statuses); no adapter row for `draft`/`scoped`/`checked`. +- Q: Wenn der `restore.execute` Adapter bereits ab `RestoreRunStatus=previewed` sichtbar ist: welchen `OperationRun`-State sollen wir für diese Phase setzen? → A: `status=queued`, `outcome=pending` (until `running`, then `completed` + terminal outcome). +- Q: RBAC Wizard (`TenantResource`) – wie funktioniert Group Search? → A: Group search is delegated-Graph-based and the picker MUST be disabled without delegated auth. +- Q: Restore Wizard (`RestoreRunResource`) – Group Mapping Phase: Graph oder DB-only? → A: DB-only via Directory Cache (`entra_groups`), no Graph calls during mapping; helper text is always shown (fallback included). + ## User Scenarios & Testing *(mandatory)* ### User Story 1 - See Every Supported Operation in Monitoring (Priority: P1) @@ -27,10 +37,10 @@ ### User Story 1 - See Every Supported Operation in Monitoring (Priority: P1) **Acceptance Scenarios**: -1. **Given** I am signed into tenant A, **When** I open Monitoring → Operations, **Then** I see only tenant A runs and can filter by run type, outcome bucket, time range, and initiator. +1. **Given** I am signed into tenant A, **When** I open Monitoring → Operations, **Then** I see only tenant A runs and can filter by run type, run state (queued/running/terminal outcome), time range, and initiator. 2. **Given** multiple run types exist, **When** I filter to `inventory.sync`, **Then** only inventory sync runs are shown. -3. **Given** a run exists, **When** I open its detail view, **Then** I can see initiator, run type, outcome bucket, timestamps, summary counts (if applicable), sanitized failures (if any), and links to relevant feature context/results. -4. **Given** restore execution exists, **When** I open Monitoring → Operations, **Then** I can see a `restore.execute` entry that links to the existing restore record (restore history remains owned by the restore domain record). +3. **Given** a run exists, **When** I open its detail view, **Then** I can see initiator, run type, run state (queued/running/terminal outcome), timestamps, summary counts (if applicable), sanitized failures (if any), and links to relevant feature context/results. +4. **Given** a restore run has reached `previewed` or later, **When** I open Monitoring → Operations, **Then** I can see a `restore.execute` entry that links to the existing restore record (restore history remains owned by the restore domain record). 5. **Given** I am a `Readonly` user in tenant A, **When** I view Monitoring → Operations, **Then** I can view runs and details but I do not see any start/rerun/cancel/delete controls. 6. **Given** I attempt to access a run from another tenant (direct link or list), **When** I request it, **Then** access is denied and no run details are disclosed. @@ -50,6 +60,7 @@ ### User Story 2 - Start Operations Without Blocking (Priority: P2) 2. **Given** I am a `Readonly` user in tenant A, **When** I attempt to start any Phase 1 operation, **Then** the system denies the request and does not create a new run. 3. **Given** the run reaches a terminal outcome, **When** that occurs, **Then** the initiating user receives an in-app notification including a short summary and a “View run” link. 4. **Given** background processing is unavailable, **When** I attempt to start an operation, **Then** I receive a clear message and the system MUST NOT claim it was queued. + - If an `OperationRun` record was created during the attempt, it MUST be completed immediately with outcome `failed` (never left `queued`) and MAY be linked via “View run”. --- @@ -68,7 +79,7 @@ ### User Story 3 - Duplicate Starts Reuse the Same Active Run (Priority: P3) ### Edge Cases -- Background execution unavailable: start fails fast with a clear message; the system MUST NOT create misleading “queued” runs. +- Background execution unavailable: start fails fast with a clear message; if an `OperationRun` record was created, it MUST be immediately completed as `failed` (e.g., `queue.dispatch_failed`) and MUST NOT be left `queued`. - Partial processing: at least one success and at least one failure yields “partially succeeded”, with per-item failures when applicable. - Large run history: Monitoring remains usable with filters and defaults (recent runs, last 30 days). - Permissions revoked mid-run: the run continues; visibility is evaluated at time of access. @@ -87,10 +98,14 @@ ### Scope & Assumptions - `directory_groups.sync` (Directory → Groups “Sync groups”) - `drift.generate` (Drift “Generate drift now” / auto-on-open when eligible) - `backup_set.add_policies` (Backup Sets “Add selected” / “Add policies”) +- `backup_schedule.run_now` (Backup Schedules “Run now”) +- `backup_schedule.retry` (Backup Schedules “Retry”) **Restore visibility (adapter only):** - `restore.execute` appears as a canonical run entry that links to an existing restore domain record. +- The adapter row MUST be created/visible only once a restore run reaches `previewed` (or later) and MUST NOT be created for `draft`, `scoped`, or `checked`. +- When the restore run is `previewed`, the adapter `OperationRun` MUST use `status=queued` and `outcome=pending`. - Restore execution history remains owned by the restore domain record (not replaced in Phase 1). **Out of scope for 054 (explicit):** @@ -100,6 +115,7 @@ ### Scope & Assumptions - Cancel/rerun/delete controls inside Monitoring hub (hub stays view-only) - Replacing restore domain records with canonical runs - A full settings UI for retention/notifications/etc. +- Implementing or validating `AuditLog` behavior for audit-only actions (FR-019) beyond actions explicitly changed by 054 **Assumptions (defaults to remove ambiguity in Phase 1):** @@ -107,35 +123,104 @@ ### Scope & Assumptions - System-initiated runs (if any) do not notify users by default in Phase 1. - Transition strategy: write canonical runs in parallel with any existing legacy per-module run tables (where they exist); Monitoring uses canonical runs as the source of truth immediately. +**Run vs Audit-only Adoption Matrix (Phase 1):** + +| Feature Area | Action | Tracking | run_type / audit action | +|-------------|--------|----------|--------------------------| +| Policies | Sync now | OperationRun | `policy.sync` | +| Policies | Ignore policy | Audit-only | `policy.ignore` | +| Policies | Export to backup | OperationRun (queued) | `policy.export_backup` | +| Policy Versions | Capture snapshot | OperationRun | `policy.capture_snapshot` | +| Policy Versions | Prune versions | Audit-only | `policy_versions.prune` | +| Policy Versions | Archive versions | Audit-only | `policy_versions.archive` | +| Inventory | Sync now | OperationRun | `inventory.sync` | +| Directory Groups | Sync groups | OperationRun | `directory_groups.sync` | +| Drift | Generate drift | OperationRun | `drift.generate` | +| Backup Sets | Add policies | OperationRun | `backup_set.add_policies` | +| Backup Sets | Archive | Audit-only (DB-only) | `backup_set.archive` | +| Backup Sets | Restore (bulk) | OperationRun | `backup_set.restore` | +| Backup Sets | Force delete | Audit-only (admin-only) | `backup_set.force_delete` | +| Backup Schedules | Run now | OperationRun | `backup_schedule.run_now` | +| Backup Schedules | Retry | OperationRun | `backup_schedule.retry` | +| Backup Schedules | Edit | Audit-only | `backup_schedule.edit` | +| Backup Schedules | Delete | Audit-only | `backup_schedule.delete` | +| Tenants | Sync tenant | OperationRun | `tenant.sync` | +| Tenants | Admin consent | Audit-only | `tenant.admin_consent` | +| Tenants | Verify configuration | Audit-only | `tenant.verify_config` | +| Tenants | Setup Intune RBAC | Audit-only | `tenant.setup_rbac` | +| Tenants | Deactivate | Audit-only | `tenant.deactivate` | +| Restore | Execute restore | OperationRun (adapter) | `restore.execute` (context → `restore_run_id`) | + +**Rule**: If an action is queued/background, long-running, or requires remote/external calls (e.g., Microsoft Graph), +it MUST be tracked as an OperationRun. Only fast DB-only changes MAY be Audit-only. + ### Functional Requirements -- **FR-001 Canonical Operation Run**: System MUST represent each supported operation execution as a canonical, tenant-scoped operation run record that captures initiator (nullable `user_id` FK + `initiator_name` string), run type, lifecycle status/timestamps, outcome bucket, summary counts (when applicable), safe failure summaries, an idempotency identity for dedupe, and a safe context payload referencing “what this run was about”. +- **FR-001 Canonical Operation Run**: System MUST represent each supported operation execution as a canonical, tenant-scoped operation run record that captures initiator (nullable `user_id` FK + `initiator_name` string), run type, lifecycle status/timestamps, terminal outcome (pending while active), summary counts (when applicable), safe failure summaries, an idempotency identity for dedupe, and a safe context payload referencing “what this run was about”. + - **Status semantics**: `status` represents lifecycle stage (`queued` → `running` → `completed`). + - **Outcome semantics (stored tokens)**: `outcome` stores machine tokens: `pending` while active, otherwise `succeeded` / `partially_succeeded` / `failed`. + - **UI labels**: Monitoring displays human labels derived from stored tokens (e.g., `partially_succeeded` → “Partially succeeded”). + - **Reserved**: `cancelled` is reserved for future use and MUST NOT be produced by 054 (Monitoring hub has no cancel controls). + - **Context safety**: `context` MUST be sanitized and MUST include only safe references (e.g., stable IDs, selection scope keys, correlation IDs). It MUST NOT include secrets/tokens/credentials, personal data, or full external payload dumps. - **FR-002 Run taxonomy**: Run type MUST be stable and follow `"."`. -- **FR-003 Phase 1 run types**: Phase 1 run types MUST include `inventory.sync`, `policy.sync`, `directory_groups.sync`, `drift.generate`, `backup_set.add_policies`, plus `restore.execute` implemented as a physical `operation_runs` record (adapter) pointing to the domain entity. -- **FR-004 Monitoring lists all canonical runs**: Monitoring → Operations MUST list canonical runs for the active tenant with filters for run type, outcome bucket, time range, and initiator; default sort is most recent first; default time window is last 30 days. -- **FR-005 Run detail**: Run detail MUST show initiator, run type, outcome bucket, timestamps (created/started/finished), summary counts (when applicable), sanitized failures (including per-item failures when applicable), and contextual links to owning feature surfaces/results. +- **FR-003 Phase 1 run types**: Phase 1 run types MUST include `inventory.sync`, `policy.sync`, `directory_groups.sync`, `drift.generate`, `backup_set.add_policies`, `backup_schedule.run_now`, `backup_schedule.retry`, plus `restore.execute` implemented as a physical `operation_runs` record (adapter) pointing to the domain entity. +- **FR-004 Monitoring lists all canonical runs**: Monitoring → Operations MUST list canonical runs for the active tenant with filters for run type, run state (queued/running/terminal outcome), time range, and initiator; default sort is most recent first; default time window is last 30 days. +- **FR-005 Run detail**: Run detail MUST show initiator, run type, run state (queued/running/terminal outcome), timestamps (created/started/finished), summary counts (when applicable), sanitized failures (including per-item failures when applicable), and contextual links to owning feature surfaces/results. - **FR-006 View-only hub**: Monitoring hub MUST be view-only (no start/rerun/cancel/delete controls) and MUST link back to owning feature surfaces. - **FR-007 Start surfaces always enqueue**: Every Phase 1 start surface MUST authorize start, create/reuse a canonical run (dedupe), dispatch background execution, and return immediately with confirmation + “View run”. - **FR-008 No remote work in interactive request**: Start surfaces MUST NOT perform remote work inline; long-running work happens in background execution. -- **FR-009 Deterministic idempotency**: For each run type, the system MUST define a deterministic identity for “identical run” based on tenant + effective inputs; initiator MUST NOT be part of identity. **Enforcement**: Uniqueness MUST be enforced via a partial unique index on `(tenant_id, run_identity_hash)` where outcome is `queued` or `running`. +- **FR-009 Deterministic idempotency**: For each run type, the system MUST define a deterministic identity for “identical run” based on tenant + effective inputs; initiator MUST NOT be part of identity. **Enforcement**: Uniqueness MUST be enforced via a partial unique index on `(tenant_id, run_identity_hash)` where status is `queued` or `running`. - **FR-010 Phase 1 identity rules**: Identity rules MUST be defined at least as follows: - `inventory.sync`: tenant + selection scope - `policy.sync`: tenant + effective policy scope - `directory_groups.sync`: tenant + selection (Phase 1 default: “all groups”) - `backup_set.add_policies`: tenant + backup set + selected policies + option flags (if exposed) + - `backup_schedule.run_now`: tenant + backup schedule id + - `backup_schedule.retry`: tenant + backup schedule id - `drift.generate`: tenant + scope key + baseline/current comparison inputs -- **FR-011 Outcome buckets**: Monitoring MUST present consistent outcome buckets: `queued`, `running`, `succeeded`, `partially succeeded`, `failed`. -- **FR-012 Partial vs failed**: “Partially succeeded” means at least one success and at least one failure; “Failed” means zero successes or cannot proceed. +- **FR-011 Run state presentation**: Monitoring MUST present a consistent run state using a single display bucket derived from lifecycle status and terminal outcome: + - If status is `queued` or `running`, display that status. + - If status is `completed`, display the terminal outcome derived from the stored token (`succeeded`, `partially_succeeded`, or `failed`) using the UI label mapping. +- **FR-012 Partial vs failed (terminal outcomes)**: “Partially succeeded” (`partially_succeeded`) means at least one success and at least one failure; “Failed” (`failed`) means zero successes or cannot proceed. - **FR-013 Failure details are safe + useful**: Failures MUST be persisted and displayed as stable reason codes and short sanitized messages; failures MUST NOT include secrets/tokens/credentials/PII or full external payload dumps. + - **Reason codes** MUST be stable, machine-readable identifiers (lowercase, dot-separated), e.g. `graph.throttled`, `auth.forbidden`, `validation.invalid_input`, `unexpected.exception`. + - **Messages** MUST be short (≤ 200 characters), sanitized, and written for operators (no secrets/tokens/credentials/PII; no raw external payloads). If needed, messages MAY include a non-sensitive correlation identifier. - **FR-014 Related links**: Run detail MUST include contextual links where applicable (e.g., drift findings, backup set, inventory results, directory groups, restore detail for `restore.execute`). - **FR-015 Notifications**: System MUST emit in-app notifications for “queued” (after start) and terminal outcomes for Phase 1 runs; notifications MUST include a short summary and a “View run” link; recipients are the initiating user only. + - If a start request reuses an existing active run (dedupe), the run initiator (as stored on the `OperationRun`) remains the sole notification recipient; the second starter receives no additional notifications. - **FR-016 Tenant isolation**: All run list/detail access MUST be tenant-scoped; cross-tenant access MUST be denied without disclosing run details. - **FR-017 No render-time remote calls**: Monitoring pages MUST be render-safe and MUST NOT depend on external service calls during render. - **FR-018 Roles & permissions**: Roles `Owner`, `Manager`, `Operator`, and `Readonly` MUST be able to view runs; only `Owner`, `Manager`, `Operator` may start operations; `Readonly` is strictly view-only. +- **FR-019 Audit-only actions (no OperationRun)**: Actions that are DB-only and complete within ≤2 seconds under normal + conditions MAY be executed without an OperationRun, as long as they do not start long-running background execution and + do not require any remote/external calls. + - **054 scope note**: 054 does not implement or modify audit-only actions. If any audit-only action is touched as part + of implementing 054 in the future, it MUST comply with this requirement and MUST be covered by tests. + If such an action is security-relevant or changes operational behavior (e.g., “Ignore policy”, “Deactivate tenant”, + “Admin consent”, “Prune versions”, “Force delete”), it MUST write exactly one tenant-scoped AuditLog entry with, at minimum: + - `tenant_id` + - `actor_user_id` + - `action` (stable action identifier, e.g., `policy.ignore`) + - `target_type`, `target_id` + - `before` / `after` (sanitized JSON) **or** `diff` (sanitized JSON) + - `created_at` + **Trigger guidance (to make classification reviewable)**: + - “Security-relevant” includes actions that grant/revoke access, change authorization posture, change admin consent, or otherwise modify who/what can read/write tenant data. + - “Operational behavior change” includes actions that change what the system will do in future runs (e.g., ignore/exclude resources, enable/disable schedules, retention/prune/archive actions, force deletes). + - If unclear whether an Audit-only action is security/ops-relevant, the default is to treat it as such and write an AuditLog entry. + **Sanitization (AuditLog before/after/diff)**: + - AuditLog payloads MUST include only the minimum fields needed to understand the change. + - AuditLog payloads MUST NOT include secrets/tokens/credentials, personal data, or full external payload dumps. + - If a field is sensitive, it MUST be omitted or replaced with a non-sensitive placeholder (e.g., `"[REDACTED]"`). + Monitoring/Operations remains reserved for OperationRun-tracked long-running/queued operations. + **Acceptance checks (testable)**: + - Audit-only action creates no OperationRun. + - Audit-only action creates exactly one AuditLog event containing the required fields. + - Audit-only action is tenant-scoped; cross-tenant access is forbidden and MUST NOT create AuditLog entries. ### Key Entities *(include if feature involves data)* -- **Canonical Operation Run**: A tenant-scoped record representing the lifecycle of a long-running operation, including run type, initiator (nullable `user_id` FK + `initiator_name` string), lifecycle state/timestamps, outcome bucket, summary counts, safe failure summaries, idempotency identity (uniqueness enforced by DB index on active runs), and safe context references. +- **Canonical Operation Run**: A tenant-scoped record representing the lifecycle of a long-running operation, including run type, initiator (nullable `user_id` FK + `initiator_name` string), lifecycle state/timestamps, terminal outcome, summary counts, safe failure summaries, idempotency identity (uniqueness enforced by DB index on active runs), and safe context references. - **Restore domain record (exception)**: Restore remains a domain workflow record with richer semantics and history. Monitoring shows restore activity through a physical `operation_runs` row (adapter) that links back to the restore record, without replacing it. ## Success Criteria *(mandatory)* @@ -145,4 +230,5 @@ ### Measurable Outcomes - **SC-001**: Operators can answer “what ran, when, and did it succeed?” for any Phase 1 run in under 1 minute using Monitoring → Operations. - **SC-002**: Starting a Phase 1 operation returns confirmation + “View run” link within 2 seconds under normal conditions. - **SC-003**: Duplicate starts reuse the same active run in at least 99% of attempts under normal conditions. +- **SC-003 Measurement scope (definition)**: An “attempt” counts when a start request is made for an operation with identical effective inputs while an identical run is already `queued` or `running`. The success condition is that the system reuses the existing active run reference rather than creating a second active run. “Normal conditions” exclude infrastructure outages (e.g., database unavailable) that prevent either run creation or dedupe evaluation. - **SC-004**: No secrets/tokens/credentials/PII appear in persisted failures or notifications (verified by tests). diff --git a/specs/054-unify-runs-suitewide/tasks.md b/specs/054-unify-runs-suitewide/tasks.md index fd8b909..41f51b7 100644 --- a/specs/054-unify-runs-suitewide/tasks.md +++ b/specs/054-unify-runs-suitewide/tasks.md @@ -1,64 +1,209 @@ -# Tasks: Unified Operations Runs Suitewide +--- +description: "Task list for feature implementation" +--- -**Feature**: `054-unify-runs-suitewide` -**Spec**: `specs/054-unify-runs-suitewide/spec.md` +# Tasks: Unified Operations Runs Suitewide (054) -## Phase 1: Foundation (DB & Service) +**Input**: Design documents from `/Users/ahmeddarrazi/Documents/projects/TenantAtlas/specs/054-unify-runs-suitewide/` +**Prerequisites**: `specs/054-unify-runs-suitewide/plan.md`, `specs/054-unify-runs-suitewide/spec.md`, `specs/054-unify-runs-suitewide/data-model.md`, `specs/054-unify-runs-suitewide/research.md`, `specs/054-unify-runs-suitewide/quickstart.md`, `specs/054-unify-runs-suitewide/contracts/` -- [ ] **Migration**: Create `operation_runs` table with partial unique index on `(tenant_id, run_identity_hash)` where status in `queued, running`. -- [ ] **Model**: Create `OperationRun` model with casts (JSONB for summaries/context), relationship to `Tenant` and `User`. -- [ ] **Service**: Implement `OperationRunService::ensureRun()` (idempotent creation) and `updateRun()` methods. -- [ ] **Test**: Feature test for `ensureRun` verifying idempotency (same hash = same run) and concurrency safety (simulated). -- [ ] **Test**: Feature test for `updateRun` verifying status transitions and history logging (if any). -- [ ] **Job Middleware**: Create `TrackOperationRun` middleware to automatically handle job success/failure updates for jobs using this system. -- [ ] **Retention**: Create a daily scheduled job to prune `operation_runs` older than 90 days. +**Tests**: Required (Pest) for runtime behavior changes. +**Operations**: Long-running/queued/remote/scheduled actions MUST create/reuse a canonical `OperationRun` and link to Monitoring → Operations. Monitoring pages MUST be DB-only at render time (no remote calls). -## Phase 2: Monitoring UI (Read-Only) +## Format: `[ID] [P?] [Story?] Description` -- [ ] **Page**: Create Filament Page `Monitoring/Operations` (List) strictly scoped to current tenant. -- [ ] **Table**: Implement `OperationRun` table with columns: Status (Badge), Operation Type, Initiator, Started At, Duration, Outcome. -- [ ] **Filters**: Add table filters for `Type`, `Outcome`, `Date Range`, `Initiator`. -- [ ] **Detail View**: Create "View Run" modal or separate page showing: - - Summary counts (Success/Fail/Total) - - Failure list (Sanitized codes/messages) - - Context JSON (Debug info) - - Timeline (Created/Started/Finished) -- [ ] **Test**: Livewire test verifying `Readonly` users can see table but no actions. -- [ ] **Test**: Verify cross-tenant access is blocked. +- **[P]**: Can run in parallel (different files, no blocking dependencies) +- **[Story]**: User story label (e.g., `[US1]`, `[US2]`, `[US3]`) — REQUIRED for story phases only +- Each task includes at least one concrete file path -## Phase 3: Producer Migration (Parallel Write) +## Path Conventions (Laravel Monolith) -### Inventory Sync (`inventory.sync`) -- [ ] **Refactor**: Update `RunInventorySyncJob` dispatch logic to call `OperationRunService::ensureRun()` first. -- [ ] **Refactor**: Update Job to use `TrackOperationRun` middleware (or manual updates) to sync status to `operation_runs`. -- [ ] **Verify**: Ensure legacy `inventory_sync_runs` is still written to (if legacy UI depends on it) OR confirm legacy UI is replaced. *Decision: Parallel write as per spec.* +- Source: `app/`, `routes/`, `resources/`, `config/`, `database/` +- Tests: `tests/Feature/`, `tests/Unit/` -### Policy Sync (`policy.sync`) -- [ ] **Refactor**: Update Policy Sync start logic to use `OperationRunService`. -- [ ] **Refactor**: Instrument Policy Sync job to update `operation_runs`. +--- -### Directory Groups Sync (`directory_groups.sync`) -- [ ] **Refactor**: Update Group Sync start logic to use `OperationRunService`. -- [ ] **Refactor**: Instrument Group Sync job to update `operation_runs`. +## Phase 1: Setup (Spec + Contract Alignment) -### Drift Generation (`drift.generate`) -- [ ] **Refactor**: Update Drift Generation start logic to use `OperationRunService`. -- [ ] **Refactor**: Instrument Drift job to update `operation_runs`. +**Purpose**: Resolve ambiguity before implementation starts -### Backup Set (`backup_set.add_policies`) -- [ ] **Refactor**: Update "Add Policies" action to use `OperationRunService`. +- [x] T001 Validate Phase 1 run type set is consistent (adoption set + FR-003 + identity rules) in `specs/054-unify-runs-suitewide/spec.md` +- [x] T002 Validate Monitoring Operations routes match Filament surface + OpenAPI contract in `specs/054-unify-runs-suitewide/contracts/routes.md` and `specs/054-unify-runs-suitewide/contracts/admin-pages.openapi.yaml` +- [x] T003 Validate `OperationRunService` contract + quickstart usage examples align with intended API in `specs/054-unify-runs-suitewide/contracts/service_interface.md` and `specs/054-unify-runs-suitewide/quickstart.md` +- [x] T004 Confirm FR-019 (Audit-only) is out-of-scope for 054 unless an audit-only action is touched; if touched, add AuditLog implementation + tests per `specs/054-unify-runs-suitewide/spec.md` in `specs/054-unify-runs-suitewide/tasks.md` -## Phase 4: Restore Adapter +--- -- [ ] **Listener**: Create `SyncRestoreRunToOperation` listener observing `RestoreRun` events (`created`, `updated`). -- [ ] **Logic**: Map `RestoreRun` status/outcomes to `OperationRun` schema. - - `RestoreRun` created -> `OperationRun` created (queued/running). - - `RestoreRun` updated -> `OperationRun` updated. -- [ ] **Context**: Store `{"restore_run_id": }` in `OperationRun.context`. -- [ ] **Test**: Verify creating a `RestoreRun` automatically spawns a shadow `OperationRun`. +## Phase 2: Foundational (Canonical Run Primitive) -## Phase 5: Notifications & Polish +**Purpose**: Core infrastructure that MUST be complete before ANY user story can be implemented + +- [x] T005 Create `operation_runs` migration (columns + indexes + partial unique index) in `database/migrations/*_create_operation_runs_table.php` +- [x] T006 Create `OperationRun` Eloquent model (casts + relationships + helpers) in `app/Models/OperationRun.php` +- [x] T007 [P] Add `OperationRunStatus` enum in `app/Support/OperationRunStatus.php` +- [x] T008 [P] Add `OperationRunOutcome` enum (stored tokens vs UI labels; `cancelled` reserved) in `app/Support/OperationRunOutcome.php` +- [x] T009 [P] Add `OperationRunType` enum (Phase 1 run types) in `app/Support/OperationRunType.php` +- [x] T010 [P] Add `OperationRunFactory` for tests in `database/factories/OperationRunFactory.php` +- [x] T011 Implement idempotent create/reuse + lifecycle updates + failure sanitization in `app/Services/OperationRunService.php` (depends on T005–T010) +- [x] T012 Centralize “View run” + deep links in `app/Support/OperationRunLinks.php` (depends on T011) +- [x] T013 [P] Implement tenant-scoped authorization policy in `app/Policies/OperationRunPolicy.php` +- [x] T014 Register `OperationRun` policy with Gate in `app/Providers/AppServiceProvider.php` +- [x] T015 Implement job middleware lifecycle tracking in `app/Jobs/Middleware/TrackOperationRun.php` (depends on T011) +- [x] T016 Implement 90-day retention pruning job in `app/Jobs/PruneOldOperationRunsJob.php` +- [x] T017 Schedule pruning job daily with non-overlapping lock (`withoutOverlapping` or equivalent cache lock) in `routes/console.php` +- [x] T018 Add scheduled pruning non-overlap regression test (if feasible) in `tests/Feature/Scheduling/PruneOldOperationRunsScheduleTest.php` +- [x] T019 Add `OperationRunService` tests (hash stability, idempotency, unique-index race, sanitization) in `tests/Feature/OperationRunServiceTest.php` +- [x] T020 Add `TrackOperationRun` middleware lifecycle tests in `tests/Feature/TrackOperationRunMiddlewareTest.php` + +--- + +## Phase 3: User Story 1 - See Every Supported Operation in Monitoring (Priority: P1) + +**Goal**: Monitoring → Operations shows all canonical runs (tenant-scoped) with list + detail, filters, and safe failures. + +**Independent Test**: Trigger at least one run of each Phase 1 run producer, then verify list/detail in Monitoring render DB-only and are tenant-scoped per `specs/054-unify-runs-suitewide/spec.md`. + +### Tests for User Story 1 + +- [x] T021 [US1] Add Monitoring list/detail authorization + tenant isolation tests in `tests/Feature/MonitoringOperationsTest.php` +- [x] T022 [P] [US1] Add Monitoring DB-only render test (mock Graph client; assert never called) in `tests/Feature/MonitoringOperationsTest.php` +- [x] T023 [P] [US1] Add restore adapter visibility tests (created/visible from `previewed` onward; `previewed` maps to `queued/pending`) in `tests/Feature/RestoreAdapterTest.php` + +### Implementation for User Story 1 + +- [x] T024 [US1] Create Filament Monitoring resource (list + view-only) in `app/Filament/Resources/OperationRunResource.php` +- [x] T025 [US1] Implement list columns + default sort + default last-30-days window in `app/Filament/Resources/OperationRunResource.php` +- [x] T026 [US1] Implement list filters (type, state bucket, time range, initiator) in `app/Filament/Resources/OperationRunResource.php` +- [x] T027 [US1] Implement run detail view (meta, summary_counts, failures, context, links) in `app/Filament/Resources/OperationRunResource/Pages/ViewOperationRun.php` +- [x] T028 [US1] Implement related links for each Phase 1 run type in `app/Support/OperationRunLinks.php` +- [x] T029 [US1] Implement RestoreRun → OperationRun adapter create/update (from `previewed` onward; `previewed` maps to `status=queued`, `outcome=pending`) in `app/Listeners/SyncRestoreRunToOperationRun.php` +- [x] T030 [US1] Wire RestoreRun lifecycle events to adapter in `app/Observers/RestoreRunObserver.php` +- [x] T031 [US1] Register RestoreRun observer in `app/Providers/AppServiceProvider.php` + +--- + +## Phase 4: User Story 2 - Start Operations Without Blocking (Priority: P2) + +**Goal**: Start surfaces are enqueue-only, return immediate confirmation + “View run”, and jobs update canonical run lifecycle. + +**Independent Test**: Start each Phase 1 operation from its owning UI and confirm the request returns quickly, includes “View run”, and the run progresses queued → running → terminal outcome. + +### Tests for User Story 2 + +- [x] T032 [P] [US2] Add Inventory “Sync now” start-surface tests in `tests/Feature/Inventory/InventorySyncStartSurfaceTest.php` +- [x] T033 [P] [US2] Add Policies “Sync now” start-surface tests in `tests/Feature/PolicySyncStartSurfaceTest.php` +- [x] T034 [P] [US2] Add Directory Groups “Sync groups” start-surface tests in `tests/Feature/DirectoryGroups/StartSyncFromGroupsPageTest.php` +- [x] T035 [P] [US2] Add Drift “Generate drift” start-surface tests in `tests/Feature/Drift/DriftGenerationDispatchTest.php` +- [x] T036 [P] [US2] Add Backup Set “Add policies” start-surface tests in `tests/Feature/BackupSets/AddPoliciesStartSurfaceTest.php` +- [x] T037 [P] [US2] Add Backup Schedule “Run now/Retry” start-surface tests in `tests/Feature/BackupScheduling/RunNowRetryActionsTest.php` +- [x] T067 [P] [US2] Add Backup Set “Remove policies” (row + bulk) start-surface tests in `tests/Feature/BackupSets/RemovePoliciesStartSurfaceTest.php` and `tests/Feature/Filament/BackupItemsBulkRemoveTest.php` +- [x] T038 [P] [US2] Add “queued + terminal” notification tests (initiator only; safe content) in `tests/Feature/Notifications/OperationRunNotificationTest.php` + +### Implementation for User Story 2 + +- [x] T039 [P] [US2] Refactor Inventory “Sync now” to ensure run + dispatch + “View run” in `app/Filament/Pages/InventoryLanding.php` +- [x] T040 [P] [US2] Refactor Policies “Sync now” to ensure run + dispatch + “View run” in `app/Filament/Resources/PolicyResource/Pages/ListPolicies.php` +- [x] T041 [P] [US2] Refactor Directory Groups “Sync groups” to ensure run + dispatch + “View run” in `app/Filament/Resources/EntraGroupResource/Pages/ListEntraGroups.php` +- [x] T042 [P] [US2] Refactor Drift “Generate drift” (manual + auto-on-open) to ensure run + dispatch + “View run” in `app/Filament/Pages/DriftLanding.php` +- [x] T043 [P] [US2] Refactor Backup Set “Add policies” Livewire action to ensure run + dispatch + “View run” in `app/Livewire/BackupSetPolicyPickerTable.php` +- [x] T044 [P] [US2] Refactor Backup Schedule “Run now/Retry” actions to ensure run + dispatch + “View run” in `app/Filament/Resources/BackupScheduleResource.php` +- [x] T068 [P] [US2] Refactor Backup Set “Remove policies” actions (row + bulk) to ensure run + dispatch + “View run” in `app/Filament/Resources/BackupSetResource/RelationManagers/BackupItemsRelationManager.php` and `app/Jobs/RemovePoliciesFromBackupSetJob.php` +- [x] T045 [P] [US2] Instrument inventory sync job run lifecycle + summary/failures in `app/Jobs/RunInventorySyncJob.php` +- [x] T046 [P] [US2] Instrument policy sync job run lifecycle + summary/failures in `app/Jobs/SyncPoliciesJob.php` +- [x] T047 [P] [US2] Instrument groups sync job run lifecycle + summary/failures in `app/Jobs/EntraGroupSyncJob.php` +- [x] T048 [P] [US2] Instrument drift generation job run lifecycle + summary/failures in `app/Jobs/GenerateDriftFindingsJob.php` +- [x] T049 [P] [US2] Instrument backup set “add policies” job run lifecycle + summary/failures in `app/Jobs/AddPoliciesToBackupSetJob.php` +- [x] T050 [P] [US2] Instrument backup schedule job run lifecycle + summary/failures in `app/Jobs/RunBackupScheduleJob.php` +- [x] T051 [US2] Implement queued notification (after successful dispatch) in `app/Notifications/OperationRunQueued.php` +- [x] T052 [US2] Implement terminal outcome notification (succeeded/partial/failed) in `app/Notifications/OperationRunCompleted.php` +- [x] T053 [US2] Emit notifications from canonical lifecycle updates (initiator only) in `app/Services/OperationRunService.php` +- [x] T054 [US2] Handle queue dispatch failures (fail fast; no misleading queued runs) in `app/Services/OperationRunService.php` + +--- + +## Phase 5: User Story 3 - Duplicate Starts Reuse the Same Active Run (Priority: P3) + +**Goal**: Duplicate starts reuse the same active run (dedupe), enforced at DB level and validated by tests. + +**Independent Test**: Start the same operation twice with identical effective inputs while the first is queued/running and verify the system reuses the active run. + +### Tests for User Story 3 + +- [x] T055 [US3] Add service-level dedupe + race-collision tests in `tests/Feature/OperationRunServiceTest.php` +- [x] T056 [US3] Add end-to-end “reuse active run” test for at least one producer in `tests/Feature/PolicySyncStartSurfaceTest.php` + +### Implementation for User Story 3 + +- [x] T057 [US3] Normalize identity inputs before hashing (stable JSON; ignore initiator) in `app/Services/OperationRunService.php` +- [x] T058 [P] [US3] Ensure `inventory.sync` identity inputs follow FR-010 in `app/Filament/Pages/InventoryLanding.php` +- [x] T059 [P] [US3] Ensure `policy.sync` identity inputs follow FR-010 in `app/Filament/Resources/PolicyResource/Pages/ListPolicies.php` +- [x] T060 [P] [US3] Ensure `directory_groups.sync` identity inputs follow FR-010 in `app/Filament/Resources/EntraGroupResource/Pages/ListEntraGroups.php` +- [x] T061 [P] [US3] Ensure `drift.generate` identity inputs follow FR-010 in `app/Filament/Pages/DriftLanding.php` +- [x] T062 [P] [US3] Ensure `backup_set.add_policies` identity inputs follow FR-010 in `app/Livewire/BackupSetPolicyPickerTable.php` +- [x] T063 [P] [US3] Ensure `backup_schedule.run_now`/`backup_schedule.retry` identity inputs follow FR-010 in `app/Filament/Resources/BackupScheduleResource.php` + +--- + +## Phase 6: Polish & Cross-Cutting Concerns + +- [x] T064 [P] Run formatter on changed files via `./vendor/bin/pint --dirty` +- [x] T065 Run targeted tests for this feature via `./vendor/bin/sail artisan test tests/Feature/MonitoringOperationsTest.php` +- [x] T066 Run quickstart scenarios and update docs if needed in `specs/054-unify-runs-suitewide/quickstart.md` + +--- + +## Dependencies & Execution Order + +### Phase Dependencies + +- Setup (Phase 1) → blocks Foundational +- Foundational (Phase 2) → blocks US1/US2/US3 +- US1/US2/US3 can proceed after Phase 2 (parallel if staffed, or sequential P1 → P2 → P3) +- Polish (Phase 6) depends on the desired user stories being complete + +### User Story Dependencies (Graph) + +```text +Foundational + ├─ US1 (Monitoring UI) + ├─ US2 (Start surfaces + lifecycle + notifications) + └─ US3 (Dedupe + identity + race handling) +``` + +--- + +## Parallel Execution Examples + +After Phase 2, these can run in parallel (different files/modules): + +### US1 (Monitoring UI) + +- `T023` (restore adapter tests) + `T024` (resource scaffold) + `T029` (restore adapter listener) + +### US2 (Start surfaces + lifecycle + notifications) + +- Tests: `T032`–`T038` +- Producers/start surfaces: `T039`–`T044` +- Workers/job instrumentation: `T045`–`T050` +- Notification classes: `T051` + `T052` + +### US3 (Dedupe + identity + race handling) + +- Identity review per producer: `T058`–`T063` + +--- + +## Implementation Strategy + +### MVP First (US1 Only) + +1. Complete Phase 1–2 (canonical run primitive) +2. Complete US1 (Monitoring list/detail + tenant isolation) +3. Validate Monitoring renders DB-only and is tenant-scoped + +### Incremental Delivery + +1. Add US2 producers/workers + notifications +2. Add US3 dedupe + race validation +3. Polish (formatting, targeted tests, quickstart validation) -- [ ] **Notifications**: Implement Database Notifications for "Run Started" (with link) and "Run Completed" (with outcome). -- [ ] **Frontend**: Ensure "View Run" link in Toast notifications correctly opens the Monitoring Detail view. -- [ ] **Final Verify**: Run through the `requirements.md` checklist manually. \ No newline at end of file diff --git a/tests/Feature/BackupScheduling/RunBackupScheduleJobTest.php b/tests/Feature/BackupScheduling/RunBackupScheduleJobTest.php index b6e1af6..2157638 100644 --- a/tests/Feature/BackupScheduling/RunBackupScheduleJobTest.php +++ b/tests/Feature/BackupScheduling/RunBackupScheduleJobTest.php @@ -7,6 +7,7 @@ use App\Services\BulkOperationService; use App\Services\Intune\BackupService; use App\Services\Intune\PolicySyncService; +use App\Services\OperationRunService; use Carbon\CarbonImmutable; use Illuminate\Support\Facades\Cache; @@ -37,6 +38,170 @@ 'status' => BackupScheduleRun::STATUS_RUNNING, ]); + /** @var OperationRunService $operationRunService */ + $operationRunService = app(OperationRunService::class); + $operationRun = $operationRunService->ensureRun( + tenant: $tenant, + type: 'backup_schedule.run_now', + inputs: ['backup_schedule_id' => (int) $schedule->id], + initiator: $user, + ); + + app()->bind(PolicySyncService::class, fn () => new class extends PolicySyncService + { + public function __construct() {} + + public function syncPoliciesWithReport($tenant, ?array $supportedTypes = null): array + { + return ['synced' => [], 'failures' => []]; + } + }); + + $backupSet = BackupSet::factory()->create([ + 'tenant_id' => $tenant->id, + 'status' => 'completed', + 'item_count' => 0, + ]); + + app()->bind(BackupService::class, fn () => new class($backupSet) extends BackupService + { + public function __construct(private readonly BackupSet $backupSet) {} + + public function createBackupSet($tenant, $policyIds, ?string $actorEmail = null, ?string $actorName = null, ?string $name = null, bool $includeAssignments = false, bool $includeScopeTags = false, bool $includeFoundations = false): BackupSet + { + return $this->backupSet; + } + }); + + Cache::flush(); + + (new RunBackupScheduleJob($run->id, null, $operationRun))->handle( + app(PolicySyncService::class), + app(BackupService::class), + app(\App\Services\BackupScheduling\PolicyTypeResolver::class), + app(\App\Services\BackupScheduling\ScheduleTimeService::class), + app(\App\Services\Intune\AuditLogger::class), + app(\App\Services\BackupScheduling\RunErrorMapper::class), + app(BulkOperationService::class), + ); + + $run->refresh(); + expect($run->status)->toBe(BackupScheduleRun::STATUS_SUCCESS); + expect($run->backup_set_id)->toBe($backupSet->id); + + $operationRun->refresh(); + expect($operationRun->status)->toBe('completed'); + expect($operationRun->outcome)->toBe('succeeded'); + expect($operationRun->summary_counts)->toMatchArray([ + 'backup_schedule_id' => (int) $schedule->id, + 'backup_schedule_run_id' => (int) $run->id, + 'backup_set_id' => (int) $backupSet->id, + ]); +}); + +it('skips runs when all policy types are unknown', function () { + CarbonImmutable::setTestNow(CarbonImmutable::create(2026, 1, 5, 10, 0, 30, 'UTC')); + + [$user, $tenant] = createUserWithTenant(role: 'owner'); + $this->actingAs($user); + + $schedule = BackupSchedule::query()->create([ + 'tenant_id' => $tenant->id, + 'name' => 'Daily 10:00', + 'is_enabled' => true, + 'timezone' => 'UTC', + 'frequency' => 'daily', + 'time_of_day' => '10:00:00', + 'days_of_week' => null, + 'policy_types' => ['definitelyNotARealPolicyType'], + 'include_foundations' => true, + 'retention_keep_last' => 30, + 'next_run_at' => null, + ]); + + $run = BackupScheduleRun::query()->create([ + 'backup_schedule_id' => $schedule->id, + 'tenant_id' => $tenant->id, + 'scheduled_for' => CarbonImmutable::now('UTC')->startOfMinute(), + 'status' => BackupScheduleRun::STATUS_RUNNING, + ]); + + Cache::flush(); + + /** @var OperationRunService $operationRunService */ + $operationRunService = app(OperationRunService::class); + $operationRun = $operationRunService->ensureRun( + tenant: $tenant, + type: 'backup_schedule.run_now', + inputs: ['backup_schedule_id' => (int) $schedule->id], + initiator: $user, + ); + + (new RunBackupScheduleJob($run->id, null, $operationRun))->handle( + app(PolicySyncService::class), + app(BackupService::class), + app(\App\Services\BackupScheduling\PolicyTypeResolver::class), + app(\App\Services\BackupScheduling\ScheduleTimeService::class), + app(\App\Services\Intune\AuditLogger::class), + app(\App\Services\BackupScheduling\RunErrorMapper::class), + app(BulkOperationService::class), + ); + + $run->refresh(); + expect($run->status)->toBe(BackupScheduleRun::STATUS_SKIPPED); + expect($run->error_code)->toBe('UNKNOWN_POLICY_TYPE'); + expect($run->backup_set_id)->toBeNull(); + + $operationRun->refresh(); + expect($operationRun->status)->toBe('completed'); + expect($operationRun->outcome)->toBe('failed'); + expect($operationRun->failure_summary)->toMatchArray([ + ['code' => 'unknown_policy_type', 'message' => $run->error_message], + ]); +}); + +it('updates the operation run based on the backup schedule run id when not passed into the job', function () { + CarbonImmutable::setTestNow(CarbonImmutable::create(2026, 1, 5, 10, 0, 30, 'UTC')); + + [$user, $tenant] = createUserWithTenant(role: 'owner'); + $this->actingAs($user); + + $schedule = BackupSchedule::query()->create([ + 'tenant_id' => $tenant->id, + 'name' => 'Daily 10:00', + 'is_enabled' => true, + 'timezone' => 'UTC', + 'frequency' => 'daily', + 'time_of_day' => '10:00:00', + 'days_of_week' => null, + 'policy_types' => ['deviceConfiguration'], + 'include_foundations' => true, + 'retention_keep_last' => 30, + 'next_run_at' => null, + ]); + + $run = BackupScheduleRun::query()->create([ + 'backup_schedule_id' => $schedule->id, + 'tenant_id' => $tenant->id, + 'scheduled_for' => CarbonImmutable::now('UTC')->startOfMinute(), + 'status' => BackupScheduleRun::STATUS_RUNNING, + ]); + + /** @var OperationRunService $operationRunService */ + $operationRunService = app(OperationRunService::class); + $operationRun = $operationRunService->ensureRun( + tenant: $tenant, + type: 'backup_schedule.run_now', + inputs: ['backup_schedule_id' => (int) $schedule->id], + initiator: $user, + ); + + $operationRun->update([ + 'context' => array_merge($operationRun->context ?? [], [ + 'backup_schedule_run_id' => (int) $run->getKey(), + ]), + ]); + app()->bind(PolicySyncService::class, fn () => new class extends PolicySyncService { public function __construct() {} @@ -75,52 +240,12 @@ public function createBackupSet($tenant, $policyIds, ?string $actorEmail = null, app(BulkOperationService::class), ); - $run->refresh(); - expect($run->status)->toBe(BackupScheduleRun::STATUS_SUCCESS); - expect($run->backup_set_id)->toBe($backupSet->id); -}); - -it('skips runs when all policy types are unknown', function () { - CarbonImmutable::setTestNow(CarbonImmutable::create(2026, 1, 5, 10, 0, 30, 'UTC')); - - [$user, $tenant] = createUserWithTenant(role: 'owner'); - $this->actingAs($user); - - $schedule = BackupSchedule::query()->create([ - 'tenant_id' => $tenant->id, - 'name' => 'Daily 10:00', - 'is_enabled' => true, - 'timezone' => 'UTC', - 'frequency' => 'daily', - 'time_of_day' => '10:00:00', - 'days_of_week' => null, - 'policy_types' => ['definitelyNotARealPolicyType'], - 'include_foundations' => true, - 'retention_keep_last' => 30, - 'next_run_at' => null, + $operationRun->refresh(); + expect($operationRun->status)->toBe('completed'); + expect($operationRun->outcome)->toBe('succeeded'); + expect($operationRun->summary_counts)->toMatchArray([ + 'backup_schedule_id' => (int) $schedule->id, + 'backup_schedule_run_id' => (int) $run->id, + 'backup_set_id' => (int) $backupSet->id, ]); - - $run = BackupScheduleRun::query()->create([ - 'backup_schedule_id' => $schedule->id, - 'tenant_id' => $tenant->id, - 'scheduled_for' => CarbonImmutable::now('UTC')->startOfMinute(), - 'status' => BackupScheduleRun::STATUS_RUNNING, - ]); - - Cache::flush(); - - (new RunBackupScheduleJob($run->id))->handle( - app(PolicySyncService::class), - app(BackupService::class), - app(\App\Services\BackupScheduling\PolicyTypeResolver::class), - app(\App\Services\BackupScheduling\ScheduleTimeService::class), - app(\App\Services\Intune\AuditLogger::class), - app(\App\Services\BackupScheduling\RunErrorMapper::class), - app(BulkOperationService::class), - ); - - $run->refresh(); - expect($run->status)->toBe(BackupScheduleRun::STATUS_SKIPPED); - expect($run->error_code)->toBe('UNKNOWN_POLICY_TYPE'); - expect($run->backup_set_id)->toBeNull(); }); diff --git a/tests/Feature/BackupScheduling/RunNowRetryActionsTest.php b/tests/Feature/BackupScheduling/RunNowRetryActionsTest.php index 807fc5b..6e7b808 100644 --- a/tests/Feature/BackupScheduling/RunNowRetryActionsTest.php +++ b/tests/Feature/BackupScheduling/RunNowRetryActionsTest.php @@ -5,14 +5,29 @@ use App\Models\BackupSchedule; use App\Models\BackupScheduleRun; use App\Models\BulkOperationRun; +use App\Models\OperationRun; use App\Models\User; +use App\Notifications\OperationRunQueued; +use App\Services\Graph\GraphClientInterface; +use App\Support\OperationRunLinks; use Carbon\CarbonImmutable; use Filament\Facades\Filament; use Illuminate\Support\Facades\Queue; use Livewire\Livewire; +beforeEach(function () { + $this->mock(GraphClientInterface::class, function ($mock): void { + $mock->shouldReceive('listPolicies')->never(); + $mock->shouldReceive('getPolicy')->never(); + $mock->shouldReceive('getOrganization')->never(); + $mock->shouldReceive('applyPolicy')->never(); + $mock->shouldReceive('getServicePrincipalPermissions')->never(); + $mock->shouldReceive('request')->never(); + }); +}); + test('operator can run now and it persists a database notification', function () { - Queue::fake(); + Queue::fake([RunBackupScheduleJob::class]); [$user, $tenant] = createUserWithTenant(role: 'operator'); @@ -42,6 +57,17 @@ expect($run)->not->toBeNull(); expect($run->user_id)->toBe($user->id); + $operationRun = OperationRun::query() + ->where('tenant_id', $tenant->id) + ->where('type', 'backup_schedule.run_now') + ->first(); + + expect($operationRun)->not->toBeNull(); + expect($operationRun->context)->toMatchArray([ + 'backup_schedule_id' => (int) $schedule->id, + 'backup_schedule_run_id' => (int) $run->id, + ]); + expect(BulkOperationRun::query() ->where('tenant_id', $tenant->id) ->where('user_id', $user->id) @@ -50,19 +76,29 @@ ->count()) ->toBe(1); - Queue::assertPushed(RunBackupScheduleJob::class); + Queue::assertPushed(RunBackupScheduleJob::class, function (RunBackupScheduleJob $job) use ($run, $operationRun): bool { + return $job->backupScheduleRunId === (int) $run->id + && $job->operationRun instanceof OperationRun + && $job->operationRun->is($operationRun); + }); $this->assertDatabaseCount('notifications', 1); $this->assertDatabaseHas('notifications', [ 'notifiable_id' => $user->id, 'notifiable_type' => User::class, + 'type' => OperationRunQueued::class, 'data->format' => 'filament', - 'data->title' => 'Run dispatched', + 'data->title' => 'Operation queued', ]); + + $notification = $user->notifications()->latest('id')->first(); + expect($notification)->not->toBeNull(); + expect($notification->data['actions'][0]['url'] ?? null) + ->toBe(OperationRunLinks::view($operationRun, $tenant)); }); test('operator can retry and it persists a database notification', function () { - Queue::fake(); + Queue::fake([RunBackupScheduleJob::class]); [$user, $tenant] = createUserWithTenant(role: 'operator'); @@ -92,6 +128,17 @@ expect($run)->not->toBeNull(); expect($run->user_id)->toBe($user->id); + $operationRun = OperationRun::query() + ->where('tenant_id', $tenant->id) + ->where('type', 'backup_schedule.retry') + ->first(); + + expect($operationRun)->not->toBeNull(); + expect($operationRun->context)->toMatchArray([ + 'backup_schedule_id' => (int) $schedule->id, + 'backup_schedule_run_id' => (int) $run->id, + ]); + expect(BulkOperationRun::query() ->where('tenant_id', $tenant->id) ->where('user_id', $user->id) @@ -100,13 +147,24 @@ ->count()) ->toBe(1); - Queue::assertPushed(RunBackupScheduleJob::class); + Queue::assertPushed(RunBackupScheduleJob::class, function (RunBackupScheduleJob $job) use ($run, $operationRun): bool { + return $job->backupScheduleRunId === (int) $run->id + && $job->operationRun instanceof OperationRun + && $job->operationRun->is($operationRun); + }); $this->assertDatabaseCount('notifications', 1); $this->assertDatabaseHas('notifications', [ 'notifiable_id' => $user->id, + 'notifiable_type' => User::class, + 'type' => OperationRunQueued::class, 'data->format' => 'filament', - 'data->title' => 'Retry dispatched', + 'data->title' => 'Operation queued', ]); + + $notification = $user->notifications()->latest('id')->first(); + expect($notification)->not->toBeNull(); + expect($notification->data['actions'][0]['url'] ?? null) + ->toBe(OperationRunLinks::view($operationRun, $tenant)); }); test('readonly cannot dispatch run now or retry', function () { @@ -144,10 +202,16 @@ expect(BackupScheduleRun::query()->where('backup_schedule_id', $schedule->id)->count()) ->toBe(0); + + expect(OperationRun::query() + ->where('tenant_id', $tenant->id) + ->whereIn('type', ['backup_schedule.run_now', 'backup_schedule.retry']) + ->count()) + ->toBe(0); }); test('operator can bulk run now and it persists a database notification', function () { - Queue::fake(); + Queue::fake([RunBackupScheduleJob::class]); [$user, $tenant] = createUserWithTenant(role: 'operator'); @@ -189,6 +253,12 @@ expect(BackupScheduleRun::query()->where('backup_schedule_id', $scheduleA->id)->value('user_id'))->toBe($user->id); expect(BackupScheduleRun::query()->where('backup_schedule_id', $scheduleB->id)->value('user_id'))->toBe($user->id); + expect(OperationRun::query() + ->where('tenant_id', $tenant->id) + ->where('type', 'backup_schedule.run_now') + ->count()) + ->toBe(2); + expect(BulkOperationRun::query() ->where('tenant_id', $tenant->id) ->where('user_id', $user->id) @@ -204,10 +274,15 @@ 'data->format' => 'filament', 'data->title' => 'Runs dispatched', ]); + + $notification = $user->notifications()->latest('id')->first(); + expect($notification)->not->toBeNull(); + expect($notification->data['actions'][0]['url'] ?? null) + ->toBe(OperationRunLinks::index($tenant)); }); test('operator can bulk retry and it persists a database notification', function () { - Queue::fake(); + Queue::fake([RunBackupScheduleJob::class]); [$user, $tenant] = createUserWithTenant(role: 'operator'); @@ -249,6 +324,12 @@ expect(BackupScheduleRun::query()->where('backup_schedule_id', $scheduleA->id)->value('user_id'))->toBe($user->id); expect(BackupScheduleRun::query()->where('backup_schedule_id', $scheduleB->id)->value('user_id'))->toBe($user->id); + expect(OperationRun::query() + ->where('tenant_id', $tenant->id) + ->where('type', 'backup_schedule.retry') + ->count()) + ->toBe(2); + expect(BulkOperationRun::query() ->where('tenant_id', $tenant->id) ->where('user_id', $user->id) @@ -264,10 +345,15 @@ 'data->format' => 'filament', 'data->title' => 'Retries dispatched', ]); + + $notification = $user->notifications()->latest('id')->first(); + expect($notification)->not->toBeNull(); + expect($notification->data['actions'][0]['url'] ?? null) + ->toBe(OperationRunLinks::index($tenant)); }); test('operator can bulk retry even if a run already exists for this minute', function () { - Queue::fake(); + Queue::fake([RunBackupScheduleJob::class]); [$user, $tenant] = createUserWithTenant(role: 'operator'); diff --git a/tests/Feature/BackupSets/AddPoliciesStartSurfaceTest.php b/tests/Feature/BackupSets/AddPoliciesStartSurfaceTest.php new file mode 100644 index 0000000..f6c82cd --- /dev/null +++ b/tests/Feature/BackupSets/AddPoliciesStartSurfaceTest.php @@ -0,0 +1,70 @@ +mock(GraphClientInterface::class, function ($mock): void { + $mock->shouldReceive('listPolicies')->never(); + $mock->shouldReceive('getPolicy')->never(); + $mock->shouldReceive('getOrganization')->never(); + $mock->shouldReceive('applyPolicy')->never(); + $mock->shouldReceive('getServicePrincipalPermissions')->never(); + $mock->shouldReceive('request')->never(); + }); + + [$user, $tenant] = createUserWithTenant(role: 'owner'); + $this->actingAs($user); + + $tenant->makeCurrent(); + Filament::setTenant($tenant, true); + + $backupSet = BackupSet::factory()->create([ + 'tenant_id' => $tenant->id, + 'name' => 'Test backup', + ]); + + $policies = Policy::factory()->count(2)->create([ + 'tenant_id' => $tenant->id, + 'ignored_at' => null, + 'last_synced_at' => now(), + ]); + + Livewire::actingAs($user) + ->test(BackupSetPolicyPickerTable::class, [ + 'backupSetId' => $backupSet->id, + ]) + ->callTableBulkAction('add_selected_to_backup_set', $policies) + ->assertHasNoTableBulkActionErrors(); + + $opRun = OperationRun::query() + ->where('tenant_id', $tenant->getKey()) + ->where('type', 'backup_set.add_policies') + ->latest('id') + ->first(); + + expect($opRun)->not->toBeNull(); + expect($opRun?->status)->toBe('queued'); + expect($opRun?->outcome)->toBe('pending'); + expect($opRun?->context['backup_set_id'] ?? null)->toBe((int) $backupSet->getKey()); + + $notifications = session('filament.notifications', []); + expect($notifications)->not->toBeEmpty(); + expect(collect($notifications)->last()['actions'][0]['url'] ?? null) + ->toBe(OperationRunLinks::view($opRun, $tenant)); + + Queue::assertPushed(AddPoliciesToBackupSetJob::class, function (AddPoliciesToBackupSetJob $job) use ($opRun): bool { + return $job->operationRun instanceof OperationRun + && (int) $job->operationRun->getKey() === (int) $opRun?->getKey(); + }); +}); diff --git a/tests/Feature/BackupSets/RemovePoliciesJobNotificationTest.php b/tests/Feature/BackupSets/RemovePoliciesJobNotificationTest.php new file mode 100644 index 0000000..7ecdb52 --- /dev/null +++ b/tests/Feature/BackupSets/RemovePoliciesJobNotificationTest.php @@ -0,0 +1,65 @@ +create([ + 'tenant_id' => $tenant->getKey(), + 'name' => 'Test backup', + 'item_count' => 0, + ]); + + $item = BackupItem::factory()->create([ + 'tenant_id' => $tenant->getKey(), + 'backup_set_id' => $backupSet->getKey(), + ]); + + $backupSet->update(['item_count' => $backupSet->items()->count()]); + + $opRun = OperationRun::create([ + 'tenant_id' => $tenant->getKey(), + 'user_id' => $user->getKey(), + 'initiator_name' => $user->name, + 'type' => 'backup_set.remove_policies', + 'status' => 'queued', + 'outcome' => 'pending', + 'run_identity_hash' => 'remove-hash-1', + 'context' => [ + 'backup_set_id' => (int) $backupSet->getKey(), + 'backup_item_ids' => [(int) $item->getKey()], + ], + ]); + + $this->mock(AuditLogger::class, function ($mock): void { + $mock->shouldReceive('log')->zeroOrMoreTimes(); + }); + + $job = new RemovePoliciesFromBackupSetJob( + backupSetId: (int) $backupSet->getKey(), + backupItemIds: [(int) $item->getKey()], + initiatorUserId: (int) $user->getKey(), + operationRun: $opRun, + ); + + $job->handle(app(AuditLogger::class), app(BulkOperationService::class)); + + $this->assertDatabaseHas('notifications', [ + 'notifiable_id' => $user->getKey(), + 'notifiable_type' => $user->getMorphClass(), + 'type' => DatabaseNotification::class, + ]); + + $notification = $user->notifications()->latest('id')->first(); + expect($notification)->not->toBeNull(); + expect($notification->data['actions'][0]['url'] ?? null) + ->toBe(OperationRunLinks::view($opRun, $tenant)); +}); diff --git a/tests/Feature/BackupSets/RemovePoliciesStartSurfaceTest.php b/tests/Feature/BackupSets/RemovePoliciesStartSurfaceTest.php new file mode 100644 index 0000000..585b93d --- /dev/null +++ b/tests/Feature/BackupSets/RemovePoliciesStartSurfaceTest.php @@ -0,0 +1,60 @@ +mock(GraphClientInterface::class, function ($mock): void { + $mock->shouldReceive('listPolicies')->never(); + $mock->shouldReceive('getPolicy')->never(); + $mock->shouldReceive('getOrganization')->never(); + $mock->shouldReceive('applyPolicy')->never(); + $mock->shouldReceive('getServicePrincipalPermissions')->never(); + $mock->shouldReceive('request')->never(); + }); + + [$user, $tenant] = createUserWithTenant(role: 'owner'); + $this->actingAs($user); + + $tenant->makeCurrent(); + Filament::setTenant($tenant, true); + + $backupSet = BackupSet::factory()->create([ + 'tenant_id' => $tenant->id, + 'name' => 'Test backup', + ]); + + $backupItem = BackupItem::factory()->for($backupSet)->for($tenant)->create(); + + Livewire::test(BackupItemsRelationManager::class, [ + 'ownerRecord' => $backupSet, + 'pageClass' => EditBackupSet::class, + ]) + ->callTableAction('remove', $backupItem); + + Queue::assertPushed(RemovePoliciesFromBackupSetJob::class, function (RemovePoliciesFromBackupSetJob $job) use ($backupSet, $backupItem, $user) { + return $job->backupSetId === (int) $backupSet->getKey() + && $job->backupItemIds === [(int) $backupItem->getKey()] + && $job->initiatorUserId === (int) $user->getKey(); + }); + + $run = OperationRun::query() + ->where('tenant_id', $tenant->getKey()) + ->where('type', 'backup_set.remove_policies') + ->latest('id') + ->first(); + + expect($run)->not->toBeNull(); + expect($run->status)->toBe('queued'); + expect($run->context['backup_set_id'] ?? null)->toBe((int) $backupSet->getKey()); +}); diff --git a/tests/Feature/Console/ReconcileBackupScheduleOperationRunsCommandTest.php b/tests/Feature/Console/ReconcileBackupScheduleOperationRunsCommandTest.php new file mode 100644 index 0000000..105ab0c --- /dev/null +++ b/tests/Feature/Console/ReconcileBackupScheduleOperationRunsCommandTest.php @@ -0,0 +1,88 @@ +create(); + + $schedule = BackupSchedule::create([ + 'tenant_id' => $tenant->id, + 'name' => 'Daily', + 'is_enabled' => true, + 'timezone' => 'UTC', + 'frequency' => 'daily', + 'time_of_day' => '10:00:00', + 'days_of_week' => null, + 'policy_types' => ['deviceConfiguration'], + 'include_foundations' => true, + 'retention_keep_last' => 30, + 'last_run_at' => null, + 'last_run_status' => null, + 'next_run_at' => null, + ]); + + $startedAt = CarbonImmutable::parse('2026-01-01 00:00:00', 'UTC'); + $finishedAt = CarbonImmutable::parse('2026-01-01 00:00:05', 'UTC'); + + $scheduleRun = BackupScheduleRun::create([ + 'backup_schedule_id' => $schedule->id, + 'tenant_id' => $tenant->id, + 'scheduled_for' => CarbonImmutable::parse('2026-01-01 00:00:00', 'UTC'), + 'started_at' => $startedAt, + 'finished_at' => $finishedAt, + 'status' => BackupScheduleRun::STATUS_SUCCESS, + 'summary' => [ + 'policies_total' => 5, + 'policies_backed_up' => 18, + 'sync_failures' => [], + ], + 'error_code' => null, + 'error_message' => null, + 'backup_set_id' => null, + ]); + + $operationRun = OperationRun::create([ + 'tenant_id' => $tenant->id, + 'user_id' => null, + 'initiator_name' => 'System', + 'type' => 'backup_schedule.run_now', + 'status' => 'queued', + 'outcome' => 'pending', + 'run_identity_hash' => hash('sha256', 'backup_schedule.run_now|'.$scheduleRun->id), + 'summary_counts' => [], + 'failure_summary' => [], + 'context' => [ + 'backup_schedule_id' => (int) $schedule->id, + 'backup_schedule_run_id' => (int) $scheduleRun->id, + ], + ]); + + $this->artisan('tenantpilot:operation-runs:reconcile-backup-schedules', [ + '--tenant' => [$tenant->id], + '--older-than' => 0, + ])->assertSuccessful(); + + $operationRun->refresh(); + + expect($operationRun->status)->toBe('completed'); + expect($operationRun->outcome)->toBe('succeeded'); + expect($operationRun->failure_summary)->toBe([]); + + expect($operationRun->started_at?->format('Y-m-d H:i:s'))->toBe($startedAt->format('Y-m-d H:i:s')); + expect($operationRun->completed_at?->format('Y-m-d H:i:s'))->toBe($finishedAt->format('Y-m-d H:i:s')); + + expect($operationRun->summary_counts)->toMatchArray([ + 'backup_schedule_id' => (int) $schedule->id, + 'backup_schedule_run_id' => (int) $scheduleRun->id, + 'policies_total' => 5, + 'policies_backed_up' => 18, + 'sync_failures' => 0, + ]); +}); diff --git a/tests/Feature/DirectoryGroups/StartSyncFromGroupsPageTest.php b/tests/Feature/DirectoryGroups/StartSyncFromGroupsPageTest.php new file mode 100644 index 0000000..d93ea1b --- /dev/null +++ b/tests/Feature/DirectoryGroups/StartSyncFromGroupsPageTest.php @@ -0,0 +1,72 @@ +mock(GraphClientInterface::class, function ($mock): void { + $mock->shouldReceive('listPolicies')->never(); + $mock->shouldReceive('getPolicy')->never(); + $mock->shouldReceive('getOrganization')->never(); + $mock->shouldReceive('applyPolicy')->never(); + $mock->shouldReceive('getServicePrincipalPermissions')->never(); + $mock->shouldReceive('request')->never(); + }); + + [$user, $tenant] = createUserWithTenant(role: 'owner'); + $this->actingAs($user); + + $tenant->makeCurrent(); + Filament::setTenant($tenant, true); + + Livewire::test(ListEntraGroups::class) + ->callAction('sync_groups'); + + $run = EntraGroupSyncRun::query() + ->where('tenant_id', $tenant->getKey()) + ->latest('id') + ->first(); + + expect($run)->not->toBeNull(); + expect($run?->status)->toBe(EntraGroupSyncRun::STATUS_PENDING); + expect($run?->selection_key)->toBe('groups-v1:all'); + + $opRun = OperationRun::query() + ->where('tenant_id', $tenant->getKey()) + ->where('type', 'directory_groups.sync') + ->latest('id') + ->first(); + + expect($opRun)->not->toBeNull(); + expect($opRun?->status)->toBe('queued'); + + Queue::assertPushed(EntraGroupSyncJob::class, function (EntraGroupSyncJob $job) use ($tenant, $run, $opRun): bool { + return $job->tenantId === (int) $tenant->getKey() + && $job->runId === (int) $run?->getKey() + && $job->operationRun instanceof OperationRun + && (int) $job->operationRun->getKey() === (int) $opRun?->getKey(); + }); +}); + +it('hides group sync start action for readonly users', function () { + Queue::fake(); + + [$user, $tenant] = createUserWithTenant(role: 'readonly'); + $this->actingAs($user); + + $tenant->makeCurrent(); + Filament::setTenant($tenant, true); + + Livewire::test(ListEntraGroups::class) + ->assertActionHidden('sync_groups'); + + Queue::assertNothingPushed(); +}); diff --git a/tests/Feature/Drift/DriftGenerationDispatchTest.php b/tests/Feature/Drift/DriftGenerationDispatchTest.php index 5807cc4..5e3f748 100644 --- a/tests/Feature/Drift/DriftGenerationDispatchTest.php +++ b/tests/Feature/Drift/DriftGenerationDispatchTest.php @@ -1,16 +1,28 @@ mock(GraphClientInterface::class, function ($mock): void { + $mock->shouldReceive('listPolicies')->never(); + $mock->shouldReceive('getPolicy')->never(); + $mock->shouldReceive('getOrganization')->never(); + $mock->shouldReceive('applyPolicy')->never(); + $mock->shouldReceive('getServicePrincipalPermissions')->never(); + $mock->shouldReceive('request')->never(); + }); +}); + test('opening Drift dispatches generation when findings are missing', function () { Queue::fake(); @@ -61,24 +73,30 @@ 'current_run_id' => (int) $current->getKey(), ]); - $this->assertDatabaseHas('notifications', [ - 'notifiable_id' => $user->getKey(), - 'notifiable_type' => $user->getMorphClass(), - 'type' => RunStatusChangedNotification::class, - ]); + $opRun = OperationRun::query() + ->where('tenant_id', $tenant->getKey()) + ->where('type', 'drift.generate') + ->latest('id') + ->first(); - $notification = $user->notifications()->latest('id')->first(); - expect($notification)->not->toBeNull(); - expect($notification->data['actions'][0]['url'] ?? null) - ->toBe(BulkOperationRunResource::getUrl('view', ['record' => $bulkRun->getKey()], tenant: $tenant)); + expect($opRun)->not->toBeNull(); + expect($opRun?->status)->toBe('queued'); - Queue::assertPushed(GenerateDriftFindingsJob::class, function (GenerateDriftFindingsJob $job) use ($tenant, $user, $baseline, $current, $scopeKey, $bulkRun): bool { + $notifications = session('filament.notifications', []); + + expect($notifications)->not->toBeEmpty(); + expect(collect($notifications)->last()['actions'][0]['url'] ?? null) + ->toBe(OperationRunLinks::view($opRun, $tenant)); + + Queue::assertPushed(GenerateDriftFindingsJob::class, function (GenerateDriftFindingsJob $job) use ($tenant, $user, $baseline, $current, $scopeKey, $bulkRun, $opRun): bool { return $job->tenantId === (int) $tenant->getKey() && $job->userId === (int) $user->getKey() && $job->baselineRunId === (int) $baseline->getKey() && $job->currentRunId === (int) $current->getKey() && $job->scopeKey === $scopeKey - && $job->bulkOperationRunId === (int) $bulkRun->getKey(); + && $job->bulkOperationRunId === (int) $bulkRun->getKey() + && $job->operationRun instanceof OperationRun + && (int) $job->operationRun->getKey() === (int) $opRun?->getKey(); }); }); @@ -123,6 +141,11 @@ ->where('tenant_id', $tenant->getKey()) ->where('idempotency_key', $idempotencyKey) ->count())->toBe(1); + + expect(OperationRun::query() + ->where('tenant_id', $tenant->getKey()) + ->where('type', 'drift.generate') + ->count())->toBe(1); }); test('opening Drift does not dispatch generation when fewer than two successful runs exist', function () { @@ -144,6 +167,7 @@ Queue::assertNothingPushed(); expect(BulkOperationRun::query()->where('tenant_id', $tenant->getKey())->count())->toBe(0); + expect(OperationRun::query()->where('tenant_id', $tenant->getKey())->count())->toBe(0); }); test('opening Drift does not dispatch generation for readonly users', function () { @@ -171,4 +195,5 @@ Queue::assertNothingPushed(); expect(BulkOperationRun::query()->where('tenant_id', $tenant->getKey())->count())->toBe(0); + expect(OperationRun::query()->where('tenant_id', $tenant->getKey())->count())->toBe(0); }); diff --git a/tests/Feature/Drift/GenerateDriftFindingsJobNotificationTest.php b/tests/Feature/Drift/GenerateDriftFindingsJobNotificationTest.php index 0100470..4cdf501 100644 --- a/tests/Feature/Drift/GenerateDriftFindingsJobNotificationTest.php +++ b/tests/Feature/Drift/GenerateDriftFindingsJobNotificationTest.php @@ -1,12 +1,13 @@ [], ]); + $opRun = OperationRun::create([ + 'tenant_id' => $tenant->getKey(), + 'user_id' => $user->getKey(), + 'initiator_name' => $user->name, + 'type' => 'drift.generate', + 'status' => 'queued', + 'outcome' => 'pending', + 'run_identity_hash' => 'drift-hash-1', + 'context' => [ + 'scope_key' => $scopeKey, + 'baseline_run_id' => (int) $baseline->getKey(), + 'current_run_id' => (int) $current->getKey(), + ], + ]); + $this->mock(DriftFindingGenerator::class, function (MockInterface $mock) { $mock->shouldReceive('generate')->once()->andReturn(0); }); @@ -51,6 +67,7 @@ currentRunId: (int) $current->getKey(), scopeKey: $scopeKey, bulkOperationRunId: (int) $run->getKey(), + operationRun: $opRun, ); $job->handle(app(DriftFindingGenerator::class), app(BulkOperationService::class)); @@ -60,13 +77,13 @@ $this->assertDatabaseHas('notifications', [ 'notifiable_id' => $user->getKey(), 'notifiable_type' => $user->getMorphClass(), - 'type' => RunStatusChangedNotification::class, + 'type' => DatabaseNotification::class, ]); $notification = $user->notifications()->latest('id')->first(); expect($notification)->not->toBeNull(); expect($notification->data['actions'][0]['url'] ?? null) - ->toBe(BulkOperationRunResource::getUrl('view', ['record' => $run->getKey()], tenant: $tenant)); + ->toBe(OperationRunLinks::view($opRun, $tenant)); }); test('drift generation job sends failure notification with view link', function () { @@ -100,6 +117,21 @@ 'failures' => [], ]); + $opRun = OperationRun::create([ + 'tenant_id' => $tenant->getKey(), + 'user_id' => $user->getKey(), + 'initiator_name' => $user->name, + 'type' => 'drift.generate', + 'status' => 'queued', + 'outcome' => 'pending', + 'run_identity_hash' => 'drift-hash-2', + 'context' => [ + 'scope_key' => $scopeKey, + 'baseline_run_id' => (int) $baseline->getKey(), + 'current_run_id' => (int) $current->getKey(), + ], + ]); + $this->mock(DriftFindingGenerator::class, function (MockInterface $mock) { $mock->shouldReceive('generate')->once()->andThrow(new RuntimeException('boom')); }); @@ -111,6 +143,7 @@ currentRunId: (int) $current->getKey(), scopeKey: $scopeKey, bulkOperationRunId: (int) $run->getKey(), + operationRun: $opRun, ); try { @@ -133,11 +166,11 @@ $this->assertDatabaseHas('notifications', [ 'notifiable_id' => $user->getKey(), 'notifiable_type' => $user->getMorphClass(), - 'type' => RunStatusChangedNotification::class, + 'type' => DatabaseNotification::class, ]); $notification = $user->notifications()->latest('id')->first(); expect($notification)->not->toBeNull(); expect($notification->data['actions'][0]['url'] ?? null) - ->toBe(BulkOperationRunResource::getUrl('view', ['record' => $run->getKey()], tenant: $tenant)); + ->toBe(OperationRunLinks::view($opRun, $tenant)); }); diff --git a/tests/Feature/Filament/BackupItemsBulkRemoveTest.php b/tests/Feature/Filament/BackupItemsBulkRemoveTest.php index a1a0ae6..a2cecee 100644 --- a/tests/Feature/Filament/BackupItemsBulkRemoveTest.php +++ b/tests/Feature/Filament/BackupItemsBulkRemoveTest.php @@ -1,21 +1,25 @@ create(); $tenant->makeCurrent(); - $user = User::factory()->create(); + [$user] = createUserWithTenant(tenant: $tenant, role: 'owner'); $backupSet = BackupSet::factory()->create([ 'tenant_id' => $tenant->id, @@ -64,11 +68,21 @@ ->callTableBulkAction('bulk_remove', collect([$itemA, $itemB])) ->assertHasNoTableBulkActionErrors(); + Queue::assertPushed(RemovePoliciesFromBackupSetJob::class); + + $run = OperationRun::query() + ->where('tenant_id', $tenant->getKey()) + ->where('type', 'backup_set.remove_policies') + ->latest('id') + ->first(); + + expect($run)->not->toBeNull(); + expect($run?->status)->toBe('queued'); + expect($run?->outcome)->toBe('pending'); + expect($run?->context['backup_set_id'] ?? null)->toBe((int) $backupSet->getKey()); + + // Enqueue-only: deletion happens in the job. $backupSet->refresh(); - - expect($backupSet->items()->count())->toBe(0); - expect($backupSet->item_count)->toBe(0); - - $this->assertSoftDeleted('backup_items', ['id' => $itemA->id]); - $this->assertSoftDeleted('backup_items', ['id' => $itemB->id]); + expect($backupSet->items()->count())->toBe(2); + expect($backupSet->item_count)->toBe(2); }); diff --git a/tests/Feature/Inventory/InventorySyncStartSurfaceTest.php b/tests/Feature/Inventory/InventorySyncStartSurfaceTest.php new file mode 100644 index 0000000..681266a --- /dev/null +++ b/tests/Feature/Inventory/InventorySyncStartSurfaceTest.php @@ -0,0 +1,58 @@ +mock(GraphClientInterface::class, function ($mock): void { + $mock->shouldReceive('listPolicies')->never(); + $mock->shouldReceive('getPolicy')->never(); + $mock->shouldReceive('getOrganization')->never(); + $mock->shouldReceive('applyPolicy')->never(); + $mock->shouldReceive('getServicePrincipalPermissions')->never(); + $mock->shouldReceive('request')->never(); + }); + + [$user, $tenant] = createUserWithTenant(role: 'owner'); + $this->actingAs($user); + + $tenant->makeCurrent(); + Filament::setTenant($tenant, true); + + $sync = app(InventorySyncService::class); + $policyTypes = $sync->defaultSelectionPayload()['policy_types'] ?? []; + + Livewire::test(InventoryLanding::class) + ->callAction('run_inventory_sync', data: ['policy_types' => $policyTypes]); + + $opRun = OperationRun::query() + ->where('tenant_id', $tenant->getKey()) + ->where('type', 'inventory.sync') + ->latest('id') + ->first(); + + expect($opRun)->not->toBeNull(); + expect($opRun?->status)->toBe('queued'); + expect($opRun?->outcome)->toBe('pending'); + + $notifications = session('filament.notifications', []); + expect($notifications)->not->toBeEmpty(); + expect(collect($notifications)->last()['actions'][0]['url'] ?? null) + ->toBe(OperationRunLinks::view($opRun, $tenant)); + + Queue::assertPushed(RunInventorySyncJob::class, function (RunInventorySyncJob $job) use ($tenant, $user, $opRun): bool { + return $job->tenantId === (int) $tenant->getKey() + && $job->userId === (int) $user->getKey() + && $job->operationRun instanceof OperationRun + && (int) $job->operationRun->getKey() === (int) $opRun?->getKey(); + }); +}); diff --git a/tests/Feature/Inventory/RunInventorySyncJobTest.php b/tests/Feature/Inventory/RunInventorySyncJobTest.php index d560269..b0ed7da 100644 --- a/tests/Feature/Inventory/RunInventorySyncJobTest.php +++ b/tests/Feature/Inventory/RunInventorySyncJobTest.php @@ -23,7 +23,7 @@ $selectionPayload = $sync->defaultSelectionPayload(); $computed = $sync->normalizeAndHashSelection($selectionPayload); $policyTypes = $computed['selection']['policy_types']; - $run = $sync->createPendingRunForUser($tenant, $user, $selectionPayload); + $run = $sync->createPendingRunForUser($tenant, $user, $computed['selection']); $bulkRun = app(BulkOperationService::class)->createRun( tenant: $tenant, @@ -52,8 +52,10 @@ expect($run->finished_at)->not->toBeNull(); expect($bulkRun->status)->toBe('completed'); - expect($bulkRun->processed_items)->toBe(count($policyTypes)); - expect($bulkRun->succeeded)->toBe(count($policyTypes)); + expect($bulkRun->failed)->toBe(0); + expect($bulkRun->skipped)->toBe(0); + expect($bulkRun->processed_items)->toBeGreaterThan(0); + expect($bulkRun->processed_items)->toBe($bulkRun->succeeded); }); it('maps skipped inventory sync runs to bulk progress as skipped with reason', function () { @@ -66,6 +68,8 @@ $computed = $sync->normalizeAndHashSelection($selectionPayload); $policyTypes = $computed['selection']['policy_types']; + $run->update(['selection_payload' => $computed['selection']]); + $bulkRun = app(BulkOperationService::class)->createRun( tenant: $tenant, user: $user, diff --git a/tests/Feature/MonitoringOperationsTest.php b/tests/Feature/MonitoringOperationsTest.php new file mode 100644 index 0000000..4571262 --- /dev/null +++ b/tests/Feature/MonitoringOperationsTest.php @@ -0,0 +1,140 @@ +create(); + $user = User::factory()->create(); + $tenant->users()->attach($user); + + $run = OperationRun::create([ + 'tenant_id' => $tenant->id, + 'type' => 'test.run', + 'status' => 'queued', + 'outcome' => 'pending', + 'initiator_name' => 'System', + 'run_identity_hash' => 'hash123', + ]); + + $this->actingAs($user) + ->get(OperationRunResource::getUrl('index', tenant: $tenant)) + ->assertSuccessful() + ->assertSee('test.run'); +}); + +it('renders monitoring pages DB-only (never calls Graph)', function () { + $tenant = Tenant::factory()->create(); + $user = User::factory()->create(); + $tenant->users()->attach($user); + + $run = OperationRun::create([ + 'tenant_id' => $tenant->id, + 'type' => 'test.run', + 'status' => 'queued', + 'outcome' => 'pending', + 'initiator_name' => 'System', + 'run_identity_hash' => 'hash123', + ]); + + $this->mock(GraphClientInterface::class, function ($mock): void { + $mock->shouldReceive('listPolicies')->never(); + $mock->shouldReceive('getPolicy')->never(); + $mock->shouldReceive('getOrganization')->never(); + $mock->shouldReceive('applyPolicy')->never(); + $mock->shouldReceive('getServicePrincipalPermissions')->never(); + $mock->shouldReceive('request')->never(); + }); + + $this->actingAs($user) + ->get(OperationRunResource::getUrl('index', tenant: $tenant)) + ->assertSuccessful(); + + $this->actingAs($user) + ->get(OperationRunResource::getUrl('view', ['record' => $run], tenant: $tenant)) + ->assertSuccessful(); +}); + +it('shows runs only for current tenant', function () { + $tenantA = Tenant::factory()->create(); + $tenantB = Tenant::factory()->create(); + $user = User::factory()->create(); + $tenantA->users()->attach($user); + + // We must simulate being in tenant context + $this->actingAs($user); + // Filament::setTenant($tenantA); // This is usually handled by middleware on routes, but in Livewire test we might need manual set or route visit. + + // Easier approach: visit the page for tenantA + + OperationRun::create([ + 'tenant_id' => $tenantA->id, + 'type' => 'tenantA.run', + 'status' => 'queued', + 'outcome' => 'pending', + 'initiator_name' => 'System', + 'run_identity_hash' => 'hashA', + ]); + + OperationRun::create([ + 'tenant_id' => $tenantB->id, + 'type' => 'tenantB.run', + 'status' => 'queued', + 'outcome' => 'pending', + 'initiator_name' => 'System', + 'run_identity_hash' => 'hashB', + ]); + + // Livewire::test needs to know the tenant if the component relies on it. + // However, the component relies on `Filament::getTenant()`. + // The cleanest way is to just GET the page URL, which runs middleware. + + $this->get(OperationRunResource::getUrl('index', tenant: $tenantA)) + ->assertSee('tenantA.run') + ->assertDontSee('tenantB.run'); +}); + +it('allows readonly users to view operations list and detail', function () { + $tenant = Tenant::factory()->create(); + $user = User::factory()->create(); + $tenant->users()->attach($user, ['role' => 'readonly']); + + $run = OperationRun::create([ + 'tenant_id' => $tenant->id, + 'type' => 'test.run', + 'status' => 'queued', + 'outcome' => 'pending', + 'initiator_name' => 'System', + 'run_identity_hash' => 'hash123', + ]); + + $this->actingAs($user) + ->get(OperationRunResource::getUrl('index', tenant: $tenant)) + ->assertSuccessful() + ->assertSee('test.run'); + + $this->actingAs($user) + ->get(OperationRunResource::getUrl('view', ['record' => $run], tenant: $tenant)) + ->assertSuccessful() + ->assertSee('test.run'); +}); + +it('denies access to unauthorized users', function () { + $tenant = Tenant::factory()->create(); + $user = User::factory()->create(); + // Not attached to tenant + + // In a multitenant app, if you try to access a tenant route you are not part of, + // Filament typically returns 404 (Not Found) if it can't find the tenant-user relationship, or 403. + // The previous fail said "Received 404". This confirms Filament couldn't find the tenant for this user scope or just hides it. + // We should accept 404 or 403. + + $response = $this->actingAs($user) + ->get(OperationRunResource::getUrl('index', tenant: $tenant)); + + // Allow either 403 or 404 as "Denied" + $this->assertTrue(in_array($response->status(), [403, 404])); +}); diff --git a/tests/Feature/Notifications/OperationRunNotificationTest.php b/tests/Feature/Notifications/OperationRunNotificationTest.php new file mode 100644 index 0000000..5371c39 --- /dev/null +++ b/tests/Feature/Notifications/OperationRunNotificationTest.php @@ -0,0 +1,131 @@ +actingAs($user); + + $tenant->makeCurrent(); + Filament::setTenant($tenant, true); + + $service = app(OperationRunService::class); + $run = $service->ensureRun( + tenant: $tenant, + type: 'policy.sync', + inputs: ['scope' => 'all'], + initiator: $user, + ); + + $service->dispatchOrFail($run, function (): void { + // no-op (dispatch succeeded) + }); + + $this->assertDatabaseHas('notifications', [ + 'notifiable_id' => $user->getKey(), + 'notifiable_type' => $user->getMorphClass(), + 'type' => OperationRunQueued::class, + 'data->format' => 'filament', + 'data->title' => 'Operation queued', + ]); + + $notification = $user->notifications()->latest('id')->first(); + expect($notification)->not->toBeNull(); + expect($notification->data['actions'][0]['url'] ?? null) + ->toBe(OperationRunLinks::view($run, $tenant)); +}); + +it('does not emit queued notifications for runs without an initiator', function () { + [$user, $tenant] = createUserWithTenant(role: 'owner'); + $this->actingAs($user); + + $tenant->makeCurrent(); + Filament::setTenant($tenant, true); + + $service = app(OperationRunService::class); + + $run = $service->ensureRun( + tenant: $tenant, + type: 'policy.sync', + inputs: ['scope' => 'all'], + initiator: null, + ); + + $service->dispatchOrFail($run, function (): void { + // no-op + }); + + expect($user->notifications()->count())->toBe(0); +}); + +it('emits a terminal notification when an operation run transitions to completed', function () { + [$user, $tenant] = createUserWithTenant(role: 'owner'); + $this->actingAs($user); + + $tenant->makeCurrent(); + Filament::setTenant($tenant, true); + + $run = OperationRun::factory()->create([ + 'tenant_id' => $tenant->getKey(), + 'user_id' => $user->getKey(), + 'initiator_name' => $user->name, + 'type' => 'inventory.sync', + 'status' => 'queued', + 'outcome' => 'pending', + 'context' => ['policy_types' => ['deviceConfiguration']], + ]); + + $service = app(OperationRunService::class); + + $service->updateRun( + $run, + status: 'completed', + outcome: 'succeeded', + summaryCounts: ['observed' => 1], + failures: [], + ); + + $this->assertDatabaseHas('notifications', [ + 'notifiable_id' => $user->getKey(), + 'notifiable_type' => $user->getMorphClass(), + 'type' => OperationRunCompleted::class, + 'data->format' => 'filament', + 'data->title' => 'Operation completed', + ]); + + $notification = $user->notifications()->latest('id')->first(); + expect($notification)->not->toBeNull(); + expect($notification->data['actions'][0]['url'] ?? null) + ->toBe(OperationRunLinks::view($run, $tenant)); +}); + +it('marks a run failed if dispatch throws synchronously', function () { + [$user, $tenant] = createUserWithTenant(role: 'owner'); + $this->actingAs($user); + + $tenant->makeCurrent(); + Filament::setTenant($tenant, true); + + $service = app(OperationRunService::class); + + $run = $service->ensureRun( + tenant: $tenant, + type: 'policy.sync', + inputs: ['scope' => 'all'], + initiator: $user, + ); + + expect(fn () => $service->dispatchOrFail($run, function (): void { + throw new RuntimeException('Queue misconfigured'); + })) + ->toThrow(RuntimeException::class); + + $run->refresh(); + expect($run->status)->toBe('completed'); + expect($run->outcome)->toBe('failed'); +}); diff --git a/tests/Feature/OperationRunServiceTest.php b/tests/Feature/OperationRunServiceTest.php new file mode 100644 index 0000000..998b1eb --- /dev/null +++ b/tests/Feature/OperationRunServiceTest.php @@ -0,0 +1,171 @@ +create(); + $user = User::factory()->create(); + + $service = new OperationRunService; + + $run = $service->ensureRun($tenant, 'test.action', ['scope' => 'full'], $user); + + expect($run)->toBeInstanceOf(OperationRun::class); + + $this->assertDatabaseHas('operation_runs', [ + 'id' => $run->getKey(), + 'tenant_id' => $tenant->getKey(), + 'type' => 'test.action', + 'status' => 'queued', + 'initiator_name' => $user->name, + ]); +}); + +it('reuses an active run (idempotent)', function () { + $tenant = Tenant::factory()->create(); + + $service = new OperationRunService; + + $runA = $service->ensureRun($tenant, 'test.action', ['scope' => 'full']); + $runB = $service->ensureRun($tenant, 'test.action', ['scope' => 'full']); + + expect($runA->getKey())->toBe($runB->getKey()); + expect(OperationRun::query()->count())->toBe(1); +}); + +it('does not replace the initiator when deduping', function () { + $tenant = Tenant::factory()->create(); + $userA = User::factory()->create(); + $userB = User::factory()->create(); + + $service = new OperationRunService; + + $runA = $service->ensureRun($tenant, 'test.action', ['scope' => 'full'], $userA); + $runB = $service->ensureRun($tenant, 'test.action', ['scope' => 'full'], $userB); + + expect($runA->getKey())->toBe($runB->getKey()); + expect($runB->fresh()?->user_id)->toBe($userA->getKey()); + expect($runB->fresh()?->initiator_name)->toBe($userA->name); +}); + +it('hashes inputs deterministically regardless of key order', function () { + $tenant = Tenant::factory()->create(); + $service = new OperationRunService; + + $runA = $service->ensureRun($tenant, 'test.action', ['b' => 2, 'a' => 1]); + $runB = $service->ensureRun($tenant, 'test.action', ['a' => 1, 'b' => 2]); + + expect($runA->getKey())->toBe($runB->getKey()); +}); + +it('hashes list inputs deterministically regardless of list order', function () { + $tenant = Tenant::factory()->create(); + $service = new OperationRunService; + + $runA = $service->ensureRun($tenant, 'test.action', ['ids' => [2, 1]]); + $runB = $service->ensureRun($tenant, 'test.action', ['ids' => [1, 2]]); + + expect($runA->getKey())->toBe($runB->getKey()); +}); + +it('handles unique-index race collisions by returning the active run', function () { + $tenant = Tenant::factory()->create(); + $service = new OperationRunService; + + $fired = false; + + $dispatcher = OperationRun::getEventDispatcher(); + + OperationRun::creating(function (OperationRun $model) use (&$fired): void { + if ($fired) { + return; + } + + $fired = true; + + OperationRun::withoutEvents(function () use ($model): void { + OperationRun::query()->create([ + 'tenant_id' => $model->tenant_id, + 'user_id' => $model->user_id, + 'initiator_name' => $model->initiator_name, + 'type' => $model->type, + 'status' => $model->status, + 'outcome' => $model->outcome, + 'run_identity_hash' => $model->run_identity_hash, + 'context' => $model->context, + ]); + }); + }); + + try { + $run = $service->ensureRun($tenant, 'test.race', ['scope' => 'full']); + } finally { + OperationRun::flushEventListeners(); + OperationRun::setEventDispatcher($dispatcher); + } + + expect($run)->toBeInstanceOf(OperationRun::class); + expect(OperationRun::query()->where('tenant_id', $tenant->getKey())->where('type', 'test.race')->count()) + ->toBe(1); +}); + +it('creates a new run after the previous one completed', function () { + $tenant = Tenant::factory()->create(); + + $service = new OperationRunService; + + $runA = $service->ensureRun($tenant, 'test.action', ['scope' => 'full']); + $runA->update(['status' => 'completed']); + + $runB = $service->ensureRun($tenant, 'test.action', ['scope' => 'full']); + + expect($runA->getKey())->not->toBe($runB->getKey()); + expect(OperationRun::query()->count())->toBe(2); +}); + +it('updates run lifecycle fields and summaries', function () { + $tenant = Tenant::factory()->create(); + + $service = new OperationRunService; + + $run = $service->ensureRun($tenant, 'test.action', []); + + $service->updateRun($run, 'running'); + + $fresh = $run->fresh(); + expect($fresh?->status)->toBe('running'); + expect($fresh?->started_at)->not->toBeNull(); + + $service->updateRun($run, 'completed', 'succeeded', ['success' => 1]); + + $fresh = $run->fresh(); + expect($fresh?->status)->toBe('completed'); + expect($fresh?->outcome)->toBe('succeeded'); + expect($fresh?->completed_at)->not->toBeNull(); + expect($fresh?->summary_counts)->toBe(['success' => 1]); +}); + +it('sanitizes failure messages and redacts obvious secrets', function () { + $tenant = Tenant::factory()->create(); + $service = new OperationRunService; + + $run = $service->ensureRun($tenant, 'test.action', []); + + try { + throw new RuntimeException('Authorization: Bearer abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789'); + } catch (Throwable $e) { + $service->failRun($run, $e); + } + + $fresh = $run->fresh(); + expect($fresh?->status)->toBe('completed'); + expect($fresh?->outcome)->toBe('failed'); + expect($fresh?->failure_summary)->toBeArray(); + + $message = (string) (($fresh?->failure_summary[0]['message'] ?? '')); + expect($message)->not->toContain('abcdefghijklmnopqrstuvwxyz'); + expect($message)->toContain('[REDACTED]'); +}); diff --git a/tests/Feature/PolicySyncStartSurfaceTest.php b/tests/Feature/PolicySyncStartSurfaceTest.php new file mode 100644 index 0000000..c457e3e --- /dev/null +++ b/tests/Feature/PolicySyncStartSurfaceTest.php @@ -0,0 +1,181 @@ +actingAs($user); + + $tenant->makeCurrent(); + Filament::setTenant($tenant, true); + + Livewire::test(ListPolicies::class) + ->mountAction('sync') + ->callMountedAction() + ->assertHasNoActionErrors(); + + $requestedTypes = array_map( + static fn (array $typeConfig): string => (string) $typeConfig['type'], + config('tenantpilot.supported_policy_types', []) + ); + + sort($requestedTypes); + + $run = OperationRun::query() + ->where('tenant_id', $tenant->getKey()) + ->where('type', 'policy.sync') + ->latest('id') + ->first(); + + expect($run)->not->toBeNull(); + expect($run?->status)->toBe('queued'); + expect($run?->context)->toMatchArray([ + 'scope' => 'all', + 'types' => $requestedTypes, + ]); + + Queue::assertPushed(SyncPoliciesJob::class, function (SyncPoliciesJob $job) use ($tenant, $run, $requestedTypes): bool { + return $job->tenantId === (int) $tenant->getKey() + && $job->types === $requestedTypes + && $job->operationRun instanceof OperationRun + && (int) $job->operationRun->getKey() === (int) $run?->getKey(); + }); +}); + +it('reuses an active policy sync run and does not enqueue twice', function () { + Queue::fake(); + + [$user, $tenant] = createUserWithTenant(role: 'owner'); + $this->actingAs($user); + + $tenant->makeCurrent(); + Filament::setTenant($tenant, true); + + Livewire::test(ListPolicies::class) + ->mountAction('sync') + ->callMountedAction(); + + Livewire::test(ListPolicies::class) + ->mountAction('sync') + ->callMountedAction(); + + Queue::assertPushed(SyncPoliciesJob::class, 1); + + expect(OperationRun::query() + ->where('tenant_id', $tenant->getKey()) + ->where('type', 'policy.sync') + ->count())->toBe(1); +}); + +it('queues row policy sync and creates a canonical operation run (no Graph calls in request)', function () { + Queue::fake(); + bindFailHardGraphClient(); + + [$user, $tenant] = createUserWithTenant(role: 'owner'); + $this->actingAs($user); + + $tenant->makeCurrent(); + Filament::setTenant($tenant, true); + + $policy = Policy::factory()->create([ + 'tenant_id' => $tenant->getKey(), + 'ignored_at' => null, + ]); + + Livewire::test(ListPolicies::class) + ->callTableAction('sync', $policy) + ->assertHasNoTableActionErrors(); + + $run = OperationRun::query() + ->where('tenant_id', $tenant->getKey()) + ->where('type', 'policy.sync_one') + ->latest('id') + ->first(); + + expect($run)->not->toBeNull(); + expect($run?->status)->toBe('queued'); + expect($run?->context)->toMatchArray([ + 'scope' => 'one', + 'policy_id' => (int) $policy->getKey(), + ]); + + Queue::assertPushed(SyncPoliciesJob::class, function (SyncPoliciesJob $job) use ($tenant, $run, $policy): bool { + return $job->tenantId === (int) $tenant->getKey() + && $job->types === null + && $job->policyIds === [(int) $policy->getKey()] + && $job->operationRun instanceof OperationRun + && (int) $job->operationRun->getKey() === (int) $run?->getKey(); + }); +}); + +it('queues bulk policy sync and creates a canonical operation run (no Graph calls in request)', function () { + Queue::fake(); + bindFailHardGraphClient(); + + [$user, $tenant] = createUserWithTenant(role: 'owner'); + $this->actingAs($user); + + $tenant->makeCurrent(); + Filament::setTenant($tenant, true); + + $policies = Policy::factory()->count(2)->create([ + 'tenant_id' => $tenant->getKey(), + 'ignored_at' => null, + ]); + + Livewire::test(ListPolicies::class) + ->callTableBulkAction('bulk_sync', $policies) + ->assertHasNoTableBulkActionErrors(); + + $selectedIds = $policies + ->pluck('id') + ->map(static fn ($id): int => (int) $id) + ->sort() + ->values() + ->all(); + + $run = OperationRun::query() + ->where('tenant_id', $tenant->getKey()) + ->where('type', 'policy.sync') + ->latest('id') + ->first(); + + expect($run)->not->toBeNull(); + expect($run?->status)->toBe('queued'); + expect($run?->context)->toMatchArray([ + 'scope' => 'subset', + 'policy_ids' => $selectedIds, + ]); + + Queue::assertPushed(SyncPoliciesJob::class, function (SyncPoliciesJob $job) use ($tenant, $run, $selectedIds): bool { + return $job->tenantId === (int) $tenant->getKey() + && $job->types === null + && $job->policyIds === $selectedIds + && $job->operationRun instanceof OperationRun + && (int) $job->operationRun->getKey() === (int) $run?->getKey(); + }); +}); + +it('hides policy sync start action for readonly users', function () { + Queue::fake(); + + [$user, $tenant] = createUserWithTenant(role: 'readonly'); + $this->actingAs($user); + + $tenant->makeCurrent(); + Filament::setTenant($tenant, true); + + Livewire::test(ListPolicies::class) + ->assertActionHidden('sync'); + + Queue::assertNothingPushed(); +}); diff --git a/tests/Feature/RestoreAdapterTest.php b/tests/Feature/RestoreAdapterTest.php new file mode 100644 index 0000000..5fa645f --- /dev/null +++ b/tests/Feature/RestoreAdapterTest.php @@ -0,0 +1,93 @@ +create([ + 'status' => RestoreRunStatus::Checked->value, + ]); + + expect(OperationRun::query() + ->where('tenant_id', $restoreRun->tenant_id) + ->where('type', 'restore.execute') + ->count())->toBe(0); + + $restoreRun->update(['status' => RestoreRunStatus::Previewed->value]); + + $opRun = OperationRun::query() + ->where('tenant_id', $restoreRun->tenant_id) + ->where('type', 'restore.execute') + ->latest('id') + ->first(); + + expect($opRun)->not->toBeNull(); + expect($opRun?->status)->toBe('queued'); + expect($opRun?->outcome)->toBe('pending'); + expect($opRun?->context)->toMatchArray([ + 'restore_run_id' => (int) $restoreRun->getKey(), + 'backup_set_id' => (int) $restoreRun->backup_set_id, + 'is_dry_run' => (bool) $restoreRun->is_dry_run, + ]); +}); + +it('updates the operation run when restore completes', function () { + $restoreRun = RestoreRun::factory()->create([ + 'status' => RestoreRunStatus::Previewed->value, + ]); + + $opRun = OperationRun::query() + ->where('tenant_id', $restoreRun->tenant_id) + ->where('type', 'restore.execute') + ->latest('id') + ->first(); + + expect($opRun)->not->toBeNull(); + expect($opRun?->status)->toBe('queued'); + + $restoreRun->update([ + 'status' => RestoreRunStatus::Completed->value, + 'results' => [ + 'assignment_outcomes' => [ + ['status' => 'success'], + ['status' => 'failed'], + ['status' => 'skipped'], + ], + ], + ]); + + $opRun->refresh(); + + expect($opRun->status)->toBe('completed'); + expect($opRun->outcome)->toBe('succeeded'); + expect($opRun->summary_counts)->toMatchArray([ + 'assignments_success' => 1, + 'assignments_failed' => 1, + 'assignments_skipped' => 1, + ]); + expect($opRun->completed_at)->not->toBeNull(); +}); + +it('maps cancelled restore runs to failed outcome (cancelled is reserved)', function () { + $restoreRun = RestoreRun::factory()->create([ + 'status' => RestoreRunStatus::Previewed->value, + ]); + + $opRun = OperationRun::query() + ->where('tenant_id', $restoreRun->tenant_id) + ->where('type', 'restore.execute') + ->latest('id') + ->first(); + + expect($opRun)->not->toBeNull(); + + $restoreRun->update(['status' => RestoreRunStatus::Cancelled->value]); + + $opRun->refresh(); + + expect($opRun->status)->toBe('completed'); + expect($opRun->outcome)->toBe('failed'); + expect($opRun->failure_summary)->toBeArray(); + expect($opRun->failure_summary[0]['code'] ?? null)->toBe('restore.cancelled'); +}); diff --git a/tests/Feature/Scheduling/PruneOldOperationRunsScheduleTest.php b/tests/Feature/Scheduling/PruneOldOperationRunsScheduleTest.php new file mode 100644 index 0000000..370df03 --- /dev/null +++ b/tests/Feature/Scheduling/PruneOldOperationRunsScheduleTest.php @@ -0,0 +1,15 @@ +events()) + ->first(fn ($event) => ($event->description ?? null) === PruneOldOperationRunsJob::class); + + expect($event)->not->toBeNull(); + expect($event->withoutOverlapping)->toBeTrue(); +}); diff --git a/tests/Feature/TrackOperationRunMiddlewareTest.php b/tests/Feature/TrackOperationRunMiddlewareTest.php new file mode 100644 index 0000000..20e4c6e --- /dev/null +++ b/tests/Feature/TrackOperationRunMiddlewareTest.php @@ -0,0 +1,43 @@ +ensureRun( + tenant: $tenant, + type: 'test.release', + inputs: ['foo' => 'bar'], + initiator: $user, + ); + + $job = new class($operationRun) implements ShouldQueue + { + use Dispatchable, InteractsWithQueue, Queueable, SerializesModels; + + public function __construct(public OperationRun $operationRun) + { + $this->withFakeQueueInteractions(); + } + }; + + $middleware = new TrackOperationRun; + + $middleware->handle($job, function ($job): void { + $job->release(60); + }); + + $operationRun->refresh(); + expect($operationRun->status)->toBe('running'); + expect($operationRun->outcome)->toBe('pending'); +});