Compare commits
2 Commits
dev
...
231-findin
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8552d7fe9d | ||
|
|
807578dd9c |
14
.github/agents/copilot-instructions.md
vendored
14
.github/agents/copilot-instructions.md
vendored
@ -240,14 +240,6 @@ ## Active Technologies
|
||||
- Filesystem only (`specs/226-astrodeck-inventory-planning/*`) (226-astrodeck-inventory-planning)
|
||||
- PHP 8.4.15, Laravel 12, Filament v5, Livewire v4, Blade + `App\Models\Finding`, `App\Filament\Resources\FindingResource`, `App\Services\Findings\FindingWorkflowService`, `App\Services\Baselines\BaselineAutoCloseService`, `App\Services\EntraAdminRoles\EntraAdminRolesFindingGenerator`, `App\Services\PermissionPosture\PermissionPostureFindingGenerator`, `App\Jobs\CompareBaselineToTenantJob`, `App\Filament\Pages\Reviews\ReviewRegister`, `App\Filament\Resources\TenantReviewResource`, `BadgeCatalog`, `BadgeRenderer`, `AuditLog` metadata via `AuditLogger` (231-finding-outcome-taxonomy)
|
||||
- PostgreSQL via existing `findings`, `finding_exceptions`, `tenant_reviews`, `stored_reports`, and audit-log tables; no schema changes planned (231-finding-outcome-taxonomy)
|
||||
- PHP 8.4.15, Laravel 12, Filament v5, Livewire v4 + Filament Resources/Pages/Widgets, Pest v4, `App\Support\OperationRunLinks`, `App\Support\System\SystemOperationRunLinks`, `App\Support\Navigation\CanonicalNavigationContext`, `App\Support\Navigation\RelatedNavigationResolver`, existing workspace and tenant authorization helpers (232-operation-run-link-contract)
|
||||
- PostgreSQL-backed existing `operation_runs`, `tenants`, and `workspaces` records plus current session-backed canonical navigation state; no new persistence (232-operation-run-link-contract)
|
||||
- PHP 8.4.15, Laravel 12, Filament v5, Livewire v4 + Filament widgets/resources/pages, Pest v4, `App\Models\OperationRun`, `App\Support\Operations\OperationRunFreshnessState`, `App\Services\Operations\OperationLifecycleReconciler`, `App\Support\OpsUx\OperationUxPresenter`, `App\Support\OpsUx\ActiveRuns`, `App\Support\Badges\BadgeCatalog` / `BadgeRenderer`, `App\Support\Workspaces\WorkspaceOverviewBuilder`, `App\Support\OperationRunLinks` (233-stale-run-visibility)
|
||||
- Existing PostgreSQL `operation_runs` records and current session/query-backed monitoring navigation state; no new persistence (233-stale-run-visibility)
|
||||
- PHP 8.4.15, Laravel 12, Filament v5, Livewire v4 + `App\Models\BaselineProfile`, `App\Support\Baselines\BaselineProfileStatus`, `App\Support\Badges\BadgeCatalog`, `App\Support\Badges\BadgeDomain`, `Database\Factories\TenantFactory`, `App\Console\Commands\SeedBackupHealthBrowserFixture`, existing tenant-truth and baseline-profile Pest tests (234-dead-transitional-residue)
|
||||
- Existing PostgreSQL `baseline_profiles` and `tenants` tables; no new persistence and no schema migration in this slice (234-dead-transitional-residue)
|
||||
- PHP 8.4.15, Laravel 12, Filament v5, Livewire v4 + `BaselineCaptureService`, `CaptureBaselineSnapshotJob`, `BaselineReasonCodes`, `BaselineCompareStats`, `ReasonTranslator`, `GovernanceRunDiagnosticSummaryBuilder`, `OperationRunService`, `BaselineProfile`, `BaselineSnapshot`, `OperationRunOutcome`, existing Filament capture/compare surfaces (235-baseline-capture-truth)
|
||||
- Existing PostgreSQL tables only; no new table or schema migration is planned in the mainline slice (235-baseline-capture-truth)
|
||||
|
||||
- PHP 8.4.15 (feat/005-bulk-operations)
|
||||
|
||||
@ -282,9 +274,9 @@ ## Code Style
|
||||
PHP 8.4.15: Follow standard conventions
|
||||
|
||||
## Recent Changes
|
||||
- 235-baseline-capture-truth: Added PHP 8.4.15, Laravel 12, Filament v5, Livewire v4 + `BaselineCaptureService`, `CaptureBaselineSnapshotJob`, `BaselineReasonCodes`, `BaselineCompareStats`, `ReasonTranslator`, `GovernanceRunDiagnosticSummaryBuilder`, `OperationRunService`, `BaselineProfile`, `BaselineSnapshot`, `OperationRunOutcome`, existing Filament capture/compare surfaces
|
||||
- 234-dead-transitional-residue: Added PHP 8.4.15, Laravel 12, Filament v5, Livewire v4 + `App\Models\BaselineProfile`, `App\Support\Baselines\BaselineProfileStatus`, `App\Support\Badges\BadgeCatalog`, `App\Support\Badges\BadgeDomain`, `Database\Factories\TenantFactory`, `App\Console\Commands\SeedBackupHealthBrowserFixture`, existing tenant-truth and baseline-profile Pest tests
|
||||
- 233-stale-run-visibility: Added PHP 8.4.15, Laravel 12, Filament v5, Livewire v4 + Filament widgets/resources/pages, Pest v4, `App\Models\OperationRun`, `App\Support\Operations\OperationRunFreshnessState`, `App\Services\Operations\OperationLifecycleReconciler`, `App\Support\OpsUx\OperationUxPresenter`, `App\Support\OpsUx\ActiveRuns`, `App\Support\Badges\BadgeCatalog` / `BadgeRenderer`, `App\Support\Workspaces\WorkspaceOverviewBuilder`, `App\Support\OperationRunLinks`
|
||||
- 231-finding-outcome-taxonomy: Added PHP 8.4.15, Laravel 12, Filament v5, Livewire v4, Blade + `App\Models\Finding`, `App\Filament\Resources\FindingResource`, `App\Services\Findings\FindingWorkflowService`, `App\Services\Baselines\BaselineAutoCloseService`, `App\Services\EntraAdminRoles\EntraAdminRolesFindingGenerator`, `App\Services\PermissionPosture\PermissionPostureFindingGenerator`, `App\Jobs\CompareBaselineToTenantJob`, `App\Filament\Pages\Reviews\ReviewRegister`, `App\Filament\Resources\TenantReviewResource`, `BadgeCatalog`, `BadgeRenderer`, `AuditLog` metadata via `AuditLogger`
|
||||
- 226-astrodeck-inventory-planning: Added Markdown artifacts + Astro 6.0.0 + TypeScript 5.9 context for source discovery + Repository spec workflow (`.specify`), Astro website source tree under `apps/website/src`, existing component taxonomy (`primitives`, `content`, `sections`, `layout`)
|
||||
- 225-assignment-hygiene: Added PHP 8.4.15, Laravel 12, Filament v5, Livewire v4, Blade + `Finding`, `FindingResource`, `MyFindingsInbox`, `FindingsIntakeQueue`, `WorkspaceOverviewBuilder`, `EnsureFilamentTenantSelected`, `FindingWorkflowService`, `AuditLog`, `TenantMembership`, Filament page and table primitives
|
||||
<!-- MANUAL ADDITIONS START -->
|
||||
|
||||
### Pre-production compatibility check
|
||||
|
||||
@ -1,28 +1,32 @@
|
||||
<!--
|
||||
Sync Impact Report
|
||||
|
||||
- Version change: 2.8.0 -> 2.9.0
|
||||
- Modified principles:
|
||||
- Added provider-boundary guardrail set under First Provider Is Not
|
||||
Platform Core (PROV-001 with sub-rules PROV-002 through PROV-005)
|
||||
- Expanded Governance review expectations for provider-owned vs
|
||||
platform-core boundaries
|
||||
- Version change: 2.7.0 -> 2.8.0
|
||||
- Modified principles: None
|
||||
- Added sections:
|
||||
- First Provider Is Not Platform Core (PROV-001): keeps Microsoft as
|
||||
the current first provider without allowing provider-specific
|
||||
semantics to silently become platform-core truth; requires explicit
|
||||
review of provider-owned vs platform-core seams and prefers bounded
|
||||
extraction over speculative multi-provider frameworks
|
||||
- Pre-Production Lean Doctrine (LEAN-001): forbids legacy aliases,
|
||||
migration shims, dual-write logic, and compatibility fixtures in a
|
||||
pre-production codebase; includes AI-agent verification checklist,
|
||||
review rule, and explicit exit condition at first production deploy
|
||||
- Shared Pattern First For Cross-Cutting Interaction Classes
|
||||
(XCUT-001): requires shared contracts/presenters/builders for
|
||||
notifications, status messaging, action links, dashboard signals,
|
||||
navigation, and similar interaction classes before any local
|
||||
domain-specific variant is allowed
|
||||
- Removed sections: None
|
||||
- Templates requiring updates:
|
||||
- .specify/templates/spec-template.md: add provider-boundary platform
|
||||
core check ✅
|
||||
- .specify/templates/plan-template.md: add provider-boundary planning
|
||||
fields + constitution check ✅
|
||||
- .specify/templates/tasks-template.md: add provider-boundary task
|
||||
- .specify/templates/spec-template.md: added "Compatibility posture"
|
||||
default block ✅
|
||||
- .specify/templates/spec-template.md: add cross-cutting shared-pattern
|
||||
reuse block ✅
|
||||
- .specify/templates/plan-template.md: add shared pattern and system
|
||||
fit section ✅
|
||||
- .specify/templates/tasks-template.md: add cross-cutting reuse task
|
||||
requirements ✅
|
||||
- .specify/templates/checklist-template.md: add provider-boundary
|
||||
- .specify/templates/checklist-template.md: add shared-pattern reuse
|
||||
review checks ✅
|
||||
- .github/agents/copilot-instructions.md: added "Pre-production
|
||||
compatibility check" agent checklist ✅
|
||||
- Commands checked:
|
||||
- N/A `.specify/templates/commands/*.md` directory is not present
|
||||
- Follow-up TODOs: None
|
||||
@ -62,15 +66,6 @@ ### No Premature Abstraction (ABSTR-001)
|
||||
- Test convenience alone is not sufficient justification for a new abstraction.
|
||||
- Narrow abstractions are allowed when required for security, tenant isolation, auditability, compliance evidence, or queue/job execution correctness.
|
||||
|
||||
### First Provider Is Not Platform Core (PROV-001)
|
||||
- Microsoft is the current first provider, not the platform core.
|
||||
- Shared platform-owned contracts, taxonomies, identifiers, compare semantics, and operator vocabulary MUST NOT silently become Microsoft-shaped truth just because Microsoft is the only provider today.
|
||||
- Shared platform-owned boundaries SHOULD prefer neutral core terms such as `provider`, `connection`, `target scope`, `governed subject`, and `operation` unless the feature is intentionally provider-owned and explicitly bounded.
|
||||
- Shared core terms at shared boundaries (PROV-002): if a boundary is reused across multiple domains, features, or workflows, the default is neutral platform language rather than provider-specific labels or semantics.
|
||||
- No accidental deepening of provider coupling (PROV-003): a feature MAY retain provider-specific semantics at a provider-owned seam, but it MUST NOT spread those semantics deeper into platform-core contracts, shared persistence truth, shared taxonomies, or shared UI language without proving that the narrower current-release truth genuinely requires it.
|
||||
- Shared-boundary review is mandatory (PROV-004): when a feature touches a shared provider/platform seam, the spec, plan, and review MUST state whether the seam is provider-owned or platform-core, what provider-specific semantics remain, and why that choice is the narrowest correct implementation now.
|
||||
- Prefer bounded extraction over premature generalization (PROV-005): if an existing hotspot is too Microsoft-specific, the default remedy is a bounded normalization or extraction of that hotspot, not a speculative multi-provider framework with unused extension points.
|
||||
|
||||
### No New Persisted Truth Without Source-of-Truth Need (PERSIST-001)
|
||||
- New tables, persisted entities, or stored artifacts MUST represent real product truth that survives independently of the originating request, run, or view.
|
||||
- Persisted storage is justified only when at least one of these is true: it is a source of truth, has an independent lifecycle, must be audited independently, must outlive its originating run/request, is required for permissions/routing/compliance evidence, or is required for stable operator workflows over time.
|
||||
@ -1613,7 +1608,6 @@ ### Scope, Compliance, and Review Expectations
|
||||
- Specs and PRs that introduce new persisted truth, abstractions, states, DTO/presenter layers, or taxonomies MUST include the proportionality review required by BLOAT-001.
|
||||
- Runtime-changing or test-affecting specs and PRs MUST include testing/lane/runtime impact covering actual test-purpose classification, affected lanes, fixture/helper/factory/seed/context cost changes, any heavy-family expansion, expected budget/baseline/trend effect, escalation decisions, and the minimal validation commands.
|
||||
- Specs, plans, task lists, and review checklists MUST surface the test-governance questions needed to catch lane drift, hidden defaults, and runtime-cost escalation before merge.
|
||||
- Specs and PRs that touch shared provider/platform seams MUST classify the touched boundary as provider-owned or platform-core, keep provider-specific semantics out of platform-core contracts and vocabulary unless explicitly justified, and record whether any remaining hotspot is resolved in-feature or escalated as a follow-up spec.
|
||||
- Specs and PRs that change operator-facing surfaces MUST classify each
|
||||
affected surface under DECIDE-001 and justify any new Primary
|
||||
Decision Surface or workflow-first navigation change.
|
||||
@ -1631,4 +1625,4 @@ ### Versioning Policy (SemVer)
|
||||
- **MINOR**: new principle/section or materially expanded guidance.
|
||||
- **MAJOR**: removing/redefining principles in a backward-incompatible way.
|
||||
|
||||
**Version**: 2.9.0 | **Ratified**: 2026-01-03 | **Last Amended**: 2026-04-23
|
||||
**Version**: 2.7.0 | **Ratified**: 2026-01-03 | **Last Amended**: 2025-07-19
|
||||
|
||||
@ -32,23 +32,18 @@ ## Shared Pattern Reuse
|
||||
- [ ] CHK008 The change extends the shared path where it is sufficient, or the deviation is explicitly documented with product reason, preserved consistency, ownership cost, and spread-control.
|
||||
- [ ] CHK009 The change does not create a parallel operator-facing UX language for the same interaction class unless a bounded exception is recorded.
|
||||
|
||||
## Provider Boundary And Vocabulary
|
||||
|
||||
- [ ] CHK010 The change states whether any touched shared seam is provider-owned, platform-core, or mixed, and provider-specific semantics do not silently spread into platform-core contracts, taxonomy, identifiers, compare semantics, or operator vocabulary.
|
||||
- [ ] CHK011 Any retained provider-specific shared boundary is justified as a bounded current-release exception or an explicit follow-up-spec need instead of becoming permanent platform truth by default.
|
||||
|
||||
## Signals, Exceptions, And Test Depth
|
||||
|
||||
- [ ] CHK012 Any triggered repository signal is classified with one handling mode: `hard-stop-candidate`, `review-mandatory`, `exception-required`, or `report-only`.
|
||||
- [ ] CHK013 Any deviation from default rules includes a bounded exception record naming the broken rule, product reason, standardized parts, spread-control rule, and the active feature PR close-out entry.
|
||||
- [ ] CHK014 The required surface test profile is explicit: `shared-detail-family`, `monitoring-state-page`, `global-context-shell`, `exception-coded-surface`, or `standard-native-filament`.
|
||||
- [ ] CHK015 The chosen test family/lane and any manual smoke are the narrowest honest proof for the declared surface class, and `standard-native-filament` relief is used when no special contract exists.
|
||||
- [ ] CHK010 Any triggered repository signal is classified with one handling mode: `hard-stop-candidate`, `review-mandatory`, `exception-required`, or `report-only`.
|
||||
- [ ] CHK011 Any deviation from default rules includes a bounded exception record naming the broken rule, product reason, standardized parts, spread-control rule, and the active feature PR close-out entry.
|
||||
- [ ] CHK012 The required surface test profile is explicit: `shared-detail-family`, `monitoring-state-page`, `global-context-shell`, `exception-coded-surface`, or `standard-native-filament`.
|
||||
- [ ] CHK013 The chosen test family/lane and any manual smoke are the narrowest honest proof for the declared surface class, and `standard-native-filament` relief is used when no special contract exists.
|
||||
|
||||
## Review Outcome
|
||||
|
||||
- [ ] CHK016 One review outcome class is chosen: `blocker`, `strong-warning`, `documentation-required-exception`, or `acceptable-special-case`.
|
||||
- [ ] CHK017 One workflow outcome is chosen: `keep`, `split`, `document-in-feature`, `follow-up-spec`, or `reject-or-split`.
|
||||
- [ ] CHK018 The final note location is explicit: the active feature PR close-out entry for guarded work, or a concise `N/A` note for low-impact changes.
|
||||
- [ ] CHK014 One review outcome class is chosen: `blocker`, `strong-warning`, `documentation-required-exception`, or `acceptable-special-case`.
|
||||
- [ ] CHK015 One workflow outcome is chosen: `keep`, `split`, `document-in-feature`, `follow-up-spec`, or `reject-or-split`.
|
||||
- [ ] CHK016 The final note location is explicit: the active feature PR close-out entry for guarded work, or a concise `N/A` note for low-impact changes.
|
||||
|
||||
## Notes
|
||||
|
||||
|
||||
@ -54,17 +54,6 @@ ## Shared Pattern & System Fit
|
||||
- **Why the existing abstraction was sufficient or insufficient**: [Short explanation tied to current-release truth]
|
||||
- **Bounded deviation / spread control**: [none / describe the exception boundary and containment rule]
|
||||
|
||||
## Provider Boundary & Portability Fit
|
||||
|
||||
> **Fill when the feature touches shared provider/platform seams, identity scope, governed-subject taxonomy, compare strategy selection, provider connection descriptors, or operator vocabulary that may leak provider-specific semantics into platform-core truth. Docs-only or template-only work may use concise `N/A`.**
|
||||
|
||||
- **Shared provider/platform boundary touched?**: [yes / no / N/A]
|
||||
- **Provider-owned seams**: [List or `N/A`]
|
||||
- **Platform-core seams**: [List or `N/A`]
|
||||
- **Neutral platform terms / contracts preserved**: [List or `N/A`]
|
||||
- **Retained provider-specific semantics and why**: [none / short explanation]
|
||||
- **Bounded extraction or follow-up path**: [none / document-in-feature / follow-up-spec / N/A]
|
||||
|
||||
## Constitution Check
|
||||
|
||||
*GATE: Must pass before Phase 0 research. Re-check after Phase 1 design.*
|
||||
@ -93,7 +82,6 @@ ## Constitution Check
|
||||
- Behavioral state (STATE-001): new states/statuses/reason codes change behavior, routing, permissions, lifecycle, audit, retention, or retry handling; presentation-only distinctions stay derived
|
||||
- UI semantics (UI-SEM-001): avoid turning badges, explanation text, trust/confidence labels, or detail summaries into mandatory interpretation frameworks; prefer direct domain-to-UI mapping
|
||||
- Shared pattern first (XCUT-001): cross-cutting interaction classes reuse existing shared contracts/presenters/builders/renderers first; any deviation is explicit, bounded, and justified against current-release truth
|
||||
- Provider boundary (PROV-001): shared provider/platform seams are classified as provider-owned vs platform-core; provider-specific semantics stay out of platform-core contracts, taxonomy, identifiers, compare semantics, and operator vocabulary unless explicitly justified; bounded extraction beats speculative multi-provider frameworks
|
||||
- V1 explicitness / few layers (V1-EXP-001, LAYER-001): prefer direct implementation, local mappings, and small helpers; any new layer replaces an old one or proves the old one cannot serve
|
||||
- Spec discipline / bloat check (SPEC-DISC-001, BLOAT-001): related semantic changes are grouped coherently, and any new enum, DTO/presenter, persisted entity, interface/registry/resolver, or taxonomy includes a proportionality review covering operator problem, insufficiency, narrowness, ownership cost, rejected alternative, and whether it is current-release truth
|
||||
- Badge semantics (BADGE-001): status-like badges use `BadgeCatalog` / `BadgeRenderer`; no ad-hoc mappings; new values include tests
|
||||
|
||||
@ -47,16 +47,6 @@ ## Cross-Cutting / Shared Pattern Reuse *(mandatory when the feature touches not
|
||||
- **Consistency impact**: [What must stay aligned across interaction structure, copy, status semantics, actions, and deep links]
|
||||
- **Review focus**: [What reviewers must verify to prevent parallel local patterns]
|
||||
|
||||
## Provider Boundary / Platform Core Check *(mandatory when the feature changes shared provider/platform seams, identity scope, governed-subject taxonomy, compare strategy selection, provider connection descriptors, or operator vocabulary that may leak provider-specific semantics into platform-core truth; otherwise write `N/A - no shared provider/platform boundary touched`)*
|
||||
|
||||
- **Shared provider/platform boundary touched?**: [yes/no]
|
||||
- **Boundary classification**: [provider-owned / platform-core / mixed / N/A]
|
||||
- **Seams affected**: [contracts, models, taxonomies, query keys, labels, filters, compare strategy, etc.]
|
||||
- **Neutral platform terms preserved or introduced**: [List them or `N/A`]
|
||||
- **Provider-specific semantics retained and why**: [none / bounded current-release necessity]
|
||||
- **Why this does not deepen provider coupling accidentally**: [Short explanation]
|
||||
- **Follow-up path**: [none / document-in-feature / follow-up-spec]
|
||||
|
||||
## UI / Surface Guardrail Impact *(mandatory when operator-facing surfaces are changed; otherwise write `N/A`)*
|
||||
|
||||
Use this section to classify UI and surface risk once. If the feature does
|
||||
@ -244,13 +234,6 @@ ## Requirements *(mandatory)*
|
||||
- record any allowed deviation, the consistency it must preserve, and its ownership/spread-control cost,
|
||||
- and make the reviewer focus explicit so parallel local UX paths do not appear silently.
|
||||
|
||||
**Constitution alignment (PROV-001):** If this feature touches a shared provider/platform seam, the spec MUST:
|
||||
- classify each touched seam as provider-owned or platform-core,
|
||||
- keep provider-specific semantics out of platform-core contracts, taxonomies, identifiers, compare semantics, and operator vocabulary unless explicitly justified,
|
||||
- name the neutral platform terms or shared contracts being preserved,
|
||||
- explain why any retained provider-specific semantics are the narrowest current-release truth,
|
||||
- and state whether the remaining hotspot is resolved in-feature or escalated as a follow-up spec.
|
||||
|
||||
**Constitution alignment (TEST-GOV-001):** If this feature changes runtime behavior or tests, the spec MUST describe:
|
||||
- the actual test-purpose classification (`Unit`, `Feature`, `Heavy-Governance`, or `Browser`) and why that classification matches the real proving purpose,
|
||||
- the affected validation lane(s) and why they are the narrowest sufficient proof,
|
||||
|
||||
@ -51,11 +51,6 @@ # Tasks: [FEATURE NAME]
|
||||
- extending the shared path when it is sufficient for current-release truth,
|
||||
- or recording a bounded exception task that documents why the shared path is insufficient, what consistency must still be preserved, and how spread is controlled,
|
||||
- and ensuring reviewer proof covers whether the feature converged on the shared path or knowingly introduced a bounded exception.
|
||||
**Provider Boundary / Platform Core (PROV-001)**: If this feature touches shared provider/platform seams, tasks MUST include:
|
||||
- classifying each touched seam as provider-owned or platform-core,
|
||||
- preventing provider-specific semantics from spreading into platform-core contracts, persistence truth, taxonomies, compare semantics, or operator vocabulary unless explicitly justified,
|
||||
- implementing bounded normalization or extraction where a current hotspot is too provider-shaped, rather than introducing speculative multi-provider frameworks,
|
||||
- and recording `document-in-feature` or `follow-up-spec` when a bounded provider-specific hotspot remains.
|
||||
**UI / Surface Guardrails**: If this feature adds or changes operator-facing surfaces or the workflow that governs them, tasks MUST include:
|
||||
- carrying forward the spec's native/custom classification, shared-family relevance, state-layer ownership, and exception need into implementation work without renaming the same decision,
|
||||
- classifying any triggered repository signals with one handling mode (`hard-stop-candidate`, `review-mandatory`, `exception-required`, or `report-only`),
|
||||
|
||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@ -1 +1 @@
|
||||
{"name":"color-name","version":"1.1.4","requiresBuild":false,"files":{"package.json":{"checkedAt":1776976148151,"integrity":"sha512-E5CrPeTNIaZAftwqMJpkT8PDNamUJUrubHLTZ6Rjn3l9RvJKSLw6MGXT6SAcRHV3ltLOSTOa1HvkQ7/GUOoaHw==","mode":438,"size":607},"index.js":{"checkedAt":1776976148156,"integrity":"sha512-nek+57RYqda5dmQCKQmtJafLicLP3Y7hmqLhJlZrenqTCyQUOip2+D2/8Z8aZ7CnHek+irJIcgwu4kM5boaUUQ==","mode":438,"size":4617},"LICENSE":{"checkedAt":1776976148162,"integrity":"sha512-/B1lNSwRTHWUyb7fW+QyujnUJv6vUL+PfFLTJ4EyPIS/yaaFMa77VYyX6+RucS4dNdhguh4aarSLSnm4lAklQA==","mode":438,"size":1085},"README.md":{"checkedAt":1776976148168,"integrity":"sha512-/hmGUPmp0gXgx/Ov5oGW6DAU3c4h4aLMa/bE1TkpZHPU7dCx5JFS9hoYM4/+919EWCaPtBhWzK+6pG/6xdx+Ng==","mode":438,"size":384}}}
|
||||
{"name":"color-name","version":"1.1.4","requiresBuild":false,"files":{"package.json":{"checkedAt":1776593337482,"integrity":"sha512-E5CrPeTNIaZAftwqMJpkT8PDNamUJUrubHLTZ6Rjn3l9RvJKSLw6MGXT6SAcRHV3ltLOSTOa1HvkQ7/GUOoaHw==","mode":438,"size":607},"index.js":{"checkedAt":1776593337489,"integrity":"sha512-nek+57RYqda5dmQCKQmtJafLicLP3Y7hmqLhJlZrenqTCyQUOip2+D2/8Z8aZ7CnHek+irJIcgwu4kM5boaUUQ==","mode":438,"size":4617},"LICENSE":{"checkedAt":1776593337495,"integrity":"sha512-/B1lNSwRTHWUyb7fW+QyujnUJv6vUL+PfFLTJ4EyPIS/yaaFMa77VYyX6+RucS4dNdhguh4aarSLSnm4lAklQA==","mode":438,"size":1085},"README.md":{"checkedAt":1776593337500,"integrity":"sha512-/hmGUPmp0gXgx/Ov5oGW6DAU3c4h4aLMa/bE1TkpZHPU7dCx5JFS9hoYM4/+919EWCaPtBhWzK+6pG/6xdx+Ng==","mode":438,"size":384}}}
|
||||
@ -1 +1 @@
|
||||
{"name":"@types/estree","version":"1.0.8","requiresBuild":false,"files":{"LICENSE":{"checkedAt":1776976148127,"integrity":"sha512-HQaIQk9pwOcyKutyDk4o2a87WnotwYuLGYFW43emGm4FvIJFKPyg+OYaw5sTegKAKf+C5SKa1ACjzCLivbaHrQ==","mode":420,"size":1141},"README.md":{"checkedAt":1776976148139,"integrity":"sha512-alZQw4vOCWtDJlTmYSm+aEvD0weTLtGERCy5tNbpyvPI5F2j9hEWxHuUdwL+TZU2Nhdx7EGRhitAiv0xuSxaeg==","mode":420,"size":458},"flow.d.ts":{"checkedAt":1776976148143,"integrity":"sha512-f3OqA/2H/A62ZLT0qAZlUCUAiI89dMFcY+XrAU08dNgwHhXSQmFeMc7w/Ee7RE8tHU5RXFoQazarmCUsnCvXxg==","mode":420,"size":4801},"index.d.ts":{"checkedAt":1776976148144,"integrity":"sha512-YwR3YirWettZcjZgr7aNimg/ibEuP+6JMqAvL+cT6ubq2ctYKL9Xv+PgBssGCPES01PG5zKTHSvhShXCjXOrDg==","mode":420,"size":18944},"package.json":{"checkedAt":1776976148144,"integrity":"sha512-KaEBTHEFL2oVUvCrjSJR/H812XIaeRGbSZFP8DBb2Hon+IQwND0zz7oRvrXTm2AzzjneqH+pkB2Lusw29yJ/WA==","mode":420,"size":829}}}
|
||||
{"name":"@types/estree","version":"1.0.8","requiresBuild":false,"files":{"LICENSE":{"checkedAt":1776593336106,"integrity":"sha512-HQaIQk9pwOcyKutyDk4o2a87WnotwYuLGYFW43emGm4FvIJFKPyg+OYaw5sTegKAKf+C5SKa1ACjzCLivbaHrQ==","mode":420,"size":1141},"README.md":{"checkedAt":1776593336125,"integrity":"sha512-alZQw4vOCWtDJlTmYSm+aEvD0weTLtGERCy5tNbpyvPI5F2j9hEWxHuUdwL+TZU2Nhdx7EGRhitAiv0xuSxaeg==","mode":420,"size":458},"flow.d.ts":{"checkedAt":1776593336132,"integrity":"sha512-f3OqA/2H/A62ZLT0qAZlUCUAiI89dMFcY+XrAU08dNgwHhXSQmFeMc7w/Ee7RE8tHU5RXFoQazarmCUsnCvXxg==","mode":420,"size":4801},"index.d.ts":{"checkedAt":1776593336138,"integrity":"sha512-YwR3YirWettZcjZgr7aNimg/ibEuP+6JMqAvL+cT6ubq2ctYKL9Xv+PgBssGCPES01PG5zKTHSvhShXCjXOrDg==","mode":420,"size":18944},"package.json":{"checkedAt":1776593336144,"integrity":"sha512-KaEBTHEFL2oVUvCrjSJR/H812XIaeRGbSZFP8DBb2Hon+IQwND0zz7oRvrXTm2AzzjneqH+pkB2Lusw29yJ/WA==","mode":420,"size":829}}}
|
||||
File diff suppressed because one or more lines are too long
@ -1 +1 @@
|
||||
{"name":"tslib","version":"2.8.1","requiresBuild":false,"files":{"tslib.es6.html":{"checkedAt":1776976148162,"integrity":"sha512-aoAR2zaxE9UtcXO4kE9FbPBgIZEVk7u3Z+nEPmDo6rwcYth07KxrVZejVEdy2XmKvkkcb8O/XM9UK3bPc1iMPw==","mode":420,"size":36},"tslib.html":{"checkedAt":1776976148164,"integrity":"sha512-4dCvZ5WYJpcbIJY4RPUhOBbFud1156Rr7RphuR12/+mXKUeIpCxol2/uWL4WDFNNlSH909M2AY4fiLWJo8+fTw==","mode":420,"size":32},"modules/index.js":{"checkedAt":1776976148166,"integrity":"sha512-DqWTtBt/Q47Jm4z8VzCLSiG/2R+Mwqy8uB60ithBWyofDYamF5C4icYdqbq/NP2IE/TefCT/03uAwA5mujzR7A==","mode":420,"size":1416},"tslib.es6.js":{"checkedAt":1776976148173,"integrity":"sha512-FugydTgfIjlaQrbH9gaIh59iXw8keW2311ILz3FBWn1IHLwPcmWte+ZE8UeXXGTQRc2E8NhQSCzYA6/zX36+7w==","mode":420,"size":19215},"tslib.js":{"checkedAt":1776976148180,"integrity":"sha512-7Gj/3vlZdba9iZH2H2up34pBk5UfN1tWQl3/TjsHzw3Oipw/stl6nko8k4jk+MeDeLPJE3rKz3VoQG5XmgwSmg==","mode":420,"size":23382},"modules/package.json":{"checkedAt":1776976148185,"integrity":"sha512-vm8hQn5MuoMkjJYvBBHTAtsdrcuXmVrKZwL3FEq32oGiKFhY562FoUQTbXv24wk0rwJVpgribUCOIU98IaS9Mg==","mode":420,"size":26},"package.json":{"checkedAt":1776976148187,"integrity":"sha512-72peSY+xgEHIo+YSpUbUl6qsExQ5ZlgeiDVDAiy4QdVmmBkK7RAB/07CCX3gg0SyvvQVJiGgAD36ub3rgE4QCg==","mode":420,"size":1219},"README.md":{"checkedAt":1776976148192,"integrity":"sha512-kCH2ENYjhlxwI7ae89ymMIP2tZeNcJJOcqnfifnmHQiHeK4mWnGc4w8ygoiUIpG1qyaurZkRSrYtwHCEIMNhbA==","mode":420,"size":4033},"SECURITY.md":{"checkedAt":1776976148195,"integrity":"sha512-ix30VBNb4RQLa5M2jgfD6IJ9+1XKmeREKrOYv7rDoWGZCin0605vEx3tTAVb5kNvteCwZwBC+nEGfQ4jHLg9Fw==","mode":420,"size":2757},"tslib.es6.mjs":{"checkedAt":1776976148199,"integrity":"sha512-q8VhXPTjmn6KDh3j6Ewn0V3siY1zNdvXvIUNN36llJUtO5cDafldf1Y2zzToBAbgOdh2pjFks7lFDRzZ/LZnDw==","mode":420,"size":17648},"modules/index.d.ts":{"checkedAt":1776976148200,"integrity":"sha512-XcNprVMjDjhbWmH3OTNZV91Uh9sDaCs8oZa3J7g5wMUHsdMJRENmv4XQ/8yqMlTUxKopv8uiztELREI7cw8BDg==","mode":420,"size":801},"tslib.d.ts":{"checkedAt":1776976148210,"integrity":"sha512-kqzM5TLHelP5iJBElSYyBRocQd2XmWsGIzOG6+Mv+CB7KhoZ6BoFioWM3RR2OCm1p96bbSCGnfHo2rozV/WJYQ==","mode":420,"size":18317},"CopyrightNotice.txt":{"checkedAt":1776976148214,"integrity":"sha512-C0myUddnUhhpZ/UcD9yZyMWodQV4fT2wxcfqb/ToD0Z98nB9WfWBl6koNVWJ+8jzeGWP6wQjz9zdX7Unua0/SQ==","mode":420,"size":822},"LICENSE.txt":{"checkedAt":1776976148225,"integrity":"sha512-9cs1Im06/fLAPBpXOY8fHMD2LgUM3kREaKlOX7S6fLWwbG5G+UqlUrqdkTKloRPeDghECezxOiUfzvW6lnEjDg==","mode":420,"size":655}}}
|
||||
{"name":"tslib","version":"2.8.1","requiresBuild":false,"files":{"tslib.es6.html":{"checkedAt":1776593335180,"integrity":"sha512-aoAR2zaxE9UtcXO4kE9FbPBgIZEVk7u3Z+nEPmDo6rwcYth07KxrVZejVEdy2XmKvkkcb8O/XM9UK3bPc1iMPw==","mode":420,"size":36},"tslib.html":{"checkedAt":1776593335194,"integrity":"sha512-4dCvZ5WYJpcbIJY4RPUhOBbFud1156Rr7RphuR12/+mXKUeIpCxol2/uWL4WDFNNlSH909M2AY4fiLWJo8+fTw==","mode":420,"size":32},"modules/index.js":{"checkedAt":1776593335198,"integrity":"sha512-DqWTtBt/Q47Jm4z8VzCLSiG/2R+Mwqy8uB60ithBWyofDYamF5C4icYdqbq/NP2IE/TefCT/03uAwA5mujzR7A==","mode":420,"size":1416},"tslib.es6.js":{"checkedAt":1776593335206,"integrity":"sha512-FugydTgfIjlaQrbH9gaIh59iXw8keW2311ILz3FBWn1IHLwPcmWte+ZE8UeXXGTQRc2E8NhQSCzYA6/zX36+7w==","mode":420,"size":19215},"tslib.js":{"checkedAt":1776593335213,"integrity":"sha512-7Gj/3vlZdba9iZH2H2up34pBk5UfN1tWQl3/TjsHzw3Oipw/stl6nko8k4jk+MeDeLPJE3rKz3VoQG5XmgwSmg==","mode":420,"size":23382},"modules/package.json":{"checkedAt":1776593335219,"integrity":"sha512-vm8hQn5MuoMkjJYvBBHTAtsdrcuXmVrKZwL3FEq32oGiKFhY562FoUQTbXv24wk0rwJVpgribUCOIU98IaS9Mg==","mode":420,"size":26},"package.json":{"checkedAt":1776593335230,"integrity":"sha512-72peSY+xgEHIo+YSpUbUl6qsExQ5ZlgeiDVDAiy4QdVmmBkK7RAB/07CCX3gg0SyvvQVJiGgAD36ub3rgE4QCg==","mode":420,"size":1219},"README.md":{"checkedAt":1776593335236,"integrity":"sha512-kCH2ENYjhlxwI7ae89ymMIP2tZeNcJJOcqnfifnmHQiHeK4mWnGc4w8ygoiUIpG1qyaurZkRSrYtwHCEIMNhbA==","mode":420,"size":4033},"SECURITY.md":{"checkedAt":1776593335243,"integrity":"sha512-ix30VBNb4RQLa5M2jgfD6IJ9+1XKmeREKrOYv7rDoWGZCin0605vEx3tTAVb5kNvteCwZwBC+nEGfQ4jHLg9Fw==","mode":420,"size":2757},"tslib.es6.mjs":{"checkedAt":1776593335251,"integrity":"sha512-q8VhXPTjmn6KDh3j6Ewn0V3siY1zNdvXvIUNN36llJUtO5cDafldf1Y2zzToBAbgOdh2pjFks7lFDRzZ/LZnDw==","mode":420,"size":17648},"modules/index.d.ts":{"checkedAt":1776593335258,"integrity":"sha512-XcNprVMjDjhbWmH3OTNZV91Uh9sDaCs8oZa3J7g5wMUHsdMJRENmv4XQ/8yqMlTUxKopv8uiztELREI7cw8BDg==","mode":420,"size":801},"tslib.d.ts":{"checkedAt":1776593335264,"integrity":"sha512-kqzM5TLHelP5iJBElSYyBRocQd2XmWsGIzOG6+Mv+CB7KhoZ6BoFioWM3RR2OCm1p96bbSCGnfHo2rozV/WJYQ==","mode":420,"size":18317},"CopyrightNotice.txt":{"checkedAt":1776593335271,"integrity":"sha512-C0myUddnUhhpZ/UcD9yZyMWodQV4fT2wxcfqb/ToD0Z98nB9WfWBl6koNVWJ+8jzeGWP6wQjz9zdX7Unua0/SQ==","mode":420,"size":822},"LICENSE.txt":{"checkedAt":1776593335278,"integrity":"sha512-9cs1Im06/fLAPBpXOY8fHMD2LgUM3kREaKlOX7S6fLWwbG5G+UqlUrqdkTKloRPeDghECezxOiUfzvW6lnEjDg==","mode":420,"size":655}}}
|
||||
File diff suppressed because one or more lines are too long
@ -67,6 +67,7 @@ public function handle(): int
|
||||
'name' => (string) ($scenarioConfig['tenant_name'] ?? 'Spec 180 Blocked Backup Tenant'),
|
||||
'tenant_id' => $tenantRouteKey,
|
||||
'app_certificate_thumbprint' => null,
|
||||
'app_status' => 'ok',
|
||||
'app_notes' => null,
|
||||
'status' => Tenant::STATUS_ACTIVE,
|
||||
'environment' => 'dev',
|
||||
|
||||
@ -20,7 +20,6 @@
|
||||
use App\Support\Badges\TagBadgeDomain;
|
||||
use App\Support\Inventory\TenantCoverageTruth;
|
||||
use App\Support\Inventory\TenantCoverageTruthResolver;
|
||||
use App\Support\OperationRunLinks;
|
||||
use App\Support\Ui\ActionSurface\ActionSurfaceDeclaration;
|
||||
use App\Support\Ui\ActionSurface\ActionSurfaceDefaults;
|
||||
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceProfile;
|
||||
@ -536,7 +535,7 @@ public function basisRunSummary(): array
|
||||
: 'The coverage basis is current, but your role cannot open the cited run detail.',
|
||||
'badgeLabel' => $badge->label,
|
||||
'badgeColor' => $badge->color,
|
||||
'runUrl' => $canViewRun ? OperationRunLinks::view($truth->basisRun, $tenant) : null,
|
||||
'runUrl' => $canViewRun ? route('admin.operations.view', ['run' => (int) $truth->basisRun->getKey()]) : null,
|
||||
'historyUrl' => $canViewRun ? $this->inventorySyncHistoryUrl($tenant) : null,
|
||||
'inventoryItemsUrl' => InventoryItemResource::getUrl('index', panel: 'tenant', tenant: $tenant),
|
||||
];
|
||||
@ -561,6 +560,13 @@ protected function coverageTruth(): ?TenantCoverageTruth
|
||||
|
||||
private function inventorySyncHistoryUrl(Tenant $tenant): string
|
||||
{
|
||||
return OperationRunLinks::index($tenant, operationType: 'inventory_sync');
|
||||
return route('admin.operations.index', [
|
||||
'tenant_id' => (int) $tenant->getKey(),
|
||||
'tableFilters' => [
|
||||
'type' => [
|
||||
'value' => 'inventory_sync',
|
||||
],
|
||||
],
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
@ -110,14 +110,14 @@ protected function getHeaderActions(): array
|
||||
$actions[] = Action::make('operate_hub_back_to_operations')
|
||||
->label('Back to Operations')
|
||||
->color('gray')
|
||||
->url(fn (): string => OperationRunLinks::index());
|
||||
->url(fn (): string => route('admin.operations.index'));
|
||||
}
|
||||
|
||||
if ($activeTenant instanceof Tenant) {
|
||||
$actions[] = Action::make('operate_hub_show_all_operations')
|
||||
->label('Show all operations')
|
||||
->color('gray')
|
||||
->url(fn (): string => OperationRunLinks::index());
|
||||
->url(fn (): string => route('admin.operations.index'));
|
||||
}
|
||||
|
||||
$actions[] = Action::make('refresh')
|
||||
@ -126,7 +126,7 @@ protected function getHeaderActions(): array
|
||||
->color('primary')
|
||||
->url(fn (): string => isset($this->run)
|
||||
? OperationRunLinks::tenantlessView($this->run, $navigationContext)
|
||||
: OperationRunLinks::index());
|
||||
: route('admin.operations.index'));
|
||||
|
||||
if (! isset($this->run)) {
|
||||
return $actions;
|
||||
|
||||
@ -598,9 +598,7 @@ public function content(Schema $schema): Schema
|
||||
->tooltip(fn (): ?string => $this->canStartAnyBootstrap()
|
||||
? null
|
||||
: 'You do not have permission to start bootstrap actions.')
|
||||
->action(fn (Get $get) => $this->startBootstrap(
|
||||
$this->normalizeBootstrapOperationTypes((array) ($get('bootstrap_operation_types') ?? [])),
|
||||
)),
|
||||
->action(fn () => $this->startBootstrap((array) ($this->data['bootstrap_operation_types'] ?? []))),
|
||||
]),
|
||||
Text::make(fn (): string => $this->bootstrapRunsLabel())
|
||||
->hidden(fn (): bool => $this->bootstrapRunsLabel() === ''),
|
||||
@ -608,11 +606,9 @@ public function content(Schema $schema): Schema
|
||||
])
|
||||
->afterValidation(function (): void {
|
||||
$types = $this->data['bootstrap_operation_types'] ?? [];
|
||||
$this->selectedBootstrapOperationTypes = $this->normalizeBootstrapOperationTypes(
|
||||
is_array($types) ? $types : [],
|
||||
);
|
||||
|
||||
$this->persistBootstrapSelection($this->selectedBootstrapOperationTypes);
|
||||
$this->selectedBootstrapOperationTypes = is_array($types)
|
||||
? array_values(array_filter($types, static fn ($v): bool => is_string($v) && $v !== ''))
|
||||
: [];
|
||||
|
||||
$this->touchOnboardingSessionStep('bootstrap');
|
||||
}),
|
||||
@ -646,10 +642,6 @@ public function content(Schema $schema): Schema
|
||||
->badge()
|
||||
->color(fn (): string => $this->completionSummaryBootstrapColor()),
|
||||
]),
|
||||
Callout::make('Bootstrap needs attention')
|
||||
->description(fn (): string => $this->completionSummaryBootstrapRecoveryMessage())
|
||||
->warning()
|
||||
->visible(fn (): bool => $this->showCompletionSummaryBootstrapRecovery()),
|
||||
Callout::make('After completion')
|
||||
->description('This action is recorded in the audit log and cannot be undone from this wizard.')
|
||||
->info()
|
||||
@ -741,111 +733,10 @@ private function loadOnboardingDraft(User $user, TenantOnboardingSession|int|str
|
||||
|
||||
$bootstrapTypes = $draft->state['bootstrap_operation_types'] ?? [];
|
||||
$this->selectedBootstrapOperationTypes = is_array($bootstrapTypes)
|
||||
? $this->normalizeBootstrapOperationTypes($bootstrapTypes)
|
||||
? array_values(array_filter($bootstrapTypes, static fn ($v): bool => is_string($v) && $v !== ''))
|
||||
: [];
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<int|string, mixed> $operationTypes
|
||||
* @return array<int, string>
|
||||
*/
|
||||
private function normalizeBootstrapOperationTypes(array $operationTypes): array
|
||||
{
|
||||
$supportedTypes = array_keys($this->supportedBootstrapCapabilities());
|
||||
$normalized = [];
|
||||
|
||||
foreach ($operationTypes as $key => $value) {
|
||||
if (is_string($value)) {
|
||||
$normalizedValue = trim($value);
|
||||
|
||||
if ($normalizedValue !== '' && in_array($normalizedValue, $supportedTypes, true)) {
|
||||
$normalized[] = $normalizedValue;
|
||||
}
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
if (! is_string($key) || trim($key) === '') {
|
||||
continue;
|
||||
}
|
||||
|
||||
$isSelected = match (true) {
|
||||
is_bool($value) => $value,
|
||||
is_int($value) => $value === 1,
|
||||
is_string($value) => in_array(strtolower(trim($value)), ['1', 'true', 'on', 'yes'], true),
|
||||
default => false,
|
||||
};
|
||||
|
||||
$normalizedKey = trim($key);
|
||||
|
||||
if ($isSelected && in_array($normalizedKey, $supportedTypes, true)) {
|
||||
$normalized[] = $normalizedKey;
|
||||
}
|
||||
}
|
||||
|
||||
return array_values(array_unique($normalized));
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, string>
|
||||
*/
|
||||
private function supportedBootstrapCapabilities(): array
|
||||
{
|
||||
return [
|
||||
'inventory_sync' => Capabilities::WORKSPACE_MANAGED_TENANT_ONBOARD_BOOTSTRAP_INVENTORY_SYNC,
|
||||
'compliance.snapshot' => Capabilities::WORKSPACE_MANAGED_TENANT_ONBOARD_BOOTSTRAP_POLICY_SYNC,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<int, string> $operationTypes
|
||||
*/
|
||||
private function persistBootstrapSelection(array $operationTypes): void
|
||||
{
|
||||
$user = auth()->user();
|
||||
|
||||
if (! $user instanceof User) {
|
||||
abort(403);
|
||||
}
|
||||
|
||||
if (! $this->onboardingSession instanceof TenantOnboardingSession) {
|
||||
return;
|
||||
}
|
||||
|
||||
$normalized = $this->normalizeBootstrapOperationTypes($operationTypes);
|
||||
$existing = $this->onboardingSession->state['bootstrap_operation_types'] ?? null;
|
||||
$existing = is_array($existing)
|
||||
? $this->normalizeBootstrapOperationTypes($existing)
|
||||
: [];
|
||||
|
||||
if ($normalized === $existing) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
$this->setOnboardingSession($this->mutationService()->mutate(
|
||||
draft: $this->onboardingSession,
|
||||
actor: $user,
|
||||
expectedVersion: $this->expectedDraftVersion(),
|
||||
incrementVersion: false,
|
||||
mutator: function (TenantOnboardingSession $draft) use ($normalized): void {
|
||||
$state = is_array($draft->state) ? $draft->state : [];
|
||||
$state['bootstrap_operation_types'] = $normalized;
|
||||
|
||||
$draft->state = $state;
|
||||
},
|
||||
));
|
||||
} catch (OnboardingDraftConflictException) {
|
||||
$this->handleDraftConflict();
|
||||
|
||||
return;
|
||||
} catch (OnboardingDraftImmutableException) {
|
||||
$this->handleImmutableDraft();
|
||||
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @return Collection<int, TenantOnboardingSession>
|
||||
*/
|
||||
@ -1573,7 +1464,6 @@ private function initializeWizardData(): void
|
||||
// Ensure all entangled schema state paths exist at render time.
|
||||
// Livewire v4 can throw when entangling to missing nested array keys.
|
||||
$this->data['notes'] ??= '';
|
||||
$this->data['bootstrap_operation_types'] ??= [];
|
||||
$this->data['override_blocked'] ??= false;
|
||||
$this->data['override_reason'] ??= '';
|
||||
$this->data['new_connection'] ??= [];
|
||||
@ -1644,7 +1534,7 @@ private function initializeWizardData(): void
|
||||
|
||||
$types = $draft->state['bootstrap_operation_types'] ?? null;
|
||||
if (is_array($types)) {
|
||||
$this->data['bootstrap_operation_types'] = $this->normalizeBootstrapOperationTypes($types);
|
||||
$this->data['bootstrap_operation_types'] = array_values(array_filter($types, static fn ($v): bool => is_string($v) && $v !== ''));
|
||||
}
|
||||
}
|
||||
|
||||
@ -3076,7 +2966,7 @@ public function startBootstrap(array $operationTypes): void
|
||||
}
|
||||
|
||||
$registry = app(ProviderOperationRegistry::class);
|
||||
$types = $this->normalizeBootstrapOperationTypes($operationTypes);
|
||||
$types = array_values(array_unique(array_filter($operationTypes, static fn ($v): bool => is_string($v) && trim($v) !== '')));
|
||||
|
||||
$types = array_values(array_filter(
|
||||
$types,
|
||||
@ -3346,18 +3236,18 @@ private function bootstrapOperationSucceeded(TenantOnboardingSession $draft, str
|
||||
|
||||
private function resolveBootstrapCapability(string $operationType): ?string
|
||||
{
|
||||
return $this->supportedBootstrapCapabilities()[$operationType] ?? null;
|
||||
return match ($operationType) {
|
||||
'inventory_sync' => Capabilities::WORKSPACE_MANAGED_TENANT_ONBOARD_BOOTSTRAP_INVENTORY_SYNC,
|
||||
'compliance.snapshot' => Capabilities::WORKSPACE_MANAGED_TENANT_ONBOARD_BOOTSTRAP_POLICY_SYNC,
|
||||
default => null,
|
||||
};
|
||||
}
|
||||
|
||||
private function canStartAnyBootstrap(): bool
|
||||
{
|
||||
foreach ($this->supportedBootstrapCapabilities() as $capability) {
|
||||
if ($this->currentUserCan($capability)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
return $this->currentUserCan(Capabilities::WORKSPACE_MANAGED_TENANT_ONBOARD_BOOTSTRAP_INVENTORY_SYNC)
|
||||
|| $this->currentUserCan(Capabilities::WORKSPACE_MANAGED_TENANT_ONBOARD_BOOTSTRAP_POLICY_SYNC)
|
||||
|| $this->currentUserCan(Capabilities::WORKSPACE_MANAGED_TENANT_ONBOARD_BOOTSTRAP_BACKUP_BOOTSTRAP);
|
||||
}
|
||||
|
||||
private function currentUserCan(string $capability): bool
|
||||
@ -3608,59 +3498,33 @@ private function completionSummaryVerificationDetail(): string
|
||||
private function completionSummaryBootstrapLabel(): string
|
||||
{
|
||||
if (! $this->onboardingSession instanceof TenantOnboardingSession) {
|
||||
return $this->completionSummarySelectedBootstrapTypes() === []
|
||||
? 'Skipped'
|
||||
: 'Selected';
|
||||
}
|
||||
|
||||
if ($this->completionSummaryBootstrapActionRequiredDetail() !== null) {
|
||||
return 'Action required';
|
||||
return 'Skipped';
|
||||
}
|
||||
|
||||
$runs = $this->onboardingSession->state['bootstrap_operation_runs'] ?? null;
|
||||
$runs = is_array($runs) ? $runs : [];
|
||||
|
||||
if ($runs !== []) {
|
||||
return 'Started';
|
||||
if ($runs === []) {
|
||||
return 'Skipped';
|
||||
}
|
||||
|
||||
return $this->completionSummarySelectedBootstrapTypes() === []
|
||||
? 'Skipped'
|
||||
: 'Selected';
|
||||
return 'Started';
|
||||
}
|
||||
|
||||
private function completionSummaryBootstrapDetail(): string
|
||||
{
|
||||
if (! $this->onboardingSession instanceof TenantOnboardingSession) {
|
||||
$selectedTypes = $this->completionSummarySelectedBootstrapTypes();
|
||||
|
||||
return $selectedTypes === []
|
||||
? 'No bootstrap actions selected'
|
||||
: sprintf('%d action(s) selected', count($selectedTypes));
|
||||
return 'No bootstrap actions selected';
|
||||
}
|
||||
|
||||
$runs = $this->onboardingSession->state['bootstrap_operation_runs'] ?? null;
|
||||
$runs = is_array($runs) ? $runs : [];
|
||||
$selectedTypes = $this->completionSummarySelectedBootstrapTypes();
|
||||
$actionRequiredDetail = $this->completionSummaryBootstrapActionRequiredDetail();
|
||||
|
||||
if ($selectedTypes === []) {
|
||||
if ($runs === []) {
|
||||
return 'No bootstrap actions selected';
|
||||
}
|
||||
|
||||
if ($actionRequiredDetail !== null) {
|
||||
return $actionRequiredDetail;
|
||||
}
|
||||
|
||||
if ($runs === []) {
|
||||
return sprintf('%d action(s) selected', count($selectedTypes));
|
||||
}
|
||||
|
||||
if (count($runs) < count($selectedTypes)) {
|
||||
return sprintf('%d of %d action(s) started', count($runs), count($selectedTypes));
|
||||
}
|
||||
|
||||
return sprintf('%d action(s) started', count($runs));
|
||||
return sprintf('%d operation(s) started', count($runs));
|
||||
}
|
||||
|
||||
private function completionSummaryBootstrapSummary(): string
|
||||
@ -3672,130 +3536,11 @@ private function completionSummaryBootstrapSummary(): string
|
||||
);
|
||||
}
|
||||
|
||||
private function showCompletionSummaryBootstrapRecovery(): bool
|
||||
{
|
||||
return $this->completionSummaryBootstrapActionRequiredDetail() !== null;
|
||||
}
|
||||
|
||||
private function completionSummaryBootstrapRecoveryMessage(): string
|
||||
{
|
||||
return 'Selected bootstrap actions must complete before activation. Return to Bootstrap to remove the selected actions and skip this optional step, or resolve the required permission and start the blocked action again.';
|
||||
}
|
||||
|
||||
private function completionSummaryBootstrapColor(): string
|
||||
{
|
||||
return match ($this->completionSummaryBootstrapLabel()) {
|
||||
'Action required' => 'warning',
|
||||
'Started' => 'info',
|
||||
'Selected' => 'warning',
|
||||
default => 'gray',
|
||||
};
|
||||
}
|
||||
|
||||
private function completionSummaryBootstrapActionRequiredDetail(): ?string
|
||||
{
|
||||
$reasonCode = $this->completionSummaryBootstrapReasonCode();
|
||||
|
||||
if (! in_array($reasonCode, ['bootstrap_failed', 'bootstrap_partial_failure'], true)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$run = $this->completionSummaryBootstrapFailedRun();
|
||||
|
||||
if (! $run instanceof OperationRun) {
|
||||
return $reasonCode === 'bootstrap_partial_failure'
|
||||
? 'A bootstrap action needs attention'
|
||||
: 'A bootstrap action failed';
|
||||
}
|
||||
|
||||
$context = is_array($run->context ?? null) ? $run->context : [];
|
||||
$operatorLabel = data_get($context, 'reason_translation.operator_label');
|
||||
|
||||
if (is_string($operatorLabel) && trim($operatorLabel) !== '') {
|
||||
return trim($operatorLabel);
|
||||
}
|
||||
|
||||
return match ($run->outcome) {
|
||||
OperationRunOutcome::PartiallySucceeded->value => 'A bootstrap action needs attention',
|
||||
OperationRunOutcome::Blocked->value => 'A bootstrap action was blocked',
|
||||
default => 'A bootstrap action failed',
|
||||
};
|
||||
}
|
||||
|
||||
private function completionSummaryBootstrapReasonCode(): ?string
|
||||
{
|
||||
if (! $this->onboardingSession instanceof TenantOnboardingSession) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$reasonCode = $this->lifecycleService()->snapshot($this->onboardingSession)['reason_code'] ?? null;
|
||||
|
||||
return is_string($reasonCode) ? $reasonCode : null;
|
||||
}
|
||||
|
||||
private function completionSummaryBootstrapFailedRun(): ?OperationRun
|
||||
{
|
||||
return once(function (): ?OperationRun {
|
||||
if (! $this->onboardingSession instanceof TenantOnboardingSession) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$runMap = $this->onboardingSession->state['bootstrap_operation_runs'] ?? null;
|
||||
|
||||
if (! is_array($runMap)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$runIds = array_values(array_filter(array_map(
|
||||
static fn (mixed $value): ?int => is_numeric($value) ? (int) $value : null,
|
||||
$runMap,
|
||||
)));
|
||||
|
||||
if ($runIds === []) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return OperationRun::query()
|
||||
->whereIn('id', $runIds)
|
||||
->where('status', OperationRunStatus::Completed->value)
|
||||
->whereIn('outcome', [
|
||||
OperationRunOutcome::Blocked->value,
|
||||
OperationRunOutcome::Failed->value,
|
||||
OperationRunOutcome::PartiallySucceeded->value,
|
||||
])
|
||||
->latest('id')
|
||||
->first();
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<int, string>
|
||||
*/
|
||||
private function completionSummarySelectedBootstrapTypes(): array
|
||||
{
|
||||
$selectedTypes = $this->data['bootstrap_operation_types'] ?? null;
|
||||
|
||||
if (is_array($selectedTypes)) {
|
||||
$normalized = $this->normalizeBootstrapOperationTypes($selectedTypes);
|
||||
|
||||
if ($normalized !== []) {
|
||||
return $normalized;
|
||||
}
|
||||
}
|
||||
|
||||
if ($this->selectedBootstrapOperationTypes !== []) {
|
||||
return $this->normalizeBootstrapOperationTypes($this->selectedBootstrapOperationTypes);
|
||||
}
|
||||
|
||||
if (! $this->onboardingSession instanceof TenantOnboardingSession) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$persistedTypes = $this->onboardingSession->state['bootstrap_operation_types'] ?? null;
|
||||
|
||||
return is_array($persistedTypes)
|
||||
? $this->normalizeBootstrapOperationTypes($persistedTypes)
|
||||
: [];
|
||||
return $this->completionSummaryBootstrapLabel() === 'Started'
|
||||
? 'info'
|
||||
: 'gray';
|
||||
}
|
||||
|
||||
public function completeOnboarding(): void
|
||||
@ -4394,10 +4139,9 @@ public function updateSelectedProviderConnectionInline(int $providerConnectionId
|
||||
private function bootstrapOperationOptions(): array
|
||||
{
|
||||
$registry = app(ProviderOperationRegistry::class);
|
||||
$supportedTypes = array_keys($this->supportedBootstrapCapabilities());
|
||||
|
||||
return collect($registry->all())
|
||||
->filter(fn (array $definition, string $type): bool => in_array($type, $supportedTypes, true))
|
||||
->reject(fn (array $definition, string $type): bool => $type === 'provider.connection.check')
|
||||
->mapWithKeys(fn (array $definition, string $type): array => [$type => (string) ($definition['label'] ?? $type)])
|
||||
->all();
|
||||
}
|
||||
|
||||
@ -9,7 +9,6 @@
|
||||
use App\Models\BaselineProfile;
|
||||
use App\Models\BaselineSnapshot;
|
||||
use App\Models\BaselineTenantAssignment;
|
||||
use App\Models\OperationRun;
|
||||
use App\Models\Tenant;
|
||||
use App\Models\User;
|
||||
use App\Models\Workspace;
|
||||
@ -841,17 +840,7 @@ private static function compareReadinessIcon(BaselineProfile $profile): ?string
|
||||
|
||||
private static function profileNextStep(BaselineProfile $profile): string
|
||||
{
|
||||
$compareAvailabilityReason = self::compareAvailabilityReason($profile);
|
||||
|
||||
if ($compareAvailabilityReason === null) {
|
||||
$latestCaptureEnvelope = self::latestBaselineCaptureEnvelope($profile);
|
||||
|
||||
if ($latestCaptureEnvelope instanceof ReasonResolutionEnvelope && trim($latestCaptureEnvelope->shortExplanation) !== '') {
|
||||
return $latestCaptureEnvelope->shortExplanation;
|
||||
}
|
||||
}
|
||||
|
||||
return match ($compareAvailabilityReason) {
|
||||
return match (self::compareAvailabilityReason($profile)) {
|
||||
BaselineReasonCodes::COMPARE_INVALID_SCOPE,
|
||||
BaselineReasonCodes::COMPARE_MIXED_SCOPE,
|
||||
BaselineReasonCodes::COMPARE_UNSUPPORTED_SCOPE => 'Review the governed subject selection before starting compare.',
|
||||
@ -869,30 +858,6 @@ private static function latestAttemptedSnapshot(BaselineProfile $profile): ?Base
|
||||
return app(BaselineSnapshotTruthResolver::class)->resolveLatestAttemptedSnapshot($profile);
|
||||
}
|
||||
|
||||
private static function latestBaselineCaptureEnvelope(BaselineProfile $profile): ?ReasonResolutionEnvelope
|
||||
{
|
||||
$run = OperationRun::query()
|
||||
->where('workspace_id', (int) $profile->workspace_id)
|
||||
->where('type', 'baseline_capture')
|
||||
->where('context->baseline_profile_id', (int) $profile->getKey())
|
||||
->where('status', 'completed')
|
||||
->orderByDesc('completed_at')
|
||||
->orderByDesc('id')
|
||||
->first();
|
||||
|
||||
if (! $run instanceof OperationRun) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$reasonCode = data_get($run->context, 'reason_code');
|
||||
|
||||
if (! is_string($reasonCode) || trim($reasonCode) === '') {
|
||||
return null;
|
||||
}
|
||||
|
||||
return app(ReasonPresenter::class)->forOperationRun($run, 'artifact_truth');
|
||||
}
|
||||
|
||||
private static function compareAvailabilityReason(BaselineProfile $profile): ?string
|
||||
{
|
||||
$status = $profile->status instanceof BaselineProfileStatus
|
||||
|
||||
@ -19,7 +19,6 @@
|
||||
use App\Support\OperationRunLinks;
|
||||
use App\Support\OpsUx\OperationUxPresenter;
|
||||
use App\Support\OpsUx\OpsUxBrowserEvents;
|
||||
use App\Support\ReasonTranslation\ReasonPresenter;
|
||||
use App\Support\Rbac\WorkspaceUiEnforcement;
|
||||
use App\Support\Workspaces\WorkspaceContext;
|
||||
use Filament\Actions\Action;
|
||||
@ -106,10 +105,15 @@ private function captureAction(): Action
|
||||
|
||||
if (! $result['ok']) {
|
||||
$reasonCode = is_string($result['reason_code'] ?? null) ? (string) $result['reason_code'] : 'unknown';
|
||||
$translation = app(ReasonPresenter::class)->forArtifactTruth($reasonCode, 'artifact_truth');
|
||||
$message = is_string($translation?->shortExplanation) && trim($translation->shortExplanation) !== ''
|
||||
? trim($translation->shortExplanation)
|
||||
: 'Reason: '.str_replace('.', ' ', $reasonCode);
|
||||
|
||||
$message = match ($reasonCode) {
|
||||
BaselineReasonCodes::CAPTURE_ROLLOUT_DISABLED => 'Full-content baseline capture is currently disabled for controlled rollout.',
|
||||
BaselineReasonCodes::CAPTURE_PROFILE_NOT_ACTIVE => 'This baseline profile is not active.',
|
||||
BaselineReasonCodes::CAPTURE_MISSING_SOURCE_TENANT => 'The selected tenant is not available for this baseline profile.',
|
||||
BaselineReasonCodes::CAPTURE_INVALID_SCOPE => 'This baseline profile has an invalid governed-subject scope. Review the baseline definition before capturing.',
|
||||
BaselineReasonCodes::CAPTURE_UNSUPPORTED_SCOPE => 'This baseline profile includes governed subjects that are not currently supported for capture.',
|
||||
default => 'Reason: '.str_replace('.', ' ', $reasonCode),
|
||||
};
|
||||
|
||||
Notification::make()
|
||||
->title('Cannot start capture')
|
||||
|
||||
@ -17,7 +17,6 @@
|
||||
use App\Support\Badges\TagBadgeRenderer;
|
||||
use App\Support\Filament\FilterOptionCatalog;
|
||||
use App\Support\Inventory\InventoryPolicyTypeMeta;
|
||||
use App\Support\OperationRunLinks;
|
||||
use App\Support\Ui\ActionSurface\ActionSurfaceDeclaration;
|
||||
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceInspectAffordance;
|
||||
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceProfile;
|
||||
@ -149,13 +148,7 @@ public static function infolist(Schema $schema): Schema
|
||||
return null;
|
||||
}
|
||||
|
||||
$tenant = $record->tenant;
|
||||
|
||||
if ($tenant instanceof Tenant) {
|
||||
return OperationRunLinks::view((int) $record->last_seen_operation_run_id, $tenant);
|
||||
}
|
||||
|
||||
return OperationRunLinks::tenantlessView((int) $record->last_seen_operation_run_id);
|
||||
return route('admin.operations.view', ['run' => (int) $record->last_seen_operation_run_id]);
|
||||
})
|
||||
->openUrlInNewTab(),
|
||||
TextEntry::make('support_restore')
|
||||
|
||||
@ -13,7 +13,6 @@
|
||||
use App\Support\Badges\BadgeCatalog;
|
||||
use App\Support\Badges\BadgeDomain;
|
||||
use App\Support\Badges\BadgeRenderer;
|
||||
use App\Support\OperationRunLinks;
|
||||
use App\Support\OpsUx\OperationUxPresenter;
|
||||
use App\Support\Rbac\UiEnforcement;
|
||||
use App\Support\ReviewPackStatus;
|
||||
@ -200,19 +199,9 @@ public static function infolist(Schema $schema): Schema
|
||||
->placeholder('—'),
|
||||
TextEntry::make('operationRun.id')
|
||||
->label('Operation')
|
||||
->url(function (ReviewPack $record): ?string {
|
||||
if (! $record->operation_run_id) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$tenant = $record->tenant;
|
||||
|
||||
if ($tenant instanceof Tenant) {
|
||||
return OperationRunLinks::view((int) $record->operation_run_id, $tenant);
|
||||
}
|
||||
|
||||
return OperationRunLinks::tenantlessView((int) $record->operation_run_id);
|
||||
})
|
||||
->url(fn (ReviewPack $record): ?string => $record->operation_run_id
|
||||
? route('admin.operations.view', ['run' => (int) $record->operation_run_id])
|
||||
: null)
|
||||
->openUrlInNewTab()
|
||||
->placeholder('—'),
|
||||
TextEntry::make('fingerprint')->label('Fingerprint')->copyable()->placeholder('—'),
|
||||
|
||||
@ -41,7 +41,7 @@ protected function getViewData(): array
|
||||
return [
|
||||
'tenant' => null,
|
||||
'runs' => collect(),
|
||||
'operationsIndexUrl' => OperationRunLinks::index(),
|
||||
'operationsIndexUrl' => route('admin.operations.index'),
|
||||
'operationsIndexLabel' => OperationRunLinks::openCollectionLabel(),
|
||||
'operationsIndexDescription' => OperationRunLinks::collectionScopeDescription(),
|
||||
];
|
||||
@ -68,7 +68,7 @@ protected function getViewData(): array
|
||||
return [
|
||||
'tenant' => $tenant,
|
||||
'runs' => $runs,
|
||||
'operationsIndexUrl' => OperationRunLinks::index($tenant),
|
||||
'operationsIndexUrl' => route('admin.operations.index'),
|
||||
'operationsIndexLabel' => OperationRunLinks::openCollectionLabel(),
|
||||
'operationsIndexDescription' => OperationRunLinks::collectionScopeDescription(),
|
||||
];
|
||||
|
||||
@ -4,7 +4,6 @@
|
||||
|
||||
use App\Models\ProviderConnection;
|
||||
use App\Models\Tenant;
|
||||
use App\Models\TenantOnboardingSession;
|
||||
use App\Services\Intune\AuditLogger;
|
||||
use App\Support\Providers\ProviderConnectionType;
|
||||
use App\Support\Providers\ProviderConsentStatus;
|
||||
@ -55,8 +54,6 @@ public function __invoke(
|
||||
error: $error,
|
||||
);
|
||||
|
||||
$this->invalidateResumableOnboardingVerificationState($tenant, $connection);
|
||||
|
||||
$legacyStatus = $status === 'ok' ? 'success' : 'failed';
|
||||
$auditMetadata = [
|
||||
'source' => 'admin.consent.callback',
|
||||
@ -101,7 +98,6 @@ public function __invoke(
|
||||
'status' => $status,
|
||||
'error' => $error,
|
||||
'consentGranted' => $consentGranted,
|
||||
'verificationStateLabel' => $this->verificationStateLabel($connection),
|
||||
]);
|
||||
}
|
||||
|
||||
@ -201,48 +197,4 @@ private function parseState(?string $state): ?string
|
||||
|
||||
return $state;
|
||||
}
|
||||
|
||||
private function verificationStateLabel(ProviderConnection $connection): string
|
||||
{
|
||||
$verificationStatus = $connection->verification_status instanceof ProviderVerificationStatus
|
||||
? $connection->verification_status
|
||||
: ProviderVerificationStatus::tryFrom((string) $connection->verification_status);
|
||||
|
||||
if ($verificationStatus === ProviderVerificationStatus::Unknown) {
|
||||
return $connection->consent_status === ProviderConsentStatus::Granted
|
||||
? 'Needs verification'
|
||||
: 'Not verified';
|
||||
}
|
||||
|
||||
return ucfirst(str_replace('_', ' ', $verificationStatus?->value ?? 'unknown'));
|
||||
}
|
||||
|
||||
private function invalidateResumableOnboardingVerificationState(Tenant $tenant, ProviderConnection $connection): void
|
||||
{
|
||||
TenantOnboardingSession::query()
|
||||
->where('tenant_id', (int) $tenant->getKey())
|
||||
->resumable()
|
||||
->each(function (TenantOnboardingSession $draft) use ($connection): void {
|
||||
$state = is_array($draft->state) ? $draft->state : [];
|
||||
$providerConnectionId = $state['provider_connection_id'] ?? null;
|
||||
$providerConnectionId = is_numeric($providerConnectionId) ? (int) $providerConnectionId : null;
|
||||
|
||||
if ($providerConnectionId !== null && $providerConnectionId !== (int) $connection->getKey()) {
|
||||
return;
|
||||
}
|
||||
|
||||
unset(
|
||||
$state['verification_operation_run_id'],
|
||||
$state['verification_run_id'],
|
||||
$state['bootstrap_operation_runs'],
|
||||
$state['bootstrap_operation_types'],
|
||||
$state['bootstrap_run_ids'],
|
||||
);
|
||||
|
||||
$state['connection_recently_updated'] = true;
|
||||
|
||||
$draft->state = $state;
|
||||
$draft->save();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@ -11,7 +11,6 @@
|
||||
use App\Models\OperationRun;
|
||||
use App\Models\Tenant;
|
||||
use App\Models\User;
|
||||
use App\Services\Baselines\BaselineCaptureService;
|
||||
use App\Services\Baselines\BaselineContentCapturePhase;
|
||||
use App\Services\Baselines\BaselineSnapshotIdentity;
|
||||
use App\Services\Baselines\BaselineSnapshotItemNormalizer;
|
||||
@ -30,6 +29,7 @@
|
||||
use App\Support\Inventory\InventoryPolicyTypeMeta;
|
||||
use App\Support\OperationRunOutcome;
|
||||
use App\Support\OperationRunStatus;
|
||||
use App\Support\OperationRunType;
|
||||
use Illuminate\Bus\Queueable;
|
||||
use Illuminate\Contracts\Queue\ShouldQueue;
|
||||
use Illuminate\Foundation\Bus\Dispatchable;
|
||||
@ -71,24 +71,13 @@ public function handle(
|
||||
InventoryMetaContract $metaContract,
|
||||
AuditLogger $auditLogger,
|
||||
OperationRunService $operationRunService,
|
||||
mixed $arg5 = null,
|
||||
mixed $arg6 = null,
|
||||
?CurrentStateHashResolver $hashResolver = null,
|
||||
?BaselineContentCapturePhase $contentCapturePhase = null,
|
||||
?BaselineSnapshotItemNormalizer $snapshotItemNormalizer = null,
|
||||
?BaselineFullContentRolloutGate $rolloutGate = null,
|
||||
): void {
|
||||
$captureService = $arg5 instanceof BaselineCaptureService
|
||||
? $arg5
|
||||
: app(BaselineCaptureService::class);
|
||||
$hashResolver = $arg5 instanceof CurrentStateHashResolver
|
||||
? $arg5
|
||||
: ($arg6 instanceof CurrentStateHashResolver
|
||||
? $arg6
|
||||
: app(CurrentStateHashResolver::class));
|
||||
$contentCapturePhase = $arg5 instanceof BaselineContentCapturePhase
|
||||
? $arg5
|
||||
: ($arg6 instanceof BaselineContentCapturePhase
|
||||
? $arg6
|
||||
: app(BaselineContentCapturePhase::class));
|
||||
$hashResolver ??= app(CurrentStateHashResolver::class);
|
||||
$contentCapturePhase ??= app(BaselineContentCapturePhase::class);
|
||||
$snapshotItemNormalizer ??= app(BaselineSnapshotItemNormalizer::class);
|
||||
$rolloutGate ??= app(BaselineFullContentRolloutGate::class);
|
||||
|
||||
@ -129,124 +118,10 @@ public function handle(
|
||||
$rolloutGate->assertEnabled();
|
||||
}
|
||||
|
||||
$previousCurrentSnapshot = $profile->resolveCurrentConsumableSnapshot();
|
||||
$previousCurrentSnapshotId = $previousCurrentSnapshot instanceof BaselineSnapshot
|
||||
? (int) $previousCurrentSnapshot->getKey()
|
||||
$latestInventorySyncRun = $this->resolveLatestInventorySyncRun($sourceTenant);
|
||||
$latestInventorySyncRunId = $latestInventorySyncRun instanceof OperationRun
|
||||
? (int) $latestInventorySyncRun->getKey()
|
||||
: null;
|
||||
$previousCurrentSnapshotExists = $previousCurrentSnapshotId !== null;
|
||||
|
||||
$preflightEligibility = is_array(data_get($context, 'baseline_capture.eligibility'))
|
||||
? data_get($context, 'baseline_capture.eligibility')
|
||||
: [];
|
||||
$inventoryEligibility = $captureService->latestInventoryEligibilityDecision($sourceTenant, $effectiveScope, $truthfulTypes);
|
||||
$latestInventorySyncRunId = is_numeric($inventoryEligibility['inventory_sync_run_id'] ?? null)
|
||||
? (int) $inventoryEligibility['inventory_sync_run_id']
|
||||
: null;
|
||||
$eligibilityContext = $captureService->eligibilityContextPayload($inventoryEligibility, phase: 'runtime_recheck');
|
||||
$eligibilityContext['changed_after_enqueue'] = ($preflightEligibility['ok'] ?? null) === true
|
||||
&& ! ($inventoryEligibility['ok'] ?? false);
|
||||
$eligibilityContext['preflight_inventory_sync_run_id'] = is_numeric($preflightEligibility['inventory_sync_run_id'] ?? null)
|
||||
? (int) $preflightEligibility['inventory_sync_run_id']
|
||||
: null;
|
||||
$eligibilityContext['preflight_reason_code'] = is_string($preflightEligibility['reason_code'] ?? null)
|
||||
? (string) $preflightEligibility['reason_code']
|
||||
: null;
|
||||
|
||||
$context['baseline_capture'] = array_merge(
|
||||
is_array($context['baseline_capture'] ?? null) ? $context['baseline_capture'] : [],
|
||||
[
|
||||
'inventory_sync_run_id' => $latestInventorySyncRunId,
|
||||
'eligibility' => $eligibilityContext,
|
||||
'previous_current_snapshot_id' => $previousCurrentSnapshotId,
|
||||
'previous_current_snapshot_exists' => $previousCurrentSnapshotExists,
|
||||
],
|
||||
);
|
||||
$this->operationRun->update(['context' => $context]);
|
||||
$this->operationRun->refresh();
|
||||
$context = is_array($this->operationRun->context) ? $this->operationRun->context : [];
|
||||
|
||||
if (! ($inventoryEligibility['ok'] ?? false)) {
|
||||
$reasonCode = is_string($inventoryEligibility['reason_code'] ?? null)
|
||||
? (string) $inventoryEligibility['reason_code']
|
||||
: BaselineReasonCodes::CAPTURE_INVENTORY_MISSING;
|
||||
$summaryCounts = [
|
||||
'total' => 0,
|
||||
'processed' => 0,
|
||||
'succeeded' => 0,
|
||||
'failed' => 0,
|
||||
];
|
||||
$blockedContext = $context;
|
||||
$blockedContext['reason_code'] = $reasonCode;
|
||||
$blockedContext['baseline_capture'] = array_merge(
|
||||
is_array($blockedContext['baseline_capture'] ?? null) ? $blockedContext['baseline_capture'] : [],
|
||||
[
|
||||
'reason_code' => $reasonCode,
|
||||
'subjects_total' => 0,
|
||||
'current_baseline_changed' => false,
|
||||
],
|
||||
);
|
||||
$blockedContext['result'] = array_merge(
|
||||
is_array($blockedContext['result'] ?? null) ? $blockedContext['result'] : [],
|
||||
[
|
||||
'current_baseline_changed' => false,
|
||||
],
|
||||
);
|
||||
|
||||
$this->operationRun->update([
|
||||
'context' => $blockedContext,
|
||||
'summary_counts' => $summaryCounts,
|
||||
]);
|
||||
$this->operationRun->refresh();
|
||||
|
||||
$this->auditStarted(
|
||||
auditLogger: $auditLogger,
|
||||
tenant: $sourceTenant,
|
||||
profile: $profile,
|
||||
initiator: $initiator,
|
||||
captureMode: $captureMode,
|
||||
subjectsTotal: 0,
|
||||
effectiveScope: $effectiveScope,
|
||||
inventorySyncRunId: $latestInventorySyncRunId,
|
||||
);
|
||||
|
||||
$operationRunService->finalizeBlockedRun(
|
||||
run: $this->operationRun,
|
||||
reasonCode: $reasonCode,
|
||||
message: $this->blockedInventoryMessage(
|
||||
$reasonCode,
|
||||
(bool) ($eligibilityContext['changed_after_enqueue'] ?? false),
|
||||
),
|
||||
);
|
||||
|
||||
$this->operationRun->refresh();
|
||||
|
||||
$this->auditCompleted(
|
||||
auditLogger: $auditLogger,
|
||||
tenant: $sourceTenant,
|
||||
profile: $profile,
|
||||
snapshot: null,
|
||||
initiator: $initiator,
|
||||
captureMode: $captureMode,
|
||||
subjectsTotal: 0,
|
||||
inventorySyncRunId: $latestInventorySyncRunId,
|
||||
wasNewSnapshot: false,
|
||||
evidenceCaptureStats: [
|
||||
'requested' => 0,
|
||||
'succeeded' => 0,
|
||||
'skipped' => 0,
|
||||
'failed' => 0,
|
||||
'throttled' => 0,
|
||||
],
|
||||
gaps: [
|
||||
'count' => 0,
|
||||
'by_reason' => [],
|
||||
],
|
||||
currentBaselineChanged: false,
|
||||
reasonCode: $reasonCode,
|
||||
);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$inventoryResult = $this->collectInventorySubjects(
|
||||
sourceTenant: $sourceTenant,
|
||||
@ -279,7 +154,6 @@ public function handle(
|
||||
'failed' => 0,
|
||||
'throttled' => 0,
|
||||
];
|
||||
$phaseResult = [];
|
||||
$phaseGaps = [];
|
||||
$resumeToken = null;
|
||||
|
||||
@ -348,91 +222,6 @@ public function handle(
|
||||
],
|
||||
];
|
||||
|
||||
if ($subjectsTotal === 0) {
|
||||
$snapshotResult = $this->captureNoDataSnapshotArtifact(
|
||||
$profile,
|
||||
$identityHash,
|
||||
$snapshotSummary,
|
||||
);
|
||||
$snapshot = $snapshotResult['snapshot'];
|
||||
$wasNewSnapshot = $snapshotResult['was_new_snapshot'];
|
||||
$summaryCounts = [
|
||||
'total' => 0,
|
||||
'processed' => 0,
|
||||
'succeeded' => 0,
|
||||
'failed' => 0,
|
||||
];
|
||||
$updatedContext = is_array($this->operationRun->context) ? $this->operationRun->context : [];
|
||||
$updatedContext['reason_code'] = BaselineReasonCodes::CAPTURE_ZERO_SUBJECTS;
|
||||
$updatedContext['baseline_capture'] = array_merge(
|
||||
is_array($updatedContext['baseline_capture'] ?? null) ? $updatedContext['baseline_capture'] : [],
|
||||
[
|
||||
'reason_code' => BaselineReasonCodes::CAPTURE_ZERO_SUBJECTS,
|
||||
'subjects_total' => 0,
|
||||
'inventory_sync_run_id' => $latestInventorySyncRunId,
|
||||
'evidence_capture' => $phaseStats,
|
||||
'gaps' => [
|
||||
'count' => $gapsCount,
|
||||
'by_reason' => $gapsByReason,
|
||||
'subjects' => is_array($phaseResult['gap_subjects'] ?? null) && $phaseResult['gap_subjects'] !== []
|
||||
? array_values($phaseResult['gap_subjects'])
|
||||
: null,
|
||||
],
|
||||
'resume_token' => $resumeToken,
|
||||
'current_baseline_changed' => false,
|
||||
'previous_current_snapshot_id' => $previousCurrentSnapshotId,
|
||||
'previous_current_snapshot_exists' => $previousCurrentSnapshotExists,
|
||||
],
|
||||
);
|
||||
$updatedContext['result'] = array_merge(
|
||||
is_array($updatedContext['result'] ?? null) ? $updatedContext['result'] : [],
|
||||
[
|
||||
'snapshot_id' => (int) $snapshot->getKey(),
|
||||
'snapshot_identity_hash' => $identityHash,
|
||||
'was_new_snapshot' => $wasNewSnapshot,
|
||||
'items_captured' => 0,
|
||||
'snapshot_lifecycle' => $snapshot->lifecycleState()->value,
|
||||
'current_baseline_changed' => false,
|
||||
],
|
||||
);
|
||||
|
||||
$this->operationRun->update([
|
||||
'context' => $updatedContext,
|
||||
'summary_counts' => $summaryCounts,
|
||||
]);
|
||||
$this->operationRun->refresh();
|
||||
|
||||
$operationRunService->updateRun(
|
||||
$this->operationRun,
|
||||
status: OperationRunStatus::Completed->value,
|
||||
outcome: OperationRunOutcome::PartiallySucceeded->value,
|
||||
summaryCounts: $summaryCounts,
|
||||
);
|
||||
|
||||
$this->operationRun->refresh();
|
||||
|
||||
$this->auditCompleted(
|
||||
auditLogger: $auditLogger,
|
||||
tenant: $sourceTenant,
|
||||
profile: $profile,
|
||||
snapshot: $snapshot,
|
||||
initiator: $initiator,
|
||||
captureMode: $captureMode,
|
||||
subjectsTotal: 0,
|
||||
inventorySyncRunId: $latestInventorySyncRunId,
|
||||
wasNewSnapshot: $wasNewSnapshot,
|
||||
evidenceCaptureStats: $phaseStats,
|
||||
gaps: [
|
||||
'count' => $gapsCount,
|
||||
'by_reason' => $gapsByReason,
|
||||
],
|
||||
currentBaselineChanged: false,
|
||||
reasonCode: BaselineReasonCodes::CAPTURE_ZERO_SUBJECTS,
|
||||
);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$snapshotResult = $this->captureSnapshotArtifact(
|
||||
$profile,
|
||||
$identityHash,
|
||||
@ -447,9 +236,6 @@ public function handle(
|
||||
$profile->update(['active_snapshot_id' => $snapshot->getKey()]);
|
||||
}
|
||||
|
||||
$profile->refresh();
|
||||
$currentBaselineChanged = $this->currentBaselineChanged($profile, $previousCurrentSnapshotId);
|
||||
|
||||
$warningsRecorded = $gapsByReason !== [] || $resumeToken !== null;
|
||||
$warningsRecorded = $warningsRecorded || ($captureMode === BaselineCaptureMode::FullContent && ($snapshotItems['fidelity_counts']['meta'] ?? 0) > 0);
|
||||
$outcome = $warningsRecorded ? OperationRunOutcome::PartiallySucceeded->value : OperationRunOutcome::Succeeded->value;
|
||||
@ -483,9 +269,6 @@ public function handle(
|
||||
: null,
|
||||
],
|
||||
'resume_token' => $resumeToken,
|
||||
'current_baseline_changed' => $currentBaselineChanged,
|
||||
'previous_current_snapshot_id' => $previousCurrentSnapshotId,
|
||||
'previous_current_snapshot_exists' => $previousCurrentSnapshotExists,
|
||||
],
|
||||
);
|
||||
$updatedContext['result'] = [
|
||||
@ -494,7 +277,6 @@ public function handle(
|
||||
'was_new_snapshot' => $wasNewSnapshot,
|
||||
'items_captured' => $snapshotItems['items_count'],
|
||||
'snapshot_lifecycle' => $snapshot->lifecycleState()->value,
|
||||
'current_baseline_changed' => $currentBaselineChanged,
|
||||
];
|
||||
$this->operationRun->update(['context' => $updatedContext]);
|
||||
|
||||
@ -513,8 +295,6 @@ public function handle(
|
||||
'count' => $gapsCount,
|
||||
'by_reason' => $gapsByReason,
|
||||
],
|
||||
currentBaselineChanged: $currentBaselineChanged,
|
||||
reasonCode: null,
|
||||
);
|
||||
}
|
||||
|
||||
@ -871,51 +651,6 @@ private function captureSnapshotArtifact(
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $summaryJsonb
|
||||
* @return array{snapshot: BaselineSnapshot, was_new_snapshot: bool}
|
||||
*/
|
||||
private function captureNoDataSnapshotArtifact(
|
||||
BaselineProfile $profile,
|
||||
string $identityHash,
|
||||
array $summaryJsonb,
|
||||
): array {
|
||||
$snapshot = $this->createBuildingSnapshot($profile, $identityHash, $summaryJsonb, 0);
|
||||
|
||||
$this->rememberSnapshotOnRun(
|
||||
snapshot: $snapshot,
|
||||
identityHash: $identityHash,
|
||||
wasNewSnapshot: true,
|
||||
expectedItems: 0,
|
||||
persistedItems: 0,
|
||||
reasonCode: BaselineReasonCodes::CAPTURE_ZERO_SUBJECTS,
|
||||
);
|
||||
|
||||
$snapshot->markIncomplete(BaselineReasonCodes::CAPTURE_ZERO_SUBJECTS, [
|
||||
'expected_identity_hash' => $identityHash,
|
||||
'expected_items' => 0,
|
||||
'persisted_items' => 0,
|
||||
'producer_run_id' => (int) $this->operationRun->getKey(),
|
||||
'was_empty_capture' => true,
|
||||
]);
|
||||
|
||||
$snapshot->refresh();
|
||||
|
||||
$this->rememberSnapshotOnRun(
|
||||
snapshot: $snapshot,
|
||||
identityHash: $identityHash,
|
||||
wasNewSnapshot: true,
|
||||
expectedItems: 0,
|
||||
persistedItems: 0,
|
||||
reasonCode: BaselineReasonCodes::CAPTURE_ZERO_SUBJECTS,
|
||||
);
|
||||
|
||||
return [
|
||||
'snapshot' => $snapshot,
|
||||
'was_new_snapshot' => true,
|
||||
];
|
||||
}
|
||||
|
||||
private function findExistingConsumableSnapshot(BaselineProfile $profile, string $identityHash): ?BaselineSnapshot
|
||||
{
|
||||
$existing = BaselineSnapshot::query()
|
||||
@ -1048,32 +783,6 @@ private function countByPolicyType(array $items): array
|
||||
return $counts;
|
||||
}
|
||||
|
||||
private function currentBaselineChanged(BaselineProfile $profile, ?int $previousCurrentSnapshotId): bool
|
||||
{
|
||||
$currentSnapshot = $profile->resolveCurrentConsumableSnapshot();
|
||||
$currentSnapshotId = $currentSnapshot instanceof BaselineSnapshot
|
||||
? (int) $currentSnapshot->getKey()
|
||||
: null;
|
||||
|
||||
return $currentSnapshotId !== null && $currentSnapshotId !== $previousCurrentSnapshotId;
|
||||
}
|
||||
|
||||
private function blockedInventoryMessage(string $reasonCode, bool $changedAfterEnqueue): string
|
||||
{
|
||||
return match ($reasonCode) {
|
||||
BaselineReasonCodes::CAPTURE_INVENTORY_BLOCKED => $changedAfterEnqueue
|
||||
? 'Capture blocked because the latest inventory sync changed after the run was queued.'
|
||||
: 'Capture blocked because the latest inventory sync was blocked.',
|
||||
BaselineReasonCodes::CAPTURE_INVENTORY_FAILED => $changedAfterEnqueue
|
||||
? 'Capture blocked because the latest inventory sync failed after the run was queued.'
|
||||
: 'Capture blocked because the latest inventory sync failed.',
|
||||
BaselineReasonCodes::CAPTURE_UNUSABLE_COVERAGE => $changedAfterEnqueue
|
||||
? 'Capture blocked because the latest inventory coverage became unusable after the run was queued.'
|
||||
: 'Capture blocked because the latest inventory coverage was not usable for this baseline scope.',
|
||||
default => 'Capture blocked because no credible inventory basis was available.',
|
||||
};
|
||||
}
|
||||
|
||||
private function auditStarted(
|
||||
AuditLogger $auditLogger,
|
||||
Tenant $tenant,
|
||||
@ -1111,7 +820,7 @@ private function auditCompleted(
|
||||
AuditLogger $auditLogger,
|
||||
Tenant $tenant,
|
||||
BaselineProfile $profile,
|
||||
?BaselineSnapshot $snapshot,
|
||||
BaselineSnapshot $snapshot,
|
||||
?User $initiator,
|
||||
BaselineCaptureMode $captureMode,
|
||||
int $subjectsTotal,
|
||||
@ -1119,8 +828,6 @@ private function auditCompleted(
|
||||
bool $wasNewSnapshot,
|
||||
array $evidenceCaptureStats,
|
||||
array $gaps,
|
||||
bool $currentBaselineChanged,
|
||||
?string $reasonCode,
|
||||
): void {
|
||||
$auditLogger->log(
|
||||
tenant: $tenant,
|
||||
@ -1134,10 +841,8 @@ private function auditCompleted(
|
||||
'capture_mode' => $captureMode->value,
|
||||
'inventory_sync_run_id' => $inventorySyncRunId,
|
||||
'subjects_total' => $subjectsTotal,
|
||||
'snapshot_id' => $snapshot?->getKey(),
|
||||
'snapshot_identity_hash' => $snapshot instanceof BaselineSnapshot ? (string) $snapshot->snapshot_identity_hash : null,
|
||||
'reason_code' => $reasonCode,
|
||||
'current_baseline_changed' => $currentBaselineChanged,
|
||||
'snapshot_id' => (int) $snapshot->getKey(),
|
||||
'snapshot_identity_hash' => (string) $snapshot->snapshot_identity_hash,
|
||||
'was_new_snapshot' => $wasNewSnapshot,
|
||||
'evidence_capture' => $evidenceCaptureStats,
|
||||
'gaps' => $gaps,
|
||||
@ -1173,4 +878,17 @@ private function mergeGapCounts(array ...$gaps): array
|
||||
|
||||
return $merged;
|
||||
}
|
||||
|
||||
private function resolveLatestInventorySyncRun(Tenant $tenant): ?OperationRun
|
||||
{
|
||||
$run = OperationRun::query()
|
||||
->where('tenant_id', (int) $tenant->getKey())
|
||||
->where('type', OperationRunType::InventorySync->value)
|
||||
->where('status', OperationRunStatus::Completed->value)
|
||||
->orderByDesc('completed_at')
|
||||
->orderByDesc('id')
|
||||
->first();
|
||||
|
||||
return $run instanceof OperationRun ? $run : null;
|
||||
}
|
||||
}
|
||||
|
||||
@ -4,6 +4,7 @@
|
||||
|
||||
use App\Models\OperationRun;
|
||||
use App\Models\Tenant;
|
||||
use App\Support\OpsUx\ActiveRuns;
|
||||
use App\Support\OpsUx\OpsUxBrowserEvents;
|
||||
use Filament\Facades\Filament;
|
||||
use Illuminate\Support\Collection;
|
||||
@ -85,13 +86,13 @@ public function refreshRuns(): void
|
||||
|
||||
$query = OperationRun::query()
|
||||
->where('tenant_id', $tenantId)
|
||||
->active()
|
||||
->healthyActive()
|
||||
->orderByDesc('created_at');
|
||||
|
||||
$activeCount = (clone $query)->count();
|
||||
$this->runs = (clone $query)->limit(6)->get();
|
||||
$this->overflowCount = max(0, $activeCount - 5);
|
||||
$this->hasActiveRuns = $activeCount > 0;
|
||||
$this->hasActiveRuns = ActiveRuns::existForTenantId($tenantId);
|
||||
}
|
||||
|
||||
public function render(): \Illuminate\Contracts\View\View
|
||||
|
||||
@ -20,6 +20,21 @@ class BaselineProfile extends Model
|
||||
{
|
||||
use HasFactory;
|
||||
|
||||
/**
|
||||
* @deprecated Use BaselineProfileStatus::Draft instead.
|
||||
*/
|
||||
public const string STATUS_DRAFT = 'draft';
|
||||
|
||||
/**
|
||||
* @deprecated Use BaselineProfileStatus::Active instead.
|
||||
*/
|
||||
public const string STATUS_ACTIVE = 'active';
|
||||
|
||||
/**
|
||||
* @deprecated Use BaselineProfileStatus::Archived instead.
|
||||
*/
|
||||
public const string STATUS_ARCHIVED = 'archived';
|
||||
|
||||
/** @var list<string> */
|
||||
protected $fillable = [
|
||||
'workspace_id',
|
||||
|
||||
@ -25,17 +25,12 @@ public function toDatabase(object $notifiable): array
|
||||
{
|
||||
$message = OperationUxPresenter::terminalDatabaseNotificationMessage($this->run, $notifiable);
|
||||
$reasonEnvelope = app(ReasonPresenter::class)->forOperationRun($this->run, 'notification');
|
||||
$baselineTruthChanged = data_get($this->run->context, 'baseline_capture.current_baseline_changed');
|
||||
|
||||
if ($reasonEnvelope !== null) {
|
||||
$message['reason_translation'] = $reasonEnvelope->toArray();
|
||||
$message['diagnostic_reason_code'] = $reasonEnvelope->diagnosticCode();
|
||||
}
|
||||
|
||||
if (is_bool($baselineTruthChanged)) {
|
||||
$message['baseline_truth_changed'] = $baselineTruthChanged;
|
||||
}
|
||||
|
||||
return $message;
|
||||
}
|
||||
}
|
||||
|
||||
@ -16,9 +16,6 @@
|
||||
use App\Support\Baselines\BaselineReasonCodes;
|
||||
use App\Support\Baselines\BaselineScope;
|
||||
use App\Support\Baselines\BaselineSupportCapabilityGuard;
|
||||
use App\Support\Inventory\InventoryCoverage;
|
||||
use App\Support\OperationRunOutcome;
|
||||
use App\Support\OperationRunStatus;
|
||||
use App\Support\OperationRunType;
|
||||
use InvalidArgumentException;
|
||||
|
||||
@ -65,16 +62,6 @@ public function startCapture(
|
||||
];
|
||||
}
|
||||
|
||||
$truthfulTypes = $effectiveScope->toEffectiveScopeContext($this->capabilityGuard, 'capture')['truthful_types'] ?? null;
|
||||
$inventoryEligibility = $this->latestInventoryEligibilityDecision($sourceTenant, $effectiveScope, is_array($truthfulTypes) ? $truthfulTypes : null);
|
||||
|
||||
if (! $inventoryEligibility['ok']) {
|
||||
return [
|
||||
'ok' => false,
|
||||
'reason_code' => $inventoryEligibility['reason_code'],
|
||||
];
|
||||
}
|
||||
|
||||
$captureMode = $profile->capture_mode instanceof BaselineCaptureMode
|
||||
? $profile->capture_mode
|
||||
: BaselineCaptureMode::Opportunistic;
|
||||
@ -88,10 +75,6 @@ public function startCapture(
|
||||
'source_tenant_id' => (int) $sourceTenant->getKey(),
|
||||
'effective_scope' => $effectiveScope->toEffectiveScopeContext($this->capabilityGuard, 'capture'),
|
||||
'capture_mode' => $captureMode->value,
|
||||
'baseline_capture' => [
|
||||
'inventory_sync_run_id' => $inventoryEligibility['inventory_sync_run_id'],
|
||||
'eligibility' => $this->eligibilityContextPayload($inventoryEligibility, phase: 'preflight'),
|
||||
],
|
||||
];
|
||||
|
||||
$run = $this->runs->ensureRunWithIdentity(
|
||||
@ -131,134 +114,4 @@ private function validatePreconditions(BaselineProfile $profile, Tenant $sourceT
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param list<string>|null $truthfulTypes
|
||||
* @return array{
|
||||
* ok: bool,
|
||||
* reason_code: ?string,
|
||||
* inventory_sync_run_id: ?int,
|
||||
* inventory_outcome: ?string,
|
||||
* effective_types: list<string>,
|
||||
* covered_types: list<string>,
|
||||
* uncovered_types: list<string>
|
||||
* }
|
||||
*/
|
||||
public function latestInventoryEligibilityDecision(
|
||||
Tenant $sourceTenant,
|
||||
BaselineScope $effectiveScope,
|
||||
?array $truthfulTypes = null,
|
||||
): array {
|
||||
$effectiveTypes = is_array($truthfulTypes) && $truthfulTypes !== []
|
||||
? array_values(array_unique(array_filter($truthfulTypes, 'is_string')))
|
||||
: $effectiveScope->allTypes();
|
||||
|
||||
sort($effectiveTypes, SORT_STRING);
|
||||
|
||||
$run = OperationRun::query()
|
||||
->where('tenant_id', (int) $sourceTenant->getKey())
|
||||
->where('type', OperationRunType::InventorySync->value)
|
||||
->where('status', OperationRunStatus::Completed->value)
|
||||
->orderByDesc('completed_at')
|
||||
->orderByDesc('id')
|
||||
->first();
|
||||
|
||||
if (! $run instanceof OperationRun) {
|
||||
return [
|
||||
'ok' => false,
|
||||
'reason_code' => BaselineReasonCodes::CAPTURE_INVENTORY_MISSING,
|
||||
'inventory_sync_run_id' => null,
|
||||
'inventory_outcome' => null,
|
||||
'effective_types' => $effectiveTypes,
|
||||
'covered_types' => [],
|
||||
'uncovered_types' => $effectiveTypes,
|
||||
];
|
||||
}
|
||||
|
||||
$outcome = is_string($run->outcome) ? trim($run->outcome) : null;
|
||||
|
||||
if ($outcome === OperationRunOutcome::Blocked->value) {
|
||||
return [
|
||||
'ok' => false,
|
||||
'reason_code' => BaselineReasonCodes::CAPTURE_INVENTORY_BLOCKED,
|
||||
'inventory_sync_run_id' => (int) $run->getKey(),
|
||||
'inventory_outcome' => $outcome,
|
||||
'effective_types' => $effectiveTypes,
|
||||
'covered_types' => [],
|
||||
'uncovered_types' => $effectiveTypes,
|
||||
];
|
||||
}
|
||||
|
||||
if ($outcome === OperationRunOutcome::Failed->value) {
|
||||
return [
|
||||
'ok' => false,
|
||||
'reason_code' => BaselineReasonCodes::CAPTURE_INVENTORY_FAILED,
|
||||
'inventory_sync_run_id' => (int) $run->getKey(),
|
||||
'inventory_outcome' => $outcome,
|
||||
'effective_types' => $effectiveTypes,
|
||||
'covered_types' => [],
|
||||
'uncovered_types' => $effectiveTypes,
|
||||
];
|
||||
}
|
||||
|
||||
$coverage = InventoryCoverage::fromContext($run->context);
|
||||
$coveredTypes = $coverage instanceof InventoryCoverage
|
||||
? array_values(array_intersect($effectiveTypes, $coverage->coveredTypes()))
|
||||
: [];
|
||||
|
||||
sort($coveredTypes, SORT_STRING);
|
||||
|
||||
$uncoveredTypes = array_values(array_diff($effectiveTypes, $coveredTypes));
|
||||
sort($uncoveredTypes, SORT_STRING);
|
||||
|
||||
if ($coveredTypes === []) {
|
||||
return [
|
||||
'ok' => false,
|
||||
'reason_code' => BaselineReasonCodes::CAPTURE_UNUSABLE_COVERAGE,
|
||||
'inventory_sync_run_id' => (int) $run->getKey(),
|
||||
'inventory_outcome' => $outcome,
|
||||
'effective_types' => $effectiveTypes,
|
||||
'covered_types' => [],
|
||||
'uncovered_types' => $effectiveTypes,
|
||||
];
|
||||
}
|
||||
|
||||
return [
|
||||
'ok' => true,
|
||||
'reason_code' => null,
|
||||
'inventory_sync_run_id' => (int) $run->getKey(),
|
||||
'inventory_outcome' => $outcome,
|
||||
'effective_types' => $effectiveTypes,
|
||||
'covered_types' => $coveredTypes,
|
||||
'uncovered_types' => $uncoveredTypes,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array{
|
||||
* ok: bool,
|
||||
* reason_code: ?string,
|
||||
* inventory_sync_run_id: ?int,
|
||||
* inventory_outcome: ?string,
|
||||
* effective_types: list<string>,
|
||||
* covered_types: list<string>,
|
||||
* uncovered_types: list<string>
|
||||
* } $decision
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public function eligibilityContextPayload(array $decision, string $phase): array
|
||||
{
|
||||
return [
|
||||
'phase' => $phase,
|
||||
'ok' => (bool) ($decision['ok'] ?? false),
|
||||
'reason_code' => is_string($decision['reason_code'] ?? null) ? $decision['reason_code'] : null,
|
||||
'inventory_sync_run_id' => is_numeric($decision['inventory_sync_run_id'] ?? null)
|
||||
? (int) $decision['inventory_sync_run_id']
|
||||
: null,
|
||||
'inventory_outcome' => is_string($decision['inventory_outcome'] ?? null) ? $decision['inventory_outcome'] : null,
|
||||
'effective_types' => array_values(array_filter((array) ($decision['effective_types'] ?? []), 'is_string')),
|
||||
'covered_types' => array_values(array_filter((array) ($decision['covered_types'] ?? []), 'is_string')),
|
||||
'uncovered_types' => array_values(array_filter((array) ($decision['uncovered_types'] ?? []), 'is_string')),
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
@ -68,27 +68,12 @@ public function issueQuery(
|
||||
string $reasonFilter = self::FILTER_ALL,
|
||||
bool $applyOrdering = true,
|
||||
): Builder {
|
||||
return $this->issueQueryForVisibleTenantIds(
|
||||
$workspace,
|
||||
$this->visibleTenantIds($workspace, $user),
|
||||
$tenantId,
|
||||
$reasonFilter,
|
||||
$applyOrdering,
|
||||
$visibleTenants = $this->visibleTenants($workspace, $user);
|
||||
$visibleTenantIds = array_map(
|
||||
static fn (Tenant $tenant): int => (int) $tenant->getKey(),
|
||||
$visibleTenants,
|
||||
);
|
||||
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<int, int> $visibleTenantIds
|
||||
* @return Builder<Finding>
|
||||
*/
|
||||
private function issueQueryForVisibleTenantIds(
|
||||
Workspace $workspace,
|
||||
array $visibleTenantIds,
|
||||
?int $tenantId = null,
|
||||
string $reasonFilter = self::FILTER_ALL,
|
||||
bool $applyOrdering = true,
|
||||
): Builder {
|
||||
if ($tenantId !== null && ! in_array($tenantId, $visibleTenantIds, true)) {
|
||||
$visibleTenantIds = [];
|
||||
} elseif ($tenantId !== null) {
|
||||
@ -170,22 +155,9 @@ function ($join): void {
|
||||
*/
|
||||
public function summary(Workspace $workspace, User $user, ?int $tenantId = null): array
|
||||
{
|
||||
return $this->summaryForVisibleTenantIds(
|
||||
$workspace,
|
||||
$this->visibleTenantIds($workspace, $user),
|
||||
$tenantId,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<int, int> $visibleTenantIds
|
||||
* @return array{unique_issue_count: int, broken_assignment_count: int, stale_in_progress_count: int}
|
||||
*/
|
||||
public function summaryForVisibleTenantIds(Workspace $workspace, array $visibleTenantIds, ?int $tenantId = null): array
|
||||
{
|
||||
$allIssues = $this->issueQueryForVisibleTenantIds($workspace, $visibleTenantIds, $tenantId, self::FILTER_ALL, applyOrdering: false);
|
||||
$brokenAssignments = $this->issueQueryForVisibleTenantIds($workspace, $visibleTenantIds, $tenantId, self::REASON_BROKEN_ASSIGNMENT, applyOrdering: false);
|
||||
$staleInProgress = $this->issueQueryForVisibleTenantIds($workspace, $visibleTenantIds, $tenantId, self::REASON_STALE_IN_PROGRESS, applyOrdering: false);
|
||||
$allIssues = $this->issueQuery($workspace, $user, $tenantId, self::FILTER_ALL, applyOrdering: false);
|
||||
$brokenAssignments = $this->issueQuery($workspace, $user, $tenantId, self::REASON_BROKEN_ASSIGNMENT, applyOrdering: false);
|
||||
$staleInProgress = $this->issueQuery($workspace, $user, $tenantId, self::REASON_STALE_IN_PROGRESS, applyOrdering: false);
|
||||
|
||||
return [
|
||||
'unique_issue_count' => (clone $allIssues)->count(),
|
||||
@ -194,17 +166,6 @@ public function summaryForVisibleTenantIds(Workspace $workspace, array $visibleT
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<int, int>
|
||||
*/
|
||||
public function visibleTenantIds(Workspace $workspace, User $user): array
|
||||
{
|
||||
return array_map(
|
||||
static fn (Tenant $tenant): int => (int) $tenant->getKey(),
|
||||
$this->visibleTenants($workspace, $user),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, string>
|
||||
*/
|
||||
|
||||
@ -29,8 +29,6 @@
|
||||
use App\Support\ReasonTranslation\ReasonResolutionEnvelope;
|
||||
use App\Support\ReasonTranslation\ReasonTranslator;
|
||||
use App\Support\Tenants\TenantOperabilityReasonCode;
|
||||
use App\Support\Verification\BlockedVerificationReportFactory;
|
||||
use App\Support\Verification\VerificationReportWriter;
|
||||
use Illuminate\Database\QueryException;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use InvalidArgumentException;
|
||||
@ -944,23 +942,11 @@ public function finalizeExecutionLegitimacyBlockedRun(
|
||||
'context' => $context,
|
||||
]);
|
||||
|
||||
$run = $this->finalizeBlockedRun(
|
||||
return $this->finalizeBlockedRun(
|
||||
run: $run->fresh(),
|
||||
reasonCode: $decision->reasonCode?->value ?? ExecutionDenialReasonCode::ExecutionPrerequisiteInvalid->value,
|
||||
message: $decision->reasonCode?->message() ?? 'Operation blocked before queued execution could begin.',
|
||||
);
|
||||
|
||||
if ($run->type === 'provider.connection.check') {
|
||||
VerificationReportWriter::write(
|
||||
run: $run,
|
||||
checks: BlockedVerificationReportFactory::checks($run),
|
||||
identity: BlockedVerificationReportFactory::identity($run),
|
||||
);
|
||||
|
||||
$run->refresh();
|
||||
}
|
||||
|
||||
return $run;
|
||||
}
|
||||
|
||||
private function invokeDispatcher(callable $dispatcher, OperationRun $run): void
|
||||
|
||||
@ -11,7 +11,6 @@
|
||||
use App\Models\Tenant;
|
||||
use App\Models\User;
|
||||
use App\Services\Auth\CapabilityResolver;
|
||||
use App\Services\Auth\WorkspaceCapabilityResolver;
|
||||
use App\Services\Tenants\TenantOperabilityService;
|
||||
use App\Support\Operations\ExecutionAuthorityMode;
|
||||
use App\Support\Operations\ExecutionDenialReasonCode;
|
||||
@ -35,7 +34,6 @@ class QueuedExecutionLegitimacyGate
|
||||
public function __construct(
|
||||
private readonly OperationRunCapabilityResolver $operationRunCapabilityResolver,
|
||||
private readonly CapabilityResolver $capabilityResolver,
|
||||
private readonly WorkspaceCapabilityResolver $workspaceCapabilityResolver,
|
||||
private readonly TenantOperabilityService $tenantOperabilityService,
|
||||
private readonly WriteGateInterface $writeGate,
|
||||
) {}
|
||||
@ -73,8 +71,12 @@ public function evaluate(OperationRun $run): QueuedExecutionLegitimacyDecision
|
||||
return QueuedExecutionLegitimacyDecision::deny($context, $checks, ExecutionDenialReasonCode::InitiatorNotEntitled);
|
||||
}
|
||||
|
||||
if ($context->requiredCapability !== null) {
|
||||
$checks['capability'] = $this->initiatorHasRequiredCapability($context) ? 'passed' : 'failed';
|
||||
if ($context->requiredCapability !== null && $context->tenant instanceof Tenant) {
|
||||
$checks['capability'] = $this->capabilityResolver->can(
|
||||
$context->initiator,
|
||||
$context->tenant,
|
||||
$context->requiredCapability,
|
||||
) ? 'passed' : 'failed';
|
||||
|
||||
if ($checks['capability'] === 'failed') {
|
||||
return QueuedExecutionLegitimacyDecision::deny(
|
||||
@ -104,7 +106,7 @@ public function evaluate(OperationRun $run): QueuedExecutionLegitimacyDecision
|
||||
tenant: $context->tenant,
|
||||
question: $operabilityQuestion,
|
||||
workspaceId: $context->workspaceId,
|
||||
lane: $this->laneForContext($context),
|
||||
lane: TenantInteractionLane::AdministrativeManagement,
|
||||
);
|
||||
|
||||
$checks['tenant_operability'] = $operability->allowed ? 'passed' : 'failed';
|
||||
@ -226,35 +228,6 @@ private function resolveProviderConnectionId(array $context): ?int
|
||||
return is_numeric($providerConnectionId) ? (int) $providerConnectionId : null;
|
||||
}
|
||||
|
||||
private function initiatorHasRequiredCapability(QueuedExecutionContext $context): bool
|
||||
{
|
||||
if (! $context->initiator instanceof User || ! is_string($context->requiredCapability) || $context->requiredCapability === '') {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (str_starts_with($context->requiredCapability, 'workspace')) {
|
||||
if ($context->workspaceId <= 0) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return $this->workspaceCapabilityResolver->can(
|
||||
$context->initiator,
|
||||
$context->run->tenant?->workspace ?? $context->run->workspace()->firstOrFail(),
|
||||
$context->requiredCapability,
|
||||
);
|
||||
}
|
||||
|
||||
if (! $context->tenant instanceof Tenant) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return $this->capabilityResolver->can(
|
||||
$context->initiator,
|
||||
$context->tenant,
|
||||
$context->requiredCapability,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return list<string>
|
||||
*/
|
||||
@ -289,16 +262,4 @@ private function requiresWriteGate(QueuedExecutionContext $context): bool
|
||||
{
|
||||
return in_array('write_gate', $context->prerequisiteClasses, true);
|
||||
}
|
||||
|
||||
private function laneForContext(QueuedExecutionContext $context): TenantInteractionLane
|
||||
{
|
||||
$runContext = is_array($context->run->context) ? $context->run->context : [];
|
||||
$wizardFlow = data_get($runContext, 'wizard.flow');
|
||||
|
||||
if (is_string($wizardFlow) && trim($wizardFlow) === 'managed_tenant_onboarding') {
|
||||
return TenantInteractionLane::OnboardingWorkflow;
|
||||
}
|
||||
|
||||
return TenantInteractionLane::AdministrativeManagement;
|
||||
}
|
||||
}
|
||||
|
||||
@ -38,6 +38,7 @@ final class BadgeCatalog
|
||||
BadgeDomain::BooleanEnabled->value => Domains\BooleanEnabledBadge::class,
|
||||
BadgeDomain::BooleanHasErrors->value => Domains\BooleanHasErrorsBadge::class,
|
||||
BadgeDomain::TenantStatus->value => Domains\TenantStatusBadge::class,
|
||||
BadgeDomain::TenantAppStatus->value => Domains\TenantAppStatusBadge::class,
|
||||
BadgeDomain::TenantRbacStatus->value => Domains\TenantRbacStatusBadge::class,
|
||||
BadgeDomain::TenantPermissionStatus->value => Domains\TenantPermissionStatusBadge::class,
|
||||
BadgeDomain::PolicySnapshotMode->value => Domains\PolicySnapshotModeBadge::class,
|
||||
|
||||
@ -29,6 +29,7 @@ enum BadgeDomain: string
|
||||
case BooleanEnabled = 'boolean_enabled';
|
||||
case BooleanHasErrors = 'boolean_has_errors';
|
||||
case TenantStatus = 'tenant_status';
|
||||
case TenantAppStatus = 'tenant_app_status';
|
||||
case TenantRbacStatus = 'tenant_rbac_status';
|
||||
case TenantPermissionStatus = 'tenant_permission_status';
|
||||
case PolicySnapshotMode = 'policy_snapshot_mode';
|
||||
|
||||
@ -0,0 +1,24 @@
|
||||
<?php
|
||||
|
||||
namespace App\Support\Badges\Domains;
|
||||
|
||||
use App\Support\Badges\BadgeCatalog;
|
||||
use App\Support\Badges\BadgeMapper;
|
||||
use App\Support\Badges\BadgeSpec;
|
||||
|
||||
final class TenantAppStatusBadge implements BadgeMapper
|
||||
{
|
||||
public function spec(mixed $value): BadgeSpec
|
||||
{
|
||||
$state = BadgeCatalog::normalizeState($value);
|
||||
|
||||
return match ($state) {
|
||||
'ok' => new BadgeSpec('OK', 'success', 'heroicon-m-check-circle'),
|
||||
'configured' => new BadgeSpec('Configured', 'success', 'heroicon-m-check-circle'),
|
||||
'pending' => new BadgeSpec('Pending', 'warning', 'heroicon-m-clock'),
|
||||
'requires_consent', 'consent_required' => new BadgeSpec('Consent required', 'warning', 'heroicon-m-exclamation-triangle'),
|
||||
'error' => new BadgeSpec('Error', 'danger', 'heroicon-m-x-circle'),
|
||||
default => BadgeSpec::unknown(),
|
||||
};
|
||||
}
|
||||
}
|
||||
@ -14,7 +14,6 @@
|
||||
use App\Services\Baselines\BaselineSnapshotTruthResolver;
|
||||
use App\Support\OperationRunStatus;
|
||||
use App\Support\OperationRunType;
|
||||
use App\Support\ReasonTranslation\ReasonPresenter;
|
||||
use App\Support\Ui\OperatorExplanation\CountDescriptor;
|
||||
use App\Support\Ui\OperatorExplanation\OperatorExplanationPattern;
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
@ -121,8 +120,7 @@ public static function forTenant(?Tenant $tenant): self
|
||||
$effectiveSnapshot = $truthResolution['effective_snapshot'] ?? null;
|
||||
$snapshotId = $effectiveSnapshot instanceof BaselineSnapshot ? (int) $effectiveSnapshot->getKey() : null;
|
||||
$snapshotReasonCode = is_string($truthResolution['reason_code'] ?? null) ? (string) $truthResolution['reason_code'] : null;
|
||||
$latestCaptureRun = self::latestBaselineCaptureRun($profile);
|
||||
$snapshotReasonMessage = self::missingSnapshotMessage($snapshotReasonCode, $latestCaptureRun);
|
||||
$snapshotReasonMessage = self::missingSnapshotMessage($snapshotReasonCode);
|
||||
|
||||
try {
|
||||
$profileScope = $profile->normalizedScope();
|
||||
@ -907,35 +905,8 @@ private static function empty(
|
||||
);
|
||||
}
|
||||
|
||||
private static function latestBaselineCaptureRun(BaselineProfile $profile): ?OperationRun
|
||||
private static function missingSnapshotMessage(?string $reasonCode): ?string
|
||||
{
|
||||
return OperationRun::query()
|
||||
->where('workspace_id', (int) $profile->workspace_id)
|
||||
->where('type', OperationRunType::BaselineCapture->value)
|
||||
->where('context->baseline_profile_id', (int) $profile->getKey())
|
||||
->where('status', OperationRunStatus::Completed->value)
|
||||
->orderByDesc('completed_at')
|
||||
->orderByDesc('id')
|
||||
->first();
|
||||
}
|
||||
|
||||
private static function missingSnapshotMessage(?string $reasonCode, ?OperationRun $latestCaptureRun = null): ?string
|
||||
{
|
||||
$latestCaptureEnvelope = $latestCaptureRun instanceof OperationRun
|
||||
? app(ReasonPresenter::class)->forOperationRun($latestCaptureRun, 'artifact_truth')
|
||||
: null;
|
||||
|
||||
if ($latestCaptureEnvelope !== null
|
||||
&& in_array($latestCaptureEnvelope->internalCode, [
|
||||
BaselineReasonCodes::CAPTURE_INVENTORY_BLOCKED,
|
||||
BaselineReasonCodes::CAPTURE_INVENTORY_FAILED,
|
||||
BaselineReasonCodes::CAPTURE_UNUSABLE_COVERAGE,
|
||||
BaselineReasonCodes::CAPTURE_ZERO_SUBJECTS,
|
||||
], true)
|
||||
&& trim($latestCaptureEnvelope->shortExplanation) !== '') {
|
||||
return $latestCaptureEnvelope->shortExplanation;
|
||||
}
|
||||
|
||||
return match ($reasonCode) {
|
||||
BaselineReasonCodes::COMPARE_SNAPSHOT_BUILDING => 'The latest baseline capture is still building. Compare becomes available after a complete snapshot is finalized.',
|
||||
BaselineReasonCodes::COMPARE_SNAPSHOT_INCOMPLETE => 'The latest baseline capture is incomplete and there is no current complete snapshot to compare against.',
|
||||
|
||||
@ -22,16 +22,6 @@ final class BaselineReasonCodes
|
||||
|
||||
public const string CAPTURE_UNSUPPORTED_SCOPE = 'baseline.capture.unsupported_scope';
|
||||
|
||||
public const string CAPTURE_INVENTORY_MISSING = 'baseline.capture.inventory_missing';
|
||||
|
||||
public const string CAPTURE_INVENTORY_BLOCKED = 'baseline.capture.inventory_blocked';
|
||||
|
||||
public const string CAPTURE_INVENTORY_FAILED = 'baseline.capture.inventory_failed';
|
||||
|
||||
public const string CAPTURE_UNUSABLE_COVERAGE = 'baseline.capture.unusable_coverage';
|
||||
|
||||
public const string CAPTURE_ZERO_SUBJECTS = 'baseline.capture.zero_subjects';
|
||||
|
||||
public const string SNAPSHOT_BUILDING = 'baseline.snapshot.building';
|
||||
|
||||
public const string SNAPSHOT_INCOMPLETE = 'baseline.snapshot.incomplete';
|
||||
@ -83,11 +73,6 @@ public static function all(): array
|
||||
self::CAPTURE_ROLLOUT_DISABLED,
|
||||
self::CAPTURE_INVALID_SCOPE,
|
||||
self::CAPTURE_UNSUPPORTED_SCOPE,
|
||||
self::CAPTURE_INVENTORY_MISSING,
|
||||
self::CAPTURE_INVENTORY_BLOCKED,
|
||||
self::CAPTURE_INVENTORY_FAILED,
|
||||
self::CAPTURE_UNUSABLE_COVERAGE,
|
||||
self::CAPTURE_ZERO_SUBJECTS,
|
||||
self::SNAPSHOT_BUILDING,
|
||||
self::SNAPSHOT_INCOMPLETE,
|
||||
self::SNAPSHOT_SUPERSEDED,
|
||||
@ -143,12 +128,7 @@ public static function trustImpact(?string $reasonCode): ?string
|
||||
self::CAPTURE_MISSING_SOURCE_TENANT,
|
||||
self::CAPTURE_PROFILE_NOT_ACTIVE,
|
||||
self::CAPTURE_INVALID_SCOPE,
|
||||
self::CAPTURE_UNSUPPORTED_SCOPE,
|
||||
self::CAPTURE_INVENTORY_MISSING,
|
||||
self::CAPTURE_INVENTORY_BLOCKED,
|
||||
self::CAPTURE_INVENTORY_FAILED,
|
||||
self::CAPTURE_UNUSABLE_COVERAGE,
|
||||
self::CAPTURE_ZERO_SUBJECTS => 'unusable',
|
||||
self::CAPTURE_UNSUPPORTED_SCOPE => 'unusable',
|
||||
default => null,
|
||||
};
|
||||
}
|
||||
@ -168,10 +148,6 @@ public static function absencePattern(?string $reasonCode): ?string
|
||||
self::CAPTURE_MISSING_SOURCE_TENANT,
|
||||
self::CAPTURE_PROFILE_NOT_ACTIVE,
|
||||
self::CAPTURE_ROLLOUT_DISABLED,
|
||||
self::CAPTURE_INVENTORY_MISSING,
|
||||
self::CAPTURE_INVENTORY_BLOCKED,
|
||||
self::CAPTURE_INVENTORY_FAILED,
|
||||
self::CAPTURE_UNUSABLE_COVERAGE,
|
||||
self::COMPARE_NO_ASSIGNMENT,
|
||||
self::COMPARE_PROFILE_NOT_ACTIVE,
|
||||
self::COMPARE_NO_ELIGIBLE_TARGET,
|
||||
@ -183,7 +159,6 @@ public static function absencePattern(?string $reasonCode): ?string
|
||||
self::SNAPSHOT_SUPERSEDED,
|
||||
self::COMPARE_SNAPSHOT_SUPERSEDED => 'blocked_prerequisite',
|
||||
self::SNAPSHOT_CAPTURE_FAILED => 'unavailable',
|
||||
self::CAPTURE_ZERO_SUBJECTS => 'missing_input',
|
||||
self::CAPTURE_INVALID_SCOPE,
|
||||
self::CAPTURE_UNSUPPORTED_SCOPE => 'unavailable',
|
||||
default => null,
|
||||
|
||||
@ -6,89 +6,19 @@
|
||||
|
||||
class PanelThemeAsset
|
||||
{
|
||||
/**
|
||||
* @var array<string, bool>
|
||||
*/
|
||||
private static array $hotAssetReachability = [];
|
||||
|
||||
public static function resolve(string $entry): ?string
|
||||
{
|
||||
if (app()->runningUnitTests()) {
|
||||
return static::resolveFromManifest($entry);
|
||||
}
|
||||
|
||||
if (static::shouldUseHotAsset($entry)) {
|
||||
if (is_file(public_path('hot'))) {
|
||||
return Vite::asset($entry);
|
||||
}
|
||||
|
||||
return static::resolveFromManifest($entry);
|
||||
}
|
||||
|
||||
private static function shouldUseHotAsset(string $entry): bool
|
||||
{
|
||||
$hotFile = public_path('hot');
|
||||
|
||||
if (! is_file($hotFile)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$hotUrl = trim((string) file_get_contents($hotFile));
|
||||
|
||||
if ($hotUrl === '') {
|
||||
return false;
|
||||
}
|
||||
|
||||
$assetUrl = Vite::asset($entry);
|
||||
|
||||
if ($assetUrl === '') {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (array_key_exists($assetUrl, static::$hotAssetReachability)) {
|
||||
return static::$hotAssetReachability[$assetUrl];
|
||||
}
|
||||
|
||||
$parts = parse_url($assetUrl);
|
||||
|
||||
if (! is_array($parts)) {
|
||||
return static::$hotAssetReachability[$assetUrl] = false;
|
||||
}
|
||||
|
||||
$host = $parts['host'] ?? null;
|
||||
|
||||
if (! is_string($host) || $host === '') {
|
||||
return static::$hotAssetReachability[$assetUrl] = false;
|
||||
}
|
||||
|
||||
$scheme = $parts['scheme'] ?? 'http';
|
||||
$port = $parts['port'] ?? ($scheme === 'https' ? 443 : 80);
|
||||
$transport = $scheme === 'https' ? 'ssl://' : '';
|
||||
$connection = @fsockopen($transport.$host, $port, $errorNumber, $errorMessage, 0.2);
|
||||
|
||||
if (! is_resource($connection)) {
|
||||
return static::$hotAssetReachability[$assetUrl] = false;
|
||||
}
|
||||
|
||||
$path = ($parts['path'] ?? '/').(isset($parts['query']) ? '?'.$parts['query'] : '');
|
||||
$hostHeader = isset($parts['port']) ? $host.':'.$port : $host;
|
||||
|
||||
stream_set_timeout($connection, 0, 200000);
|
||||
fwrite(
|
||||
$connection,
|
||||
"HEAD {$path} HTTP/1.1\r\nHost: {$hostHeader}\r\nConnection: close\r\n\r\n",
|
||||
);
|
||||
|
||||
$statusLine = fgets($connection);
|
||||
|
||||
fclose($connection);
|
||||
|
||||
if (! is_string($statusLine)) {
|
||||
return static::$hotAssetReachability[$assetUrl] = false;
|
||||
}
|
||||
|
||||
return static::$hotAssetReachability[$assetUrl] = preg_match('/^HTTP\/\d\.\d\s+[23]\d\d\b/', $statusLine) === 1;
|
||||
}
|
||||
|
||||
private static function resolveFromManifest(string $entry): ?string
|
||||
{
|
||||
$manifest = public_path('build/manifest.json');
|
||||
|
||||
@ -202,7 +202,7 @@ public function auditTargetLink(AuditLog $record): ?array
|
||||
->whereKey($resourceId)
|
||||
->where('workspace_id', (int) $workspace->getKey())
|
||||
->exists()
|
||||
? ['label' => OperationRunLinks::openLabel(), 'url' => OperationRunLinks::tenantlessView($resourceId)]
|
||||
? ['label' => OperationRunLinks::openLabel(), 'url' => route('admin.operations.view', ['run' => $resourceId])]
|
||||
: null,
|
||||
'baseline_profile' => $workspace instanceof Workspace
|
||||
&& $this->workspaceCapabilityResolver->isMember($user, $workspace)
|
||||
|
||||
@ -81,7 +81,6 @@ public static function index(
|
||||
?string $activeTab = null,
|
||||
bool $allTenants = false,
|
||||
?string $problemClass = null,
|
||||
?string $operationType = null,
|
||||
): string {
|
||||
$parameters = $context?->toQuery() ?? [];
|
||||
|
||||
@ -107,10 +106,6 @@ public static function index(
|
||||
}
|
||||
}
|
||||
|
||||
if (is_string($operationType) && $operationType !== '') {
|
||||
$parameters['tableFilters']['type']['value'] = $operationType;
|
||||
}
|
||||
|
||||
return route('admin.operations.index', $parameters);
|
||||
}
|
||||
|
||||
|
||||
@ -8,7 +8,6 @@
|
||||
use App\Support\Badges\BadgeCatalog;
|
||||
use App\Support\Badges\BadgeDomain;
|
||||
use App\Support\Baselines\BaselineCompareReasonCode;
|
||||
use App\Support\Baselines\BaselineReasonCodes;
|
||||
use App\Support\OperationCatalog;
|
||||
use App\Support\ReasonTranslation\ReasonPresenter;
|
||||
use App\Support\ReasonTranslation\ReasonResolutionEnvelope;
|
||||
@ -142,37 +141,9 @@ private function baselineCaptureHeadline(
|
||||
array $counts,
|
||||
?OperatorExplanationPattern $operatorExplanation,
|
||||
): string {
|
||||
$reasonCode = (string) data_get($context, 'baseline_capture.reason_code', data_get($context, 'reason_code', ''));
|
||||
$subjectsTotal = $this->intValue(data_get($context, 'baseline_capture.subjects_total'));
|
||||
$resumeToken = data_get($context, 'baseline_capture.resume_token');
|
||||
$gapCount = $this->intValue(data_get($context, 'baseline_capture.gaps.count'));
|
||||
$changedAfterEnqueue = data_get($context, 'baseline_capture.eligibility.changed_after_enqueue') === true;
|
||||
|
||||
if ($reasonCode === BaselineReasonCodes::CAPTURE_INVENTORY_MISSING) {
|
||||
return 'The baseline capture could not continue because no current inventory basis was available.';
|
||||
}
|
||||
|
||||
if ($reasonCode === BaselineReasonCodes::CAPTURE_INVENTORY_BLOCKED) {
|
||||
return $changedAfterEnqueue
|
||||
? 'The baseline capture stopped because the latest inventory sync changed after the run was queued.'
|
||||
: 'The baseline capture was blocked because the latest inventory sync was blocked.';
|
||||
}
|
||||
|
||||
if ($reasonCode === BaselineReasonCodes::CAPTURE_INVENTORY_FAILED) {
|
||||
return $changedAfterEnqueue
|
||||
? 'The baseline capture stopped because the latest inventory sync failed after the run was queued.'
|
||||
: 'The baseline capture was blocked because the latest inventory sync failed.';
|
||||
}
|
||||
|
||||
if ($reasonCode === BaselineReasonCodes::CAPTURE_UNUSABLE_COVERAGE) {
|
||||
return $changedAfterEnqueue
|
||||
? 'The baseline capture stopped because the latest inventory coverage became unusable after the run was queued.'
|
||||
: 'The baseline capture could not produce a usable baseline because the latest inventory coverage was not credible.';
|
||||
}
|
||||
|
||||
if ($reasonCode === BaselineReasonCodes::CAPTURE_ZERO_SUBJECTS) {
|
||||
return 'The baseline capture finished without a usable baseline because no governed subjects were in scope.';
|
||||
}
|
||||
|
||||
if ($subjectsTotal === 0) {
|
||||
return 'No baseline was captured because no governed subjects were ready.';
|
||||
@ -658,55 +629,9 @@ private function pushCandidate(array &$candidates, ?string $code, ?string $label
|
||||
*/
|
||||
private function baselineCaptureCandidates(array &$candidates, array $context): void
|
||||
{
|
||||
$reasonCode = (string) data_get($context, 'baseline_capture.reason_code', data_get($context, 'reason_code', ''));
|
||||
$subjectsTotal = $this->intValue(data_get($context, 'baseline_capture.subjects_total'));
|
||||
$gapCount = $this->intValue(data_get($context, 'baseline_capture.gaps.count'));
|
||||
$resumeToken = data_get($context, 'baseline_capture.resume_token');
|
||||
$changedAfterEnqueue = data_get($context, 'baseline_capture.eligibility.changed_after_enqueue') === true;
|
||||
|
||||
if ($reasonCode === BaselineReasonCodes::CAPTURE_INVENTORY_MISSING) {
|
||||
$this->pushCandidate($candidates, $reasonCode, 'Run tenant sync first', 'No current inventory basis was available for this baseline capture.', 95);
|
||||
}
|
||||
|
||||
if ($reasonCode === BaselineReasonCodes::CAPTURE_INVENTORY_BLOCKED) {
|
||||
$this->pushCandidate(
|
||||
$candidates,
|
||||
$reasonCode,
|
||||
'Latest inventory sync was blocked',
|
||||
$changedAfterEnqueue
|
||||
? 'The latest inventory sync changed after the run was queued and blocked the capture.'
|
||||
: 'The latest inventory sync was blocked before this capture could produce a trustworthy baseline.',
|
||||
95,
|
||||
);
|
||||
}
|
||||
|
||||
if ($reasonCode === BaselineReasonCodes::CAPTURE_INVENTORY_FAILED) {
|
||||
$this->pushCandidate(
|
||||
$candidates,
|
||||
$reasonCode,
|
||||
'Latest inventory sync failed',
|
||||
$changedAfterEnqueue
|
||||
? 'The latest inventory sync failed after the run was queued, so the capture stopped without refreshing baseline truth.'
|
||||
: 'The latest inventory sync failed before this capture could produce a trustworthy baseline.',
|
||||
95,
|
||||
);
|
||||
}
|
||||
|
||||
if ($reasonCode === BaselineReasonCodes::CAPTURE_UNUSABLE_COVERAGE) {
|
||||
$this->pushCandidate(
|
||||
$candidates,
|
||||
$reasonCode,
|
||||
'Latest inventory coverage unusable',
|
||||
$changedAfterEnqueue
|
||||
? 'The latest inventory coverage became unusable after the run was queued, so the capture stopped without refreshing baseline truth.'
|
||||
: 'The latest inventory sync did not produce usable governed-subject coverage for this baseline capture.',
|
||||
95,
|
||||
);
|
||||
}
|
||||
|
||||
if ($reasonCode === BaselineReasonCodes::CAPTURE_ZERO_SUBJECTS) {
|
||||
$this->pushCandidate($candidates, $reasonCode, 'No subjects were in scope', 'No governed subjects were available for this baseline capture.', 95);
|
||||
}
|
||||
|
||||
if ($subjectsTotal === 0) {
|
||||
$this->pushCandidate($candidates, 'no_subjects_in_scope', 'No governed subjects captured', 'No governed subjects were available for this baseline capture.', 95);
|
||||
|
||||
@ -547,11 +547,6 @@ private static function terminalSupportingLines(OperationRun $run): array
|
||||
$lines[] = $guidance;
|
||||
}
|
||||
|
||||
$baselineTruthChange = self::baselineTruthChangeLine($run);
|
||||
if ($baselineTruthChange !== null) {
|
||||
$lines[] = $baselineTruthChange;
|
||||
}
|
||||
|
||||
$summary = SummaryCountsNormalizer::renderSummaryLine(is_array($run->summary_counts) ? $run->summary_counts : []);
|
||||
if ($summary !== null) {
|
||||
$lines[] = $summary;
|
||||
@ -565,25 +560,6 @@ private static function terminalSupportingLines(OperationRun $run): array
|
||||
return array_values(array_filter($lines, static fn (string $line): bool => trim($line) !== ''));
|
||||
}
|
||||
|
||||
private static function baselineTruthChangeLine(OperationRun $run): ?string
|
||||
{
|
||||
if ((string) $run->type !== 'baseline_capture') {
|
||||
return null;
|
||||
}
|
||||
|
||||
$changed = data_get($run->context, 'baseline_capture.current_baseline_changed');
|
||||
|
||||
if ($changed === true) {
|
||||
return 'Current baseline truth was updated.';
|
||||
}
|
||||
|
||||
if ($changed === false) {
|
||||
return 'Current baseline truth was unchanged.';
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{label:string, url:?string, target:string}
|
||||
*/
|
||||
|
||||
@ -44,7 +44,6 @@ public function forOperationRun(OperationRun $run, string $surface = 'detail'):
|
||||
|
||||
$contextReasonCode = data_get($context, 'execution_legitimacy.reason_code')
|
||||
?? data_get($context, 'reason_code')
|
||||
?? data_get($context, 'baseline_capture.reason_code')
|
||||
?? data_get($context, 'baseline_compare.reason_code');
|
||||
|
||||
if (is_string($contextReasonCode) && trim($contextReasonCode) !== '') {
|
||||
|
||||
@ -51,8 +51,8 @@ public function translate(
|
||||
$artifactKey === null && $this->providerReasonTranslator->canTranslate($reasonCode) => $this->providerReasonTranslator->translate($reasonCode, $surface, $context),
|
||||
$artifactKey === self::GOVERNANCE_ARTIFACT_TRUTH_ARTIFACT && BaselineCompareReasonCode::tryFrom($reasonCode) instanceof BaselineCompareReasonCode => $this->translateBaselineCompareReason($reasonCode),
|
||||
$artifactKey === null && BaselineCompareReasonCode::tryFrom($reasonCode) instanceof BaselineCompareReasonCode => $this->translateBaselineCompareReason($reasonCode),
|
||||
$artifactKey === self::GOVERNANCE_ARTIFACT_TRUTH_ARTIFACT && BaselineReasonCodes::isKnown($reasonCode) => $this->translateBaselineReason($reasonCode, $context),
|
||||
$artifactKey === null && BaselineReasonCodes::isKnown($reasonCode) => $this->translateBaselineReason($reasonCode, $context),
|
||||
$artifactKey === self::GOVERNANCE_ARTIFACT_TRUTH_ARTIFACT && BaselineReasonCodes::isKnown($reasonCode) => $this->translateBaselineReason($reasonCode),
|
||||
$artifactKey === null && BaselineReasonCodes::isKnown($reasonCode) => $this->translateBaselineReason($reasonCode),
|
||||
$artifactKey === self::EXECUTION_DENIAL_ARTIFACT,
|
||||
$artifactKey === null && ExecutionDenialReasonCode::tryFrom($reasonCode) instanceof ExecutionDenialReasonCode => ExecutionDenialReasonCode::tryFrom($reasonCode)?->toReasonResolutionEnvelope($surface, $context),
|
||||
$artifactKey === null && LifecycleReconciliationReason::tryFrom($reasonCode) instanceof LifecycleReconciliationReason => LifecycleReconciliationReason::tryFrom($reasonCode)?->toReasonResolutionEnvelope($surface, $context),
|
||||
@ -116,10 +116,7 @@ private function fallbackTranslate(
|
||||
return $this->fallbackReasonTranslator->translate($reasonCode, $surface, $context);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $context
|
||||
*/
|
||||
private function translateBaselineReason(string $reasonCode, array $context = []): ReasonResolutionEnvelope
|
||||
private function translateBaselineReason(string $reasonCode): ReasonResolutionEnvelope
|
||||
{
|
||||
[$operatorLabel, $shortExplanation, $actionability, $nextStep] = match ($reasonCode) {
|
||||
BaselineReasonCodes::CAPTURE_MISSING_SOURCE_TENANT => [
|
||||
@ -141,51 +138,6 @@ private function translateBaselineReason(string $reasonCode, array $context = []
|
||||
'prerequisite_missing',
|
||||
'Enable the rollout before retrying full-content baseline work.',
|
||||
],
|
||||
BaselineReasonCodes::CAPTURE_INVENTORY_MISSING => [
|
||||
'Run tenant sync first',
|
||||
$this->baselineCaptureTruthImpactExplanation(
|
||||
'No current inventory basis was available for this baseline capture.',
|
||||
$context,
|
||||
),
|
||||
'prerequisite_missing',
|
||||
'Run inventory sync for this tenant, then capture the baseline again.',
|
||||
],
|
||||
BaselineReasonCodes::CAPTURE_INVENTORY_BLOCKED => [
|
||||
'Latest inventory sync was blocked',
|
||||
$this->baselineCaptureTruthImpactExplanation(
|
||||
'The latest inventory sync was blocked, so this capture could not use a credible upstream basis.',
|
||||
$context,
|
||||
),
|
||||
'prerequisite_missing',
|
||||
'Review the blocked inventory sync, fix the prerequisite, and rerun sync before capturing again.',
|
||||
],
|
||||
BaselineReasonCodes::CAPTURE_INVENTORY_FAILED => [
|
||||
'Latest inventory sync failed',
|
||||
$this->baselineCaptureTruthImpactExplanation(
|
||||
'The latest inventory sync failed, so this capture could not use a credible upstream basis.',
|
||||
$context,
|
||||
),
|
||||
'prerequisite_missing',
|
||||
'Review the failed inventory sync, fix the error, and rerun sync before capturing again.',
|
||||
],
|
||||
BaselineReasonCodes::CAPTURE_UNUSABLE_COVERAGE => [
|
||||
'Latest inventory coverage unusable',
|
||||
$this->baselineCaptureTruthImpactExplanation(
|
||||
'The latest inventory sync did not produce usable governed-subject coverage for this baseline capture.',
|
||||
$context,
|
||||
),
|
||||
'prerequisite_missing',
|
||||
'Run inventory sync until the governed subject types show current coverage, then capture again.',
|
||||
],
|
||||
BaselineReasonCodes::CAPTURE_ZERO_SUBJECTS => [
|
||||
'No subjects were in scope',
|
||||
$this->baselineCaptureTruthImpactExplanation(
|
||||
'The latest inventory basis was credible, but no governed subjects were in scope for this baseline capture.',
|
||||
$context,
|
||||
),
|
||||
'prerequisite_missing',
|
||||
'Review the baseline scope and tenant inventory, then capture again when governed subjects are available.',
|
||||
],
|
||||
BaselineReasonCodes::SNAPSHOT_BUILDING,
|
||||
BaselineReasonCodes::COMPARE_SNAPSHOT_BUILDING => [
|
||||
'Baseline still building',
|
||||
@ -290,29 +242,6 @@ private function translateBaselineReason(string $reasonCode, array $context = []
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $context
|
||||
*/
|
||||
private function baselineCaptureTruthImpactExplanation(string $baseExplanation, array $context): string
|
||||
{
|
||||
$changed = data_get($context, 'baseline_capture.current_baseline_changed');
|
||||
$previousSnapshotExists = data_get($context, 'baseline_capture.previous_current_snapshot_exists');
|
||||
|
||||
if ($changed === true) {
|
||||
return $baseExplanation.' TenantPilot updated the current baseline truth with a new consumable snapshot.';
|
||||
}
|
||||
|
||||
if ($previousSnapshotExists === true) {
|
||||
return $baseExplanation.' TenantPilot kept the last trustworthy baseline in place.';
|
||||
}
|
||||
|
||||
if ($previousSnapshotExists === false) {
|
||||
return $baseExplanation.' No current trustworthy baseline is available yet.';
|
||||
}
|
||||
|
||||
return $baseExplanation;
|
||||
}
|
||||
|
||||
private function translateBaselineCompareReason(string $reasonCode): ReasonResolutionEnvelope
|
||||
{
|
||||
$enum = BaselineCompareReasonCode::tryFrom($reasonCode);
|
||||
|
||||
@ -71,7 +71,6 @@ public function build(Workspace $workspace, User $user): array
|
||||
->all();
|
||||
|
||||
$this->capabilityResolver->primeMemberships($user, $accessibleTenantIds);
|
||||
$visibleFindingsTenantIds = $this->visibleFindingTenantIds($accessibleTenants, $user);
|
||||
|
||||
$canViewAlerts = $this->workspaceCapabilityResolver->can($user, $workspace, Capabilities::ALERTS_VIEW);
|
||||
$navigationContext = $this->workspaceOverviewNavigationContext();
|
||||
@ -137,8 +136,8 @@ public function build(Workspace $workspace, User $user): array
|
||||
'action_url' => $calmness['next_action']['url'] ?? ChooseTenant::getUrl(panel: 'admin'),
|
||||
];
|
||||
|
||||
$myFindingsSignal = $this->myFindingsSignal($workspaceId, $visibleFindingsTenantIds, $user);
|
||||
$findingsHygieneSignal = $this->findingsHygieneSignal($workspace, $visibleFindingsTenantIds);
|
||||
$myFindingsSignal = $this->myFindingsSignal($workspaceId, $accessibleTenants, $user);
|
||||
$findingsHygieneSignal = $this->findingsHygieneSignal($workspace, $user);
|
||||
|
||||
$zeroTenantState = null;
|
||||
|
||||
@ -211,11 +210,18 @@ private function accessibleTenants(Workspace $workspace, User $user): Collection
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<int, int> $visibleTenantIds
|
||||
* @param Collection<int, Tenant> $accessibleTenants
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
private function myFindingsSignal(int $workspaceId, array $visibleTenantIds, User $user): array
|
||||
private function myFindingsSignal(int $workspaceId, Collection $accessibleTenants, User $user): array
|
||||
{
|
||||
$visibleTenantIds = $accessibleTenants
|
||||
->filter(fn (Tenant $tenant): bool => $this->capabilityResolver->can($user, $tenant, Capabilities::TENANT_FINDINGS_VIEW))
|
||||
->pluck('id')
|
||||
->map(static fn (mixed $id): int => (int) $id)
|
||||
->values()
|
||||
->all();
|
||||
|
||||
$assignedCounts = $visibleTenantIds === []
|
||||
? null
|
||||
: $this->scopeToVisibleTenants(
|
||||
@ -265,9 +271,9 @@ private function myFindingsSignal(int $workspaceId, array $visibleTenantIds, Use
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
private function findingsHygieneSignal(Workspace $workspace, array $visibleTenantIds): array
|
||||
private function findingsHygieneSignal(Workspace $workspace, User $user): array
|
||||
{
|
||||
$summary = $this->findingAssignmentHygieneService->summaryForVisibleTenantIds($workspace, $visibleTenantIds);
|
||||
$summary = $this->findingAssignmentHygieneService->summary($workspace, $user);
|
||||
$uniqueIssueCount = $summary['unique_issue_count'];
|
||||
$brokenAssignmentCount = $summary['broken_assignment_count'];
|
||||
$staleInProgressCount = $summary['stale_in_progress_count'];
|
||||
@ -291,20 +297,6 @@ private function findingsHygieneSignal(Workspace $workspace, array $visibleTenan
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @param Collection<int, Tenant> $accessibleTenants
|
||||
* @return array<int, int>
|
||||
*/
|
||||
private function visibleFindingTenantIds(Collection $accessibleTenants, User $user): array
|
||||
{
|
||||
return $accessibleTenants
|
||||
->filter(fn (Tenant $tenant): bool => $this->capabilityResolver->can($user, $tenant, Capabilities::TENANT_FINDINGS_VIEW))
|
||||
->pluck('id')
|
||||
->map(static fn (mixed $id): int => (int) $id)
|
||||
->values()
|
||||
->all();
|
||||
}
|
||||
|
||||
private function findingsHygieneDescription(int $brokenAssignmentCount, int $staleInProgressCount): string
|
||||
{
|
||||
if ($brokenAssignmentCount === 0 && $staleInProgressCount === 0) {
|
||||
|
||||
@ -42,6 +42,7 @@ public function definition(): array
|
||||
'app_client_id' => fake()->uuid(),
|
||||
'app_client_secret' => null, // Skip encryption in tests
|
||||
'app_certificate_thumbprint' => null,
|
||||
'app_status' => 'ok',
|
||||
'app_notes' => null,
|
||||
'status' => 'active',
|
||||
'environment' => 'other',
|
||||
|
||||
@ -156,9 +156,7 @@ private function classifyLegacySnapshot(object $row, array $summary, int $persis
|
||||
'was_empty_capture' => ($expectedItems ?? $producerExpectedItems ?? $producerSubjectsTotal) === 0 && $persistedItems === 0,
|
||||
];
|
||||
|
||||
if ($expectedItems !== null
|
||||
&& $expectedItems === $persistedItems
|
||||
&& ! ($expectedItems === 0 && $persistedItems === 0)) {
|
||||
if ($expectedItems !== null && $expectedItems === $persistedItems) {
|
||||
return [
|
||||
'lifecycle_state' => BaselineSnapshotLifecycleState::Complete->value,
|
||||
'completed_at' => $producerRun->completed_at ?? $row->captured_at ?? $row->created_at ?? $row->updated_at,
|
||||
@ -169,10 +167,7 @@ private function classifyLegacySnapshot(object $row, array $summary, int $persis
|
||||
];
|
||||
}
|
||||
|
||||
if ($producerSucceeded
|
||||
&& $producerExpectedItems !== null
|
||||
&& $producerExpectedItems === $persistedItems
|
||||
&& ! ($producerExpectedItems === 0 && $persistedItems === 0)) {
|
||||
if ($producerSucceeded && $producerExpectedItems !== null && $producerExpectedItems === $persistedItems) {
|
||||
return [
|
||||
'lifecycle_state' => BaselineSnapshotLifecycleState::Complete->value,
|
||||
'completed_at' => $producerRun->completed_at ?? $row->captured_at ?? $row->created_at ?? $row->updated_at,
|
||||
@ -189,11 +184,11 @@ private function classifyLegacySnapshot(object $row, array $summary, int $persis
|
||||
$producerSubjectsTotal,
|
||||
], static fn (?int $value): bool => $value !== null), true)) {
|
||||
return [
|
||||
'lifecycle_state' => BaselineSnapshotLifecycleState::Incomplete->value,
|
||||
'completed_at' => null,
|
||||
'failed_at' => $producerRun->completed_at ?? $row->updated_at ?? $row->captured_at ?? $row->created_at,
|
||||
'lifecycle_state' => BaselineSnapshotLifecycleState::Complete->value,
|
||||
'completed_at' => $producerRun->completed_at ?? $row->captured_at ?? $row->created_at ?? $row->updated_at,
|
||||
'failed_at' => null,
|
||||
'completion_meta' => $completionMeta + [
|
||||
'finalization_reason_code' => BaselineReasonCodes::CAPTURE_ZERO_SUBJECTS,
|
||||
'finalization_reason_code' => 'baseline.snapshot.legacy_empty_capture_proof',
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
@ -20,7 +20,7 @@
|
||||
<p><strong>Tenant:</strong> {{ $tenant->name }} ({{ $tenant->graphTenantId() }})</p>
|
||||
@isset($connection)
|
||||
<p><strong>Connection:</strong> {{ $connection->connection_type->value === 'platform' ? 'Platform connection' : 'Dedicated connection' }}</p>
|
||||
<p><strong>Verification state:</strong> {{ $verificationStateLabel ?? ucfirst($connection->verification_status->value) }}</p>
|
||||
<p><strong>Verification state:</strong> {{ ucfirst($connection->verification_status->value) }}</p>
|
||||
@endisset
|
||||
<p>
|
||||
<span class="status {{ $status === 'ok' ? 'ok' : ($status === 'consent_denied' ? 'warning' : 'error') }}">
|
||||
|
||||
@ -1,8 +1,6 @@
|
||||
@php
|
||||
$runs = $runs ?? collect();
|
||||
$overflowCount = (int) ($overflowCount ?? 0);
|
||||
$tenant = $tenant ?? null;
|
||||
@endphp
|
||||
@php($runs = $runs ?? collect())
|
||||
@php($overflowCount = (int) ($overflowCount ?? 0))
|
||||
@php($tenant = $tenant ?? null)
|
||||
|
||||
{{-- Cleanup is delegated to the shared poller helper, which uses teardownObserver and new MutationObserver. --}}
|
||||
|
||||
@ -18,17 +16,6 @@
|
||||
@if($runs->isNotEmpty())
|
||||
<div class="fixed bottom-4 right-4 z-[999999] w-96 space-y-2" style="pointer-events: auto;">
|
||||
@foreach ($runs->take(5) as $run)
|
||||
@php
|
||||
$statusSpec = \App\Support\Badges\BadgeRenderer::spec(
|
||||
\App\Support\Badges\BadgeDomain::OperationRunStatus,
|
||||
[
|
||||
'status' => (string) $run->status,
|
||||
'freshness_state' => $run->freshnessState()->value,
|
||||
],
|
||||
);
|
||||
$lifecycleAttention = \App\Support\OpsUx\OperationUxPresenter::lifecycleAttentionSummary($run);
|
||||
$guidance = \App\Support\OpsUx\OperationUxPresenter::surfaceGuidance($run);
|
||||
@endphp
|
||||
<div class="bg-white dark:bg-gray-800 rounded-lg shadow-xl border-2 border-primary-500 dark:border-primary-400 p-4 transition-all animate-in slide-in-from-right duration-300"
|
||||
wire:key="run-{{ $run->id }}">
|
||||
<div class="flex items-start justify-between gap-4">
|
||||
@ -43,21 +30,6 @@
|
||||
Running • {{ ($run->started_at ?? $run->created_at)?->diffForHumans(null, true, true) }}
|
||||
@endif
|
||||
</p>
|
||||
<div class="mt-2 flex flex-wrap items-center gap-2">
|
||||
<x-filament::badge :color="$statusSpec->color" size="sm">
|
||||
{{ $statusSpec->label }}
|
||||
</x-filament::badge>
|
||||
@if ($lifecycleAttention)
|
||||
<span class="inline-flex items-center rounded-full border border-warning-200 bg-warning-50 px-2 py-0.5 text-xs font-medium text-warning-800 dark:border-warning-600/40 dark:bg-warning-500/10 dark:text-warning-100">
|
||||
{{ $lifecycleAttention }}
|
||||
</span>
|
||||
@endif
|
||||
</div>
|
||||
@if ($guidance)
|
||||
<p class="mt-2 text-xs leading-5 text-gray-600 dark:text-gray-300">
|
||||
{{ $guidance }}
|
||||
</p>
|
||||
@endif
|
||||
</div>
|
||||
|
||||
@if ($tenant)
|
||||
|
||||
@ -54,7 +54,7 @@
|
||||
]);
|
||||
session()->put(WorkspaceContext::SESSION_KEY, (int) $tenant->workspace_id);
|
||||
|
||||
$operationsIndexUrl = OperationRunLinks::index($tenant);
|
||||
$operationsIndexUrl = route('admin.operations.index');
|
||||
|
||||
$page = visit(TenantResource::getUrl('view', ['record' => $tenant->getRouteKey()], panel: 'admin'));
|
||||
|
||||
|
||||
@ -7,7 +7,6 @@
|
||||
use App\Models\OperationRun;
|
||||
use App\Models\RestoreRun;
|
||||
use App\Models\Tenant;
|
||||
use App\Support\OperationRunLinks;
|
||||
use App\Support\Workspaces\WorkspaceContext;
|
||||
use Filament\Facades\Filament;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
@ -64,7 +63,7 @@ public function test_shows_only_generic_links_for_tenantless_runs_on_canonical_d
|
||||
->get(route('admin.operations.view', ['run' => (int) $run->getKey()]))
|
||||
->assertOk()
|
||||
->assertSee('Operations')
|
||||
->assertSee(OperationRunLinks::index(), false)
|
||||
->assertSee(route('admin.operations.index'), false)
|
||||
->assertDontSee('View restore run');
|
||||
}
|
||||
|
||||
|
||||
@ -75,34 +75,6 @@ public function test_trusts_notification_style_run_links_with_no_selected_tenant
|
||||
->assertSee('Canonical workspace view');
|
||||
}
|
||||
|
||||
public function test_uses_canonical_collection_link_for_default_back_and_show_all_fallbacks(): void
|
||||
{
|
||||
$runTenant = Tenant::factory()->create();
|
||||
[$user, $runTenant] = createUserWithTenant(tenant: $runTenant, role: 'owner');
|
||||
|
||||
$otherTenant = Tenant::factory()->create([
|
||||
'workspace_id' => (int) $runTenant->workspace_id,
|
||||
]);
|
||||
|
||||
createUserWithTenant(tenant: $otherTenant, user: $user, role: 'owner');
|
||||
|
||||
$run = OperationRun::factory()->create([
|
||||
'workspace_id' => (int) $runTenant->workspace_id,
|
||||
'tenant_id' => (int) $runTenant->getKey(),
|
||||
'type' => 'inventory_sync',
|
||||
]);
|
||||
|
||||
Filament::setTenant($otherTenant, true);
|
||||
|
||||
$this->actingAs($user)
|
||||
->withSession([WorkspaceContext::SESSION_KEY => (int) $runTenant->workspace_id])
|
||||
->get(OperationRunLinks::tenantlessView($run))
|
||||
->assertOk()
|
||||
->assertSee('Back to Operations')
|
||||
->assertSee('Show all operations')
|
||||
->assertSee(OperationRunLinks::index(), false);
|
||||
}
|
||||
|
||||
public function test_trusts_verification_surface_run_links_with_no_selected_tenant_context(): void
|
||||
{
|
||||
$tenant = Tenant::factory()->create();
|
||||
|
||||
@ -2,9 +2,7 @@
|
||||
|
||||
use App\Models\ProviderConnection;
|
||||
use App\Models\Tenant;
|
||||
use App\Models\TenantOnboardingSession;
|
||||
use App\Models\Workspace;
|
||||
use App\Models\OperationRun;
|
||||
use App\Support\Providers\ProviderReasonCodes;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
|
||||
@ -22,8 +20,6 @@
|
||||
]));
|
||||
|
||||
$response->assertOk();
|
||||
$response->assertSeeText('Verification state:');
|
||||
$response->assertSeeText('Needs verification');
|
||||
$response->assertSee(
|
||||
route('filament.admin.resources.tenants.view', ['tenant' => $tenant->external_id, 'record' => $tenant]),
|
||||
false,
|
||||
@ -64,57 +60,6 @@
|
||||
$response->assertSee(route('admin.onboarding'), false);
|
||||
});
|
||||
|
||||
it('invalidates resumable onboarding verification state for the same platform connection after a successful callback', function () {
|
||||
$tenant = Tenant::factory()->create([
|
||||
'tenant_id' => 'tenant-verify-reset',
|
||||
'name' => 'Reset Tenant',
|
||||
'status' => Tenant::STATUS_ONBOARDING,
|
||||
]);
|
||||
|
||||
$connection = ProviderConnection::factory()->create([
|
||||
'tenant_id' => (int) $tenant->getKey(),
|
||||
'workspace_id' => (int) $tenant->workspace_id,
|
||||
'provider' => 'microsoft',
|
||||
'entra_tenant_id' => $tenant->graphTenantId(),
|
||||
'is_default' => true,
|
||||
]);
|
||||
|
||||
$run = OperationRun::factory()->create([
|
||||
'tenant_id' => (int) $tenant->getKey(),
|
||||
'workspace_id' => (int) $tenant->workspace_id,
|
||||
'type' => 'provider.connection.check',
|
||||
]);
|
||||
|
||||
$draft = TenantOnboardingSession::query()->create([
|
||||
'workspace_id' => (int) $tenant->workspace_id,
|
||||
'tenant_id' => (int) $tenant->getKey(),
|
||||
'entra_tenant_id' => (string) $tenant->tenant_id,
|
||||
'current_step' => 'verify',
|
||||
'state' => [
|
||||
'provider_connection_id' => (int) $connection->getKey(),
|
||||
'verification_operation_run_id' => (int) $run->getKey(),
|
||||
'verification_run_id' => (int) $run->getKey(),
|
||||
'bootstrap_operation_runs' => [123, 456],
|
||||
'bootstrap_operation_types' => ['inventory_sync'],
|
||||
'bootstrap_run_ids' => [123, 456],
|
||||
],
|
||||
]);
|
||||
|
||||
$this->get(route('admin.consent.callback', [
|
||||
'tenant' => $tenant->tenant_id,
|
||||
'admin_consent' => 'true',
|
||||
]))->assertOk();
|
||||
|
||||
$draft->refresh();
|
||||
|
||||
expect($draft->state['verification_operation_run_id'] ?? null)->toBeNull()
|
||||
->and($draft->state['verification_run_id'] ?? null)->toBeNull()
|
||||
->and($draft->state['bootstrap_operation_runs'] ?? null)->toBeNull()
|
||||
->and($draft->state['bootstrap_operation_types'] ?? null)->toBeNull()
|
||||
->and($draft->state['bootstrap_run_ids'] ?? null)->toBeNull()
|
||||
->and($draft->state['connection_recently_updated'] ?? null)->toBeTrue();
|
||||
});
|
||||
|
||||
it('creates tenant and provider connection when callback tenant does not exist', function () {
|
||||
$workspace = Workspace::factory()->create();
|
||||
|
||||
@ -156,8 +101,6 @@
|
||||
]));
|
||||
|
||||
$response->assertOk();
|
||||
$response->assertSeeText('Verification state:');
|
||||
$response->assertSeeText('Not verified');
|
||||
|
||||
$connection = ProviderConnection::query()
|
||||
->where('tenant_id', (int) $tenant->getKey())
|
||||
|
||||
@ -15,7 +15,6 @@
|
||||
use App\Models\Workspace;
|
||||
use App\Models\WorkspaceMembership;
|
||||
use App\Services\Auth\WorkspaceCapabilityResolver;
|
||||
use App\Support\Baselines\BaselineReasonCodes;
|
||||
use App\Support\Workspaces\WorkspaceContext;
|
||||
use Filament\Facades\Filament;
|
||||
use Livewire\Livewire;
|
||||
@ -136,35 +135,6 @@
|
||||
->assertSee('Ambiguous matches');
|
||||
});
|
||||
|
||||
it('allows entitled viewers to open blocked baseline-capture run detail surfaces', function (): void {
|
||||
$tenant = Tenant::factory()->create();
|
||||
[$user, $tenant] = createUserWithTenant(tenant: $tenant, role: 'owner');
|
||||
|
||||
$run = OperationRun::factory()->create([
|
||||
'tenant_id' => (int) $tenant->getKey(),
|
||||
'workspace_id' => (int) $tenant->workspace_id,
|
||||
'type' => 'baseline_capture',
|
||||
'status' => 'completed',
|
||||
'outcome' => 'blocked',
|
||||
'context' => [
|
||||
'reason_code' => BaselineReasonCodes::CAPTURE_INVENTORY_FAILED,
|
||||
'baseline_capture' => [
|
||||
'reason_code' => BaselineReasonCodes::CAPTURE_INVENTORY_FAILED,
|
||||
'current_baseline_changed' => false,
|
||||
],
|
||||
],
|
||||
'completed_at' => now(),
|
||||
]);
|
||||
|
||||
Filament::setTenant(null, true);
|
||||
$this->withSession([WorkspaceContext::SESSION_KEY => (int) $tenant->workspace_id]);
|
||||
session([WorkspaceContext::SESSION_KEY => (int) $tenant->workspace_id]);
|
||||
|
||||
Livewire::actingAs($user)
|
||||
->test(TenantlessOperationRunViewer::class, ['run' => $run])
|
||||
->assertSee('Latest inventory sync failed');
|
||||
});
|
||||
|
||||
it('keeps governance summary surfaces deny-as-not-found for workspace members without tenant entitlement', function (): void {
|
||||
$workspace = Workspace::factory()->create();
|
||||
$tenant = Tenant::factory()->create([
|
||||
|
||||
@ -18,9 +18,6 @@
|
||||
'capture_mode' => BaselineCaptureMode::Opportunistic->value,
|
||||
'scope_jsonb' => ['policy_types' => ['deviceConfiguration'], 'foundation_types' => []],
|
||||
]);
|
||||
$inventorySyncRun = createInventorySyncOperationRunWithCoverage($tenant, [
|
||||
'deviceConfiguration' => 'succeeded',
|
||||
]);
|
||||
|
||||
InventoryItem::factory()->create([
|
||||
'tenant_id' => (int) $tenant->getKey(),
|
||||
@ -29,7 +26,6 @@
|
||||
'policy_type' => 'deviceConfiguration',
|
||||
'display_name' => 'Audit Policy A',
|
||||
'meta_jsonb' => ['odata_type' => '#microsoft.graph.deviceConfiguration', 'etag' => 'E_AUDIT'],
|
||||
'last_seen_operation_run_id' => (int) $inventorySyncRun->getKey(),
|
||||
]);
|
||||
|
||||
$opService = app(OperationRunService::class);
|
||||
|
||||
@ -34,9 +34,12 @@
|
||||
'display_name' => 'Isolated Policy',
|
||||
]);
|
||||
|
||||
$lastSeenRun = createInventorySyncOperationRunWithCoverage($tenant, [
|
||||
'deviceConfiguration' => 'succeeded',
|
||||
], attributes: [
|
||||
$lastSeenRun = OperationRun::factory()->create([
|
||||
'tenant_id' => (int) $tenant->getKey(),
|
||||
'workspace_id' => (int) $tenant->workspace_id,
|
||||
'type' => OperationRunType::InventorySync->value,
|
||||
'status' => OperationRunStatus::Completed->value,
|
||||
'outcome' => OperationRunOutcome::Succeeded->value,
|
||||
'completed_at' => now(),
|
||||
]);
|
||||
|
||||
|
||||
@ -20,9 +20,6 @@
|
||||
'workspace_id' => $tenant->workspace_id,
|
||||
'scope_jsonb' => ['policy_types' => ['deviceConfiguration'], 'foundation_types' => []],
|
||||
]);
|
||||
$inventorySyncRun = createInventorySyncOperationRunWithCoverage($tenant, [
|
||||
'deviceConfiguration' => 'succeeded',
|
||||
]);
|
||||
|
||||
$policy = Policy::factory()->create([
|
||||
'tenant_id' => (int) $tenant->getKey(),
|
||||
@ -44,7 +41,6 @@
|
||||
'assignment_target_count' => 1,
|
||||
],
|
||||
'last_seen_at' => now()->subHour(),
|
||||
'last_seen_operation_run_id' => (int) $inventorySyncRun->getKey(),
|
||||
]);
|
||||
|
||||
$snapshotPayload = [
|
||||
|
||||
@ -29,9 +29,6 @@
|
||||
'capture_mode' => BaselineCaptureMode::FullContent->value,
|
||||
'scope_jsonb' => ['policy_types' => ['deviceConfiguration'], 'foundation_types' => []],
|
||||
]);
|
||||
$inventorySyncRun = createInventorySyncOperationRunWithCoverage($tenant, [
|
||||
'deviceConfiguration' => 'succeeded',
|
||||
]);
|
||||
|
||||
$policy = Policy::factory()->create([
|
||||
'tenant_id' => (int) $tenant->getKey(),
|
||||
@ -53,7 +50,6 @@
|
||||
'assignment_target_count' => 1,
|
||||
],
|
||||
'last_seen_at' => now()->subHour(),
|
||||
'last_seen_operation_run_id' => (int) $inventorySyncRun->getKey(),
|
||||
]);
|
||||
|
||||
expect(PolicyVersion::query()->where('policy_id', (int) $policy->getKey())->count())->toBe(0);
|
||||
|
||||
@ -34,9 +34,12 @@
|
||||
'display_name' => 'Policy Capture Meta',
|
||||
]);
|
||||
|
||||
$lastSeenRun = createInventorySyncOperationRunWithCoverage($tenant, [
|
||||
'deviceConfiguration' => 'succeeded',
|
||||
], attributes: [
|
||||
$lastSeenRun = OperationRun::factory()->create([
|
||||
'tenant_id' => (int) $tenant->getKey(),
|
||||
'workspace_id' => (int) $tenant->workspace_id,
|
||||
'type' => OperationRunType::InventorySync->value,
|
||||
'status' => OperationRunStatus::Completed->value,
|
||||
'outcome' => OperationRunOutcome::Succeeded->value,
|
||||
'completed_at' => now(),
|
||||
]);
|
||||
|
||||
|
||||
@ -6,7 +6,6 @@
|
||||
use App\Models\BaselineSnapshotItem;
|
||||
use App\Models\InventoryItem;
|
||||
use App\Models\OperationRun;
|
||||
use App\Models\Tenant;
|
||||
use App\Services\Baselines\BaselineCaptureService;
|
||||
use App\Services\Baselines\BaselineSnapshotIdentity;
|
||||
use App\Services\Baselines\InventoryMetaContract;
|
||||
@ -19,28 +18,6 @@
|
||||
use App\Support\Baselines\BaselineSubjectKey;
|
||||
use Illuminate\Support\Facades\Queue;
|
||||
|
||||
function createBaselineCaptureInventoryBasis(
|
||||
Tenant $tenant,
|
||||
array $statusByType,
|
||||
array $attributes = [],
|
||||
): OperationRun {
|
||||
return createInventorySyncOperationRunWithCoverage($tenant, $statusByType, [], $attributes);
|
||||
}
|
||||
|
||||
function runBaselineCaptureJob(
|
||||
OperationRun $run,
|
||||
?OperationRunService $operationRunService = null,
|
||||
): void {
|
||||
$operationRunService ??= app(OperationRunService::class);
|
||||
|
||||
(new CaptureBaselineSnapshotJob($run))->handle(
|
||||
app(BaselineSnapshotIdentity::class),
|
||||
app(InventoryMetaContract::class),
|
||||
app(AuditLogger::class),
|
||||
$operationRunService,
|
||||
);
|
||||
}
|
||||
|
||||
// --- T031: Capture enqueue + precondition tests ---
|
||||
|
||||
it('enqueues capture for an active profile and creates an operation run', function () {
|
||||
@ -52,9 +29,6 @@ function runBaselineCaptureJob(
|
||||
'workspace_id' => $tenant->workspace_id,
|
||||
'scope_jsonb' => ['policy_types' => ['deviceConfiguration'], 'foundation_types' => []],
|
||||
]);
|
||||
$inventorySyncRun = createBaselineCaptureInventoryBasis($tenant, [
|
||||
'deviceConfiguration' => 'succeeded',
|
||||
]);
|
||||
|
||||
/** @var BaselineCaptureService $service */
|
||||
$service = app(BaselineCaptureService::class);
|
||||
@ -79,119 +53,10 @@ function runBaselineCaptureJob(
|
||||
expect($effectiveScope['foundation_types'])->toBe([]);
|
||||
expect($effectiveScope['all_types'])->toBe(['deviceConfiguration']);
|
||||
expect($effectiveScope['foundations_included'])->toBeFalse();
|
||||
expect(data_get($context, 'baseline_capture.inventory_sync_run_id'))->toBe((int) $inventorySyncRun->getKey());
|
||||
expect(data_get($context, 'baseline_capture.eligibility.phase'))->toBe('preflight');
|
||||
expect(data_get($context, 'baseline_capture.eligibility.ok'))->toBeTrue();
|
||||
expect(data_get($context, 'baseline_capture.eligibility.covered_types'))->toBe(['deviceConfiguration']);
|
||||
expect(data_get($context, 'baseline_capture.eligibility.uncovered_types'))->toBe([]);
|
||||
|
||||
Queue::assertPushed(CaptureBaselineSnapshotJob::class);
|
||||
});
|
||||
|
||||
it('rejects capture when no current inventory sync exists', function () {
|
||||
Queue::fake();
|
||||
|
||||
[$user, $tenant] = createUserWithTenant(role: 'owner');
|
||||
|
||||
$profile = BaselineProfile::factory()->active()->create([
|
||||
'workspace_id' => $tenant->workspace_id,
|
||||
'scope_jsonb' => ['policy_types' => ['deviceConfiguration'], 'foundation_types' => []],
|
||||
]);
|
||||
|
||||
$result = app(BaselineCaptureService::class)->startCapture($profile, $tenant, $user);
|
||||
|
||||
expect($result['ok'])->toBeFalse();
|
||||
expect($result['reason_code'])->toBe(BaselineReasonCodes::CAPTURE_INVENTORY_MISSING);
|
||||
|
||||
Queue::assertNotPushed(CaptureBaselineSnapshotJob::class);
|
||||
expect(OperationRun::query()->where('type', 'baseline_capture')->count())->toBe(0);
|
||||
});
|
||||
|
||||
it('rejects capture when the latest inventory sync was blocked', function () {
|
||||
Queue::fake();
|
||||
|
||||
[$user, $tenant] = createUserWithTenant(role: 'owner');
|
||||
|
||||
$profile = BaselineProfile::factory()->active()->create([
|
||||
'workspace_id' => $tenant->workspace_id,
|
||||
'scope_jsonb' => ['policy_types' => ['deviceConfiguration'], 'foundation_types' => []],
|
||||
]);
|
||||
|
||||
createBaselineCaptureInventoryBasis($tenant, [
|
||||
'deviceConfiguration' => 'succeeded',
|
||||
], [
|
||||
'completed_at' => now()->subMinute(),
|
||||
]);
|
||||
createBaselineCaptureInventoryBasis($tenant, [
|
||||
'deviceConfiguration' => 'failed',
|
||||
], [
|
||||
'outcome' => 'blocked',
|
||||
'completed_at' => now(),
|
||||
]);
|
||||
|
||||
$result = app(BaselineCaptureService::class)->startCapture($profile, $tenant, $user);
|
||||
|
||||
expect($result['ok'])->toBeFalse();
|
||||
expect($result['reason_code'])->toBe(BaselineReasonCodes::CAPTURE_INVENTORY_BLOCKED);
|
||||
|
||||
Queue::assertNotPushed(CaptureBaselineSnapshotJob::class);
|
||||
expect(OperationRun::query()->where('type', 'baseline_capture')->count())->toBe(0);
|
||||
});
|
||||
|
||||
it('rejects capture when the latest inventory sync failed without falling back to an older success', function () {
|
||||
Queue::fake();
|
||||
|
||||
[$user, $tenant] = createUserWithTenant(role: 'owner');
|
||||
|
||||
$profile = BaselineProfile::factory()->active()->create([
|
||||
'workspace_id' => $tenant->workspace_id,
|
||||
'scope_jsonb' => ['policy_types' => ['deviceConfiguration'], 'foundation_types' => []],
|
||||
]);
|
||||
|
||||
createBaselineCaptureInventoryBasis($tenant, [
|
||||
'deviceConfiguration' => 'succeeded',
|
||||
], [
|
||||
'completed_at' => now()->subMinute(),
|
||||
]);
|
||||
createBaselineCaptureInventoryBasis($tenant, [
|
||||
'deviceConfiguration' => 'failed',
|
||||
], [
|
||||
'outcome' => 'failed',
|
||||
'completed_at' => now(),
|
||||
]);
|
||||
|
||||
$result = app(BaselineCaptureService::class)->startCapture($profile, $tenant, $user);
|
||||
|
||||
expect($result['ok'])->toBeFalse();
|
||||
expect($result['reason_code'])->toBe(BaselineReasonCodes::CAPTURE_INVENTORY_FAILED);
|
||||
|
||||
Queue::assertNotPushed(CaptureBaselineSnapshotJob::class);
|
||||
expect(OperationRun::query()->where('type', 'baseline_capture')->count())->toBe(0);
|
||||
});
|
||||
|
||||
it('rejects capture when the latest inventory coverage is unusable for the baseline scope', function () {
|
||||
Queue::fake();
|
||||
|
||||
[$user, $tenant] = createUserWithTenant(role: 'owner');
|
||||
|
||||
$profile = BaselineProfile::factory()->active()->create([
|
||||
'workspace_id' => $tenant->workspace_id,
|
||||
'scope_jsonb' => ['policy_types' => ['deviceConfiguration'], 'foundation_types' => []],
|
||||
]);
|
||||
|
||||
createBaselineCaptureInventoryBasis($tenant, [
|
||||
'deviceCompliancePolicy' => 'succeeded',
|
||||
]);
|
||||
|
||||
$result = app(BaselineCaptureService::class)->startCapture($profile, $tenant, $user);
|
||||
|
||||
expect($result['ok'])->toBeFalse();
|
||||
expect($result['reason_code'])->toBe(BaselineReasonCodes::CAPTURE_UNUSABLE_COVERAGE);
|
||||
|
||||
Queue::assertNotPushed(CaptureBaselineSnapshotJob::class);
|
||||
expect(OperationRun::query()->where('type', 'baseline_capture')->count())->toBe(0);
|
||||
});
|
||||
|
||||
it('rejects capture for a draft profile with reason code', function () {
|
||||
Queue::fake();
|
||||
|
||||
@ -261,9 +126,6 @@ function runBaselineCaptureJob(
|
||||
'workspace_id' => $tenant->workspace_id,
|
||||
'scope_jsonb' => ['policy_types' => ['deviceConfiguration'], 'foundation_types' => []],
|
||||
]);
|
||||
createBaselineCaptureInventoryBasis($tenant, [
|
||||
'deviceConfiguration' => 'succeeded',
|
||||
]);
|
||||
|
||||
$service = app(BaselineCaptureService::class);
|
||||
|
||||
@ -286,9 +148,6 @@ function runBaselineCaptureJob(
|
||||
'workspace_id' => $tenant->workspace_id,
|
||||
'scope_jsonb' => ['policy_types' => ['deviceConfiguration'], 'foundation_types' => []],
|
||||
]);
|
||||
$inventorySyncRun = createBaselineCaptureInventoryBasis($tenant, [
|
||||
'deviceConfiguration' => 'succeeded',
|
||||
]);
|
||||
|
||||
$inventoryA = InventoryItem::factory()->create([
|
||||
'tenant_id' => $tenant->getKey(),
|
||||
@ -297,7 +156,6 @@ function runBaselineCaptureJob(
|
||||
'policy_type' => 'deviceConfiguration',
|
||||
'display_name' => 'Policy A',
|
||||
'meta_jsonb' => ['odata_type' => '#microsoft.graph.deviceConfiguration', 'etag' => 'E1'],
|
||||
'last_seen_operation_run_id' => (int) $inventorySyncRun->getKey(),
|
||||
]);
|
||||
$inventoryB = InventoryItem::factory()->create([
|
||||
'tenant_id' => $tenant->getKey(),
|
||||
@ -306,7 +164,6 @@ function runBaselineCaptureJob(
|
||||
'policy_type' => 'deviceConfiguration',
|
||||
'display_name' => 'Policy B',
|
||||
'meta_jsonb' => ['odata_type' => '#microsoft.graph.deviceConfiguration', 'etag' => 'E2'],
|
||||
'last_seen_operation_run_id' => (int) $inventorySyncRun->getKey(),
|
||||
]);
|
||||
$inventoryC = InventoryItem::factory()->create([
|
||||
'tenant_id' => $tenant->getKey(),
|
||||
@ -315,7 +172,6 @@ function runBaselineCaptureJob(
|
||||
'policy_type' => 'deviceConfiguration',
|
||||
'display_name' => 'Policy C',
|
||||
'meta_jsonb' => ['odata_type' => '#microsoft.graph.deviceConfiguration', 'etag' => 'E3'],
|
||||
'last_seen_operation_run_id' => (int) $inventorySyncRun->getKey(),
|
||||
]);
|
||||
|
||||
$opService = app(OperationRunService::class);
|
||||
@ -331,7 +187,13 @@ function runBaselineCaptureJob(
|
||||
initiator: $user,
|
||||
);
|
||||
|
||||
runBaselineCaptureJob($run, $opService);
|
||||
$job = new CaptureBaselineSnapshotJob($run);
|
||||
$job->handle(
|
||||
app(BaselineSnapshotIdentity::class),
|
||||
app(InventoryMetaContract::class),
|
||||
app(AuditLogger::class),
|
||||
$opService,
|
||||
);
|
||||
|
||||
$run->refresh();
|
||||
expect($run->status)->toBe('completed');
|
||||
@ -407,14 +269,6 @@ function runBaselineCaptureJob(
|
||||
expect(data_get($meta, 'meta_contract'))->toBeNull();
|
||||
}
|
||||
|
||||
expect(data_get($run->context, 'baseline_capture.inventory_sync_run_id'))->toBe((int) $inventorySyncRun->getKey());
|
||||
expect(data_get($run->context, 'baseline_capture.eligibility.phase'))->toBe('runtime_recheck');
|
||||
expect(data_get($run->context, 'baseline_capture.eligibility.ok'))->toBeTrue();
|
||||
expect(data_get($run->context, 'baseline_capture.eligibility.changed_after_enqueue'))->toBeFalse();
|
||||
expect(data_get($run->context, 'baseline_capture.current_baseline_changed'))->toBeTrue();
|
||||
expect(data_get($run->context, 'baseline_capture.previous_current_snapshot_exists'))->toBeFalse();
|
||||
expect(data_get($run->context, 'result.current_baseline_changed'))->toBeTrue();
|
||||
|
||||
$profile->refresh();
|
||||
expect($profile->active_snapshot_id)->toBe((int) $snapshot->getKey());
|
||||
});
|
||||
@ -457,16 +311,12 @@ function runBaselineCaptureJob(
|
||||
'workspace_id' => $tenant->workspace_id,
|
||||
'scope_jsonb' => ['policy_types' => ['deviceConfiguration'], 'foundation_types' => []],
|
||||
]);
|
||||
$inventorySyncRun = createBaselineCaptureInventoryBasis($tenant, [
|
||||
'deviceConfiguration' => 'succeeded',
|
||||
]);
|
||||
|
||||
InventoryItem::factory()->count(2)->create([
|
||||
'tenant_id' => $tenant->getKey(),
|
||||
'workspace_id' => $tenant->workspace_id,
|
||||
'policy_type' => 'deviceConfiguration',
|
||||
'meta_jsonb' => ['odata_type' => '#microsoft.graph.deviceConfiguration', 'stable_field' => 'value'],
|
||||
'last_seen_operation_run_id' => (int) $inventorySyncRun->getKey(),
|
||||
]);
|
||||
|
||||
$opService = app(OperationRunService::class);
|
||||
@ -486,7 +336,8 @@ function runBaselineCaptureJob(
|
||||
initiator: $user,
|
||||
);
|
||||
|
||||
(new CaptureBaselineSnapshotJob($run1))->handle($idService, $metaContract, $auditLogger, $opService);
|
||||
$job1 = new CaptureBaselineSnapshotJob($run1);
|
||||
$job1->handle($idService, $metaContract, $auditLogger, $opService);
|
||||
|
||||
$snapshotCountAfterFirst = BaselineSnapshot::query()
|
||||
->where('baseline_profile_id', $profile->getKey())
|
||||
@ -510,7 +361,8 @@ function runBaselineCaptureJob(
|
||||
],
|
||||
]);
|
||||
|
||||
(new CaptureBaselineSnapshotJob($run2))->handle($idService, $metaContract, $auditLogger, $opService);
|
||||
$job2 = new CaptureBaselineSnapshotJob($run2);
|
||||
$job2->handle($idService, $metaContract, $auditLogger, $opService);
|
||||
|
||||
$snapshotCountAfterSecond = BaselineSnapshot::query()
|
||||
->where('baseline_profile_id', $profile->getKey())
|
||||
@ -519,68 +371,14 @@ function runBaselineCaptureJob(
|
||||
expect($snapshotCountAfterSecond)->toBe(1);
|
||||
});
|
||||
|
||||
it('blocks a queued capture when the latest inventory basis fails after enqueue and keeps the prior current baseline', function () {
|
||||
// --- EC-005: Empty scope produces empty snapshot without errors ---
|
||||
|
||||
it('captures an empty snapshot when no inventory items match the scope', function () {
|
||||
[$user, $tenant] = createUserWithTenant(role: 'owner');
|
||||
|
||||
$profile = BaselineProfile::factory()->active()->create([
|
||||
'workspace_id' => $tenant->workspace_id,
|
||||
'scope_jsonb' => ['policy_types' => ['deviceConfiguration'], 'foundation_types' => []],
|
||||
]);
|
||||
|
||||
$previousSnapshot = BaselineSnapshot::factory()->complete()->create([
|
||||
'workspace_id' => $tenant->workspace_id,
|
||||
'baseline_profile_id' => (int) $profile->getKey(),
|
||||
]);
|
||||
$profile->update(['active_snapshot_id' => (int) $previousSnapshot->getKey()]);
|
||||
|
||||
createBaselineCaptureInventoryBasis($tenant, [
|
||||
'deviceConfiguration' => 'succeeded',
|
||||
], [
|
||||
'completed_at' => now()->subMinute(),
|
||||
]);
|
||||
|
||||
Queue::fake();
|
||||
|
||||
$result = app(BaselineCaptureService::class)->startCapture($profile, $tenant, $user);
|
||||
|
||||
expect($result['ok'])->toBeTrue();
|
||||
|
||||
createBaselineCaptureInventoryBasis($tenant, [
|
||||
'deviceConfiguration' => 'failed',
|
||||
], [
|
||||
'outcome' => 'failed',
|
||||
'completed_at' => now(),
|
||||
]);
|
||||
|
||||
/** @var OperationRun $run */
|
||||
$run = $result['run'];
|
||||
runBaselineCaptureJob($run);
|
||||
|
||||
$run->refresh();
|
||||
$profile->refresh();
|
||||
|
||||
expect($run->status)->toBe('completed');
|
||||
expect($run->outcome)->toBe('blocked');
|
||||
expect($profile->active_snapshot_id)->toBe((int) $previousSnapshot->getKey());
|
||||
expect(data_get($run->context, 'reason_code'))->toBe(BaselineReasonCodes::CAPTURE_INVENTORY_FAILED);
|
||||
expect(data_get($run->context, 'baseline_capture.reason_code'))->toBe(BaselineReasonCodes::CAPTURE_INVENTORY_FAILED);
|
||||
expect(data_get($run->context, 'baseline_capture.current_baseline_changed'))->toBeFalse();
|
||||
expect(data_get($run->context, 'baseline_capture.previous_current_snapshot_exists'))->toBeTrue();
|
||||
expect(data_get($run->context, 'baseline_capture.eligibility.changed_after_enqueue'))->toBeTrue();
|
||||
expect(data_get($run->context, 'result.current_baseline_changed'))->toBeFalse();
|
||||
});
|
||||
|
||||
// --- EC-005: Zero-subject captures stay visible but non-authoritative ---
|
||||
|
||||
it('records a zero-subject capture as partially succeeded with a non-consumable snapshot', function () {
|
||||
[$user, $tenant] = createUserWithTenant(role: 'owner');
|
||||
|
||||
$profile = BaselineProfile::factory()->active()->create([
|
||||
'workspace_id' => $tenant->workspace_id,
|
||||
'scope_jsonb' => ['policy_types' => ['deviceConfiguration'], 'foundation_types' => []],
|
||||
]);
|
||||
createBaselineCaptureInventoryBasis($tenant, [
|
||||
'deviceConfiguration' => 'succeeded',
|
||||
'scope_jsonb' => ['policy_types' => ['nonExistentPolicyType'], 'foundation_types' => []],
|
||||
]);
|
||||
|
||||
$opService = app(OperationRunService::class);
|
||||
@ -591,22 +389,22 @@ function runBaselineCaptureJob(
|
||||
context: [
|
||||
'baseline_profile_id' => (int) $profile->getKey(),
|
||||
'source_tenant_id' => (int) $tenant->getKey(),
|
||||
'effective_scope' => ['policy_types' => ['deviceConfiguration'], 'foundation_types' => []],
|
||||
'effective_scope' => ['policy_types' => ['nonExistentPolicyType'], 'foundation_types' => []],
|
||||
],
|
||||
initiator: $user,
|
||||
);
|
||||
|
||||
runBaselineCaptureJob($run, $opService);
|
||||
$job = new CaptureBaselineSnapshotJob($run);
|
||||
$job->handle(
|
||||
app(BaselineSnapshotIdentity::class),
|
||||
app(InventoryMetaContract::class),
|
||||
app(AuditLogger::class),
|
||||
$opService,
|
||||
);
|
||||
|
||||
$run->refresh();
|
||||
expect($run->status)->toBe('completed');
|
||||
expect($run->outcome)->toBe('partially_succeeded');
|
||||
expect(data_get($run->context, 'reason_code'))->toBe(BaselineReasonCodes::CAPTURE_ZERO_SUBJECTS);
|
||||
expect(data_get($run->context, 'baseline_capture.reason_code'))->toBe(BaselineReasonCodes::CAPTURE_ZERO_SUBJECTS);
|
||||
expect(data_get($run->context, 'baseline_capture.subjects_total'))->toBe(0);
|
||||
expect(data_get($run->context, 'baseline_capture.current_baseline_changed'))->toBeFalse();
|
||||
expect(data_get($run->context, 'result.current_baseline_changed'))->toBeFalse();
|
||||
expect(data_get($run->context, 'result.snapshot_lifecycle'))->toBe(BaselineSnapshotLifecycleState::Incomplete->value);
|
||||
expect($run->outcome)->toBe('succeeded');
|
||||
|
||||
$counts = is_array($run->summary_counts) ? $run->summary_counts : [];
|
||||
expect((int) ($counts['total'] ?? 0))->toBe(0);
|
||||
@ -617,12 +415,7 @@ function runBaselineCaptureJob(
|
||||
->first();
|
||||
|
||||
expect($snapshot)->not->toBeNull();
|
||||
expect($snapshot?->lifecycleState())->toBe(BaselineSnapshotLifecycleState::Incomplete);
|
||||
expect(data_get($snapshot?->completion_meta_jsonb ?? [], 'finalization_reason_code'))->toBe(BaselineReasonCodes::CAPTURE_ZERO_SUBJECTS);
|
||||
expect(BaselineSnapshotItem::query()->where('baseline_snapshot_id', $snapshot->getKey())->count())->toBe(0);
|
||||
|
||||
$profile->refresh();
|
||||
expect($profile->active_snapshot_id)->toBeNull();
|
||||
});
|
||||
|
||||
it('captures all inventory items when scope has empty policy_types (all types)', function () {
|
||||
@ -632,23 +425,17 @@ function runBaselineCaptureJob(
|
||||
'workspace_id' => $tenant->workspace_id,
|
||||
'scope_jsonb' => ['policy_types' => [], 'foundation_types' => []],
|
||||
]);
|
||||
$inventorySyncRun = createBaselineCaptureInventoryBasis($tenant, [
|
||||
'deviceCompliancePolicy' => 'succeeded',
|
||||
'deviceConfiguration' => 'succeeded',
|
||||
]);
|
||||
|
||||
InventoryItem::factory()->create([
|
||||
'tenant_id' => $tenant->getKey(),
|
||||
'workspace_id' => $tenant->workspace_id,
|
||||
'policy_type' => 'deviceConfiguration',
|
||||
'last_seen_operation_run_id' => (int) $inventorySyncRun->getKey(),
|
||||
]);
|
||||
|
||||
InventoryItem::factory()->create([
|
||||
'tenant_id' => $tenant->getKey(),
|
||||
'workspace_id' => $tenant->workspace_id,
|
||||
'policy_type' => 'deviceCompliancePolicy',
|
||||
'last_seen_operation_run_id' => (int) $inventorySyncRun->getKey(),
|
||||
]);
|
||||
|
||||
// Foundation types are excluded by default (unless foundation_types is selected).
|
||||
@ -656,7 +443,6 @@ function runBaselineCaptureJob(
|
||||
'tenant_id' => $tenant->getKey(),
|
||||
'workspace_id' => $tenant->workspace_id,
|
||||
'policy_type' => 'assignmentFilter',
|
||||
'last_seen_operation_run_id' => (int) $inventorySyncRun->getKey(),
|
||||
]);
|
||||
|
||||
$opService = app(OperationRunService::class);
|
||||
@ -672,7 +458,13 @@ function runBaselineCaptureJob(
|
||||
initiator: $user,
|
||||
);
|
||||
|
||||
runBaselineCaptureJob($run, $opService);
|
||||
$job = new CaptureBaselineSnapshotJob($run);
|
||||
$job->handle(
|
||||
app(BaselineSnapshotIdentity::class),
|
||||
app(InventoryMetaContract::class),
|
||||
app(AuditLogger::class),
|
||||
$opService,
|
||||
);
|
||||
|
||||
$run->refresh();
|
||||
expect($run->status)->toBe('completed');
|
||||
|
||||
@ -1335,19 +1335,12 @@
|
||||
'scope_jsonb' => ['policy_types' => ['deviceConfiguration'], 'foundation_types' => []],
|
||||
]);
|
||||
|
||||
$inventorySyncRun = createInventorySyncOperationRunWithCoverage(
|
||||
tenant: $tenant,
|
||||
statusByType: ['deviceConfiguration' => 'succeeded'],
|
||||
);
|
||||
|
||||
InventoryItem::factory()->create([
|
||||
'tenant_id' => (int) $tenant->getKey(),
|
||||
'workspace_id' => (int) $tenant->workspace_id,
|
||||
'external_id' => 'policy-a',
|
||||
'policy_type' => 'deviceConfiguration',
|
||||
'meta_jsonb' => ['odata_type' => '#microsoft.graph.deviceConfiguration', 'etag' => 'E1'],
|
||||
'last_seen_operation_run_id' => (int) $inventorySyncRun->getKey(),
|
||||
'last_seen_at' => now(),
|
||||
]);
|
||||
|
||||
$operationRuns = app(OperationRunService::class);
|
||||
@ -1373,6 +1366,18 @@
|
||||
|
||||
$captureRun->refresh();
|
||||
|
||||
$inventorySyncRun = createInventorySyncOperationRunWithCoverage(
|
||||
tenant: $tenant,
|
||||
statusByType: ['deviceConfiguration' => 'succeeded'],
|
||||
);
|
||||
|
||||
InventoryItem::query()
|
||||
->where('tenant_id', (int) $tenant->getKey())
|
||||
->update([
|
||||
'last_seen_operation_run_id' => (int) $inventorySyncRun->getKey(),
|
||||
'last_seen_at' => now(),
|
||||
]);
|
||||
|
||||
$snapshotId = (int) ($profile->fresh()?->active_snapshot_id ?? 0);
|
||||
expect($snapshotId)->toBeGreaterThan(0);
|
||||
|
||||
|
||||
@ -43,10 +43,6 @@
|
||||
it('archives baseline profiles for authorized workspace members', function (): void {
|
||||
[$user, $tenant] = createUserWithTenant(role: 'owner');
|
||||
|
||||
expect(defined(BaselineProfile::class.'::STATUS_DRAFT'))->toBeFalse()
|
||||
->and(defined(BaselineProfile::class.'::STATUS_ACTIVE'))->toBeFalse()
|
||||
->and(defined(BaselineProfile::class.'::STATUS_ARCHIVED'))->toBeFalse();
|
||||
|
||||
$profile = BaselineProfile::factory()->active()->create([
|
||||
'workspace_id' => (int) $tenant->workspace_id,
|
||||
]);
|
||||
|
||||
@ -3,8 +3,6 @@
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Filament\Resources\BaselineProfileResource;
|
||||
use App\Models\BaselineProfile;
|
||||
use App\Support\Baselines\BaselineProfileStatus;
|
||||
use Filament\Facades\Filament;
|
||||
|
||||
it('keeps baseline profiles out of tenant panel registration and tenant navigation URLs', function (): void {
|
||||
@ -25,11 +23,6 @@
|
||||
it('keeps baseline profile urls workspace-owned even when a tenant context exists', function (): void {
|
||||
[$user, $tenant] = createUserWithTenant(role: 'owner');
|
||||
|
||||
$profile = BaselineProfile::factory()->create([
|
||||
'workspace_id' => (int) $tenant->workspace_id,
|
||||
'status' => BaselineProfileStatus::Archived->value,
|
||||
]);
|
||||
|
||||
$this->actingAs($user)
|
||||
->withSession([\App\Support\Workspaces\WorkspaceContext::SESSION_KEY => (int) $tenant->workspace_id]);
|
||||
|
||||
@ -39,8 +32,5 @@
|
||||
expect($workspaceUrl)->not->toContain("/admin/t/{$tenant->external_id}/baseline-profiles");
|
||||
|
||||
$this->get($workspaceUrl)->assertOk();
|
||||
$this->get(BaselineProfileResource::getUrl('view', ['record' => $profile], panel: 'admin'))->assertOk();
|
||||
$this->get("/admin/t/{$tenant->external_id}/baseline-profiles")->assertNotFound();
|
||||
|
||||
expect($profile->fresh()->status)->toBe(BaselineProfileStatus::Archived);
|
||||
});
|
||||
|
||||
@ -60,7 +60,7 @@ function classifyLegacySnapshotForTest(BaselineSnapshot $snapshot): array
|
||||
->and(data_get($classification, 'completion_meta.persisted_items'))->toBe(2);
|
||||
});
|
||||
|
||||
it('classifies proven empty legacy captures as incomplete no-data snapshots when the producer run confirms zero subjects', function (): void {
|
||||
it('classifies proven empty legacy captures as complete when the producer run confirms zero subjects', function (): void {
|
||||
$workspace = Workspace::factory()->create();
|
||||
$profile = BaselineProfile::factory()->active()->create([
|
||||
'workspace_id' => (int) $workspace->getKey(),
|
||||
@ -86,9 +86,8 @@ function classifyLegacySnapshotForTest(BaselineSnapshot $snapshot): array
|
||||
|
||||
$classification = classifyLegacySnapshotForTest($snapshot);
|
||||
|
||||
expect($classification['lifecycle_state'])->toBe(BaselineSnapshotLifecycleState::Incomplete->value)
|
||||
->and(data_get($classification, 'completion_meta.was_empty_capture'))->toBeTrue()
|
||||
->and(data_get($classification, 'completion_meta.finalization_reason_code'))->toBe(BaselineReasonCodes::CAPTURE_ZERO_SUBJECTS);
|
||||
expect($classification['lifecycle_state'])->toBe(BaselineSnapshotLifecycleState::Complete->value)
|
||||
->and(data_get($classification, 'completion_meta.was_empty_capture'))->toBeTrue();
|
||||
});
|
||||
|
||||
it('classifies ambiguous legacy snapshots as incomplete with a conservative reason code', function (): void {
|
||||
|
||||
@ -42,31 +42,3 @@
|
||||
->assertSee($explanation?->trustworthinessLabel() ?? '')
|
||||
->assertSee($explanation?->nextActionText ?? '');
|
||||
});
|
||||
|
||||
it('renders no-data baseline-capture result surfaces with the shared zero-subject explanation', function (): void {
|
||||
[$user, $tenant] = createUserWithTenant(role: 'owner');
|
||||
|
||||
$profile = BaselineProfile::factory()->active()->create([
|
||||
'workspace_id' => (int) $tenant->workspace_id,
|
||||
]);
|
||||
|
||||
$snapshot = BaselineSnapshot::factory()->incomplete(BaselineReasonCodes::CAPTURE_ZERO_SUBJECTS)->create([
|
||||
'workspace_id' => (int) $tenant->workspace_id,
|
||||
'baseline_profile_id' => (int) $profile->getKey(),
|
||||
]);
|
||||
|
||||
setAdminPanelContext();
|
||||
session()->put(WorkspaceContext::SESSION_KEY, (int) $tenant->workspace_id);
|
||||
|
||||
$truth = app(ArtifactTruthPresenter::class)->forBaselineSnapshot($snapshot->fresh());
|
||||
$explanation = $truth->operatorExplanation;
|
||||
|
||||
$this->actingAs($user)
|
||||
->get(BaselineSnapshotResource::getUrl('view', ['record' => $snapshot], panel: 'admin'))
|
||||
->assertOk()
|
||||
->assertSee('Result meaning')
|
||||
->assertSee($explanation?->evaluationResultLabel() ?? '')
|
||||
->assertSee('Result trust')
|
||||
->assertSee($explanation?->trustworthinessLabel() ?? '')
|
||||
->assertSee($explanation?->nextActionText ?? '');
|
||||
});
|
||||
|
||||
@ -10,7 +10,6 @@
|
||||
use App\Models\BaselineSnapshot;
|
||||
use App\Models\BaselineTenantAssignment;
|
||||
use App\Models\OperationRun;
|
||||
use App\Support\Baselines\BaselineReasonCodes;
|
||||
use App\Support\Baselines\Compare\CompareStrategyRegistry;
|
||||
use App\Support\Baselines\Compare\IntuneCompareStrategy;
|
||||
use App\Support\Governance\GovernanceSubjectTaxonomyRegistry;
|
||||
@ -408,92 +407,3 @@
|
||||
->assertSet('uncoveredTypes', ['deviceCompliancePolicy'])
|
||||
->assertSet('fidelity', 'meta');
|
||||
});
|
||||
|
||||
it('shows the latest blocked capture explanation when no consumable baseline exists yet', function (): void {
|
||||
[$user, $tenant] = createUserWithTenant(role: 'owner');
|
||||
$this->actingAs($user);
|
||||
|
||||
$tenant->makeCurrent();
|
||||
Filament::setTenant($tenant, true);
|
||||
|
||||
$profile = BaselineProfile::factory()->active()->create([
|
||||
'workspace_id' => (int) $tenant->workspace_id,
|
||||
'scope_jsonb' => ['policy_types' => ['deviceConfiguration'], 'foundation_types' => []],
|
||||
]);
|
||||
|
||||
BaselineTenantAssignment::factory()->create([
|
||||
'workspace_id' => (int) $tenant->workspace_id,
|
||||
'tenant_id' => (int) $tenant->getKey(),
|
||||
'baseline_profile_id' => (int) $profile->getKey(),
|
||||
]);
|
||||
|
||||
OperationRun::factory()->create([
|
||||
'tenant_id' => (int) $tenant->getKey(),
|
||||
'workspace_id' => (int) $tenant->workspace_id,
|
||||
'type' => OperationRunType::BaselineCapture->value,
|
||||
'status' => OperationRunStatus::Completed->value,
|
||||
'outcome' => OperationRunOutcome::Blocked->value,
|
||||
'completed_at' => now(),
|
||||
'context' => [
|
||||
'baseline_profile_id' => (int) $profile->getKey(),
|
||||
'reason_code' => BaselineReasonCodes::CAPTURE_INVENTORY_FAILED,
|
||||
'baseline_capture' => [
|
||||
'reason_code' => BaselineReasonCodes::CAPTURE_INVENTORY_FAILED,
|
||||
'current_baseline_changed' => false,
|
||||
],
|
||||
],
|
||||
]);
|
||||
|
||||
Livewire::test(BaselineCompareLanding::class)
|
||||
->assertSet('state', 'no_snapshot')
|
||||
->assertSet('snapshotId', null)
|
||||
->assertSet('message', 'The latest inventory sync failed, so this capture could not use a credible upstream basis.');
|
||||
});
|
||||
|
||||
it('keeps compare available against the prior consumable snapshot after a zero-subject capture', function (): void {
|
||||
[$user, $tenant] = createUserWithTenant(role: 'owner');
|
||||
$this->actingAs($user);
|
||||
|
||||
$tenant->makeCurrent();
|
||||
Filament::setTenant($tenant, true);
|
||||
|
||||
$profile = BaselineProfile::factory()->active()->create([
|
||||
'workspace_id' => (int) $tenant->workspace_id,
|
||||
]);
|
||||
|
||||
$snapshot = BaselineSnapshot::factory()->complete()->create([
|
||||
'workspace_id' => (int) $tenant->workspace_id,
|
||||
'baseline_profile_id' => (int) $profile->getKey(),
|
||||
]);
|
||||
|
||||
$profile->update(['active_snapshot_id' => (int) $snapshot->getKey()]);
|
||||
|
||||
BaselineTenantAssignment::factory()->create([
|
||||
'workspace_id' => (int) $tenant->workspace_id,
|
||||
'tenant_id' => (int) $tenant->getKey(),
|
||||
'baseline_profile_id' => (int) $profile->getKey(),
|
||||
]);
|
||||
|
||||
OperationRun::factory()->create([
|
||||
'tenant_id' => (int) $tenant->getKey(),
|
||||
'workspace_id' => (int) $tenant->workspace_id,
|
||||
'type' => OperationRunType::BaselineCapture->value,
|
||||
'status' => OperationRunStatus::Completed->value,
|
||||
'outcome' => OperationRunOutcome::PartiallySucceeded->value,
|
||||
'completed_at' => now(),
|
||||
'context' => [
|
||||
'baseline_profile_id' => (int) $profile->getKey(),
|
||||
'reason_code' => BaselineReasonCodes::CAPTURE_ZERO_SUBJECTS,
|
||||
'baseline_capture' => [
|
||||
'reason_code' => BaselineReasonCodes::CAPTURE_ZERO_SUBJECTS,
|
||||
'current_baseline_changed' => false,
|
||||
'previous_current_snapshot_exists' => true,
|
||||
],
|
||||
],
|
||||
]);
|
||||
|
||||
Livewire::test(BaselineCompareLanding::class)
|
||||
->assertSet('state', 'idle')
|
||||
->assertSet('snapshotId', (int) $snapshot->getKey())
|
||||
->assertActionEnabled('compareNow');
|
||||
});
|
||||
|
||||
@ -93,9 +93,6 @@ function seedCaptureProfileForTenant(
|
||||
[$user, $tenant] = createUserWithTenant(role: 'owner');
|
||||
|
||||
$profile = seedCaptureProfileForTenant($tenant);
|
||||
$inventorySyncRun = createInventorySyncOperationRunWithCoverage($tenant, [
|
||||
'deviceConfiguration' => 'succeeded',
|
||||
]);
|
||||
|
||||
session()->put(WorkspaceContext::SESSION_KEY, (int) $tenant->workspace_id);
|
||||
|
||||
@ -127,31 +124,6 @@ function seedCaptureProfileForTenant(
|
||||
|
||||
expect($run)->not->toBeNull();
|
||||
expect($run?->status)->toBe('queued');
|
||||
expect(data_get($run?->context, 'baseline_capture.inventory_sync_run_id'))->toBe((int) $inventorySyncRun->getKey());
|
||||
});
|
||||
|
||||
it('shows the shared capture block on the start surface when no credible inventory basis exists', function (): void {
|
||||
Queue::fake();
|
||||
|
||||
[$user, $tenant] = createUserWithTenant(role: 'owner');
|
||||
|
||||
$profile = seedCaptureProfileForTenant($tenant, BaselineCaptureMode::Opportunistic, [
|
||||
'scope_jsonb' => ['policy_types' => ['deviceConfiguration'], 'foundation_types' => []],
|
||||
]);
|
||||
|
||||
session()->put(WorkspaceContext::SESSION_KEY, (int) $tenant->workspace_id);
|
||||
|
||||
Livewire::actingAs($user)
|
||||
->test(ViewBaselineProfile::class, ['record' => $profile->getKey()])
|
||||
->assertActionVisible('capture')
|
||||
->assertActionHasLabel('capture', 'Capture baseline')
|
||||
->assertActionEnabled('capture')
|
||||
->callAction('capture', data: ['source_tenant_id' => (int) $tenant->getKey()])
|
||||
->assertNotified('Cannot start capture')
|
||||
->assertStatus(200);
|
||||
|
||||
Queue::assertNotPushed(CaptureBaselineSnapshotJob::class);
|
||||
expect(OperationRun::query()->where('type', 'baseline_capture')->count())->toBe(0);
|
||||
});
|
||||
|
||||
it('does not start full-content capture when rollout is disabled', function (): void {
|
||||
|
||||
@ -14,8 +14,6 @@
|
||||
it('filters baseline profiles by status inside the current workspace', function (): void {
|
||||
[$user, $tenant] = createUserWithTenant(role: 'owner');
|
||||
|
||||
expect(defined(BaselineProfile::class.'::STATUS_ACTIVE'))->toBeFalse();
|
||||
|
||||
$active = BaselineProfile::factory()->active()->create([
|
||||
'workspace_id' => (int) $tenant->workspace_id,
|
||||
]);
|
||||
|
||||
@ -7,7 +7,6 @@
|
||||
use App\Filament\Resources\BaselineProfileResource\Pages\EditBaselineProfile;
|
||||
use App\Filament\Resources\BaselineProfileResource\Pages\ViewBaselineProfile;
|
||||
use App\Models\BaselineProfile;
|
||||
use App\Support\Baselines\BaselineProfileStatus;
|
||||
use App\Support\Governance\GovernanceSubjectTaxonomyRegistry;
|
||||
use App\Support\Workspaces\WorkspaceContext;
|
||||
use Illuminate\Validation\ValidationException;
|
||||
@ -46,7 +45,7 @@
|
||||
expect($profile->scope_jsonb)->toBe([
|
||||
'policy_types' => ['deviceConfiguration'],
|
||||
'foundation_types' => ['assignmentFilter'],
|
||||
])->and($profile->status)->toBe(BaselineProfileStatus::Draft);
|
||||
]);
|
||||
|
||||
expect($profile->canonicalScopeJsonb())->toBe([
|
||||
'version' => 2,
|
||||
@ -84,7 +83,7 @@
|
||||
'name' => 'Legacy baseline profile',
|
||||
'description' => null,
|
||||
'version_label' => null,
|
||||
'status' => BaselineProfileStatus::Active->value,
|
||||
'status' => 'active',
|
||||
'capture_mode' => 'opportunistic',
|
||||
'scope_jsonb' => json_encode([
|
||||
'policy_types' => [],
|
||||
@ -179,7 +178,7 @@
|
||||
'name' => 'Legacy lineage profile',
|
||||
'description' => null,
|
||||
'version_label' => null,
|
||||
'status' => BaselineProfileStatus::Active->value,
|
||||
'status' => 'active',
|
||||
'capture_mode' => 'opportunistic',
|
||||
'scope_jsonb' => json_encode([
|
||||
'policy_types' => ['deviceConfiguration'],
|
||||
@ -225,4 +224,4 @@
|
||||
]))->toThrow(ValidationException::class, 'Filters are not supported');
|
||||
|
||||
expect(BaselineProfile::query()->where('name', 'Invalid filtered baseline')->exists())->toBeFalse();
|
||||
});
|
||||
});
|
||||
@ -4,11 +4,9 @@
|
||||
|
||||
use App\Filament\Pages\InventoryCoverage;
|
||||
use App\Filament\Resources\InventoryItemResource;
|
||||
use App\Models\InventoryItem;
|
||||
use App\Models\OperationRun;
|
||||
use App\Models\Tenant;
|
||||
use App\Support\Inventory\InventoryCoverage as InventoryCoveragePayload;
|
||||
use App\Support\OperationRunLinks;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
|
||||
uses(RefreshDatabase::class);
|
||||
@ -42,14 +40,21 @@ function seedCoverageBasisRun(Tenant $tenant): OperationRun
|
||||
|
||||
$run = seedCoverageBasisRun($tenant);
|
||||
|
||||
$historyUrl = OperationRunLinks::index($tenant, operationType: 'inventory_sync');
|
||||
$historyUrl = route('admin.operations.index', [
|
||||
'tenant_id' => (int) $tenant->getKey(),
|
||||
'tableFilters' => [
|
||||
'type' => [
|
||||
'value' => 'inventory_sync',
|
||||
],
|
||||
],
|
||||
]);
|
||||
|
||||
$this->actingAs($user)
|
||||
->get(InventoryCoverage::getUrl(tenant: $tenant))
|
||||
->assertOk()
|
||||
->assertSee('Latest coverage-bearing sync completed')
|
||||
->assertSee('Open basis run')
|
||||
->assertSee(OperationRunLinks::view($run, $tenant), false)
|
||||
->assertSee(route('admin.operations.view', ['run' => (int) $run->getKey()]), false)
|
||||
->assertSee($historyUrl, false)
|
||||
->assertSee('Review the cited inventory sync to inspect provider or permission issues in detail.');
|
||||
});
|
||||
@ -73,26 +78,6 @@ function seedCoverageBasisRun(Tenant $tenant): OperationRun
|
||||
->assertDontSee('Open basis run');
|
||||
});
|
||||
|
||||
it('shows the last inventory sync as a canonical admin operation detail link', function (): void {
|
||||
$tenant = Tenant::factory()->create();
|
||||
[$user, $tenant] = createUserWithTenant(tenant: $tenant, role: 'owner');
|
||||
|
||||
$run = OperationRun::factory()->forTenant($tenant)->create([
|
||||
'type' => 'inventory_sync',
|
||||
]);
|
||||
|
||||
$item = InventoryItem::factory()->create([
|
||||
'tenant_id' => (int) $tenant->getKey(),
|
||||
'last_seen_operation_run_id' => (int) $run->getKey(),
|
||||
]);
|
||||
|
||||
$this->actingAs($user)
|
||||
->get(InventoryItemResource::getUrl('view', ['record' => $item], tenant: $tenant))
|
||||
->assertOk()
|
||||
->assertSee('Last inventory sync')
|
||||
->assertSee(OperationRunLinks::view($run, $tenant), false);
|
||||
});
|
||||
|
||||
it('keeps the no-basis fallback explicit on the inventory items list', function (): void {
|
||||
$tenant = Tenant::factory()->create();
|
||||
[$user, $tenant] = createUserWithTenant(tenant: $tenant, role: 'owner');
|
||||
|
||||
@ -93,44 +93,6 @@ function visibleLivewireText(Testable $component): string
|
||||
->and(mb_strpos($pageText, 'Decision'))->toBeLessThan(mb_strpos($pageText, 'Artifact truth details'));
|
||||
});
|
||||
|
||||
it('shows the shared blocked-inventory explanation for baseline capture runs without a usable snapshot', function (): void {
|
||||
[$user, $tenant] = createUserWithTenant(role: 'owner');
|
||||
|
||||
$run = OperationRun::factory()->create([
|
||||
'tenant_id' => (int) $tenant->getKey(),
|
||||
'workspace_id' => (int) $tenant->workspace_id,
|
||||
'type' => 'baseline_capture',
|
||||
'status' => 'completed',
|
||||
'outcome' => 'blocked',
|
||||
'context' => [
|
||||
'reason_code' => BaselineReasonCodes::CAPTURE_INVENTORY_FAILED,
|
||||
'baseline_capture' => [
|
||||
'reason_code' => BaselineReasonCodes::CAPTURE_INVENTORY_FAILED,
|
||||
'current_baseline_changed' => false,
|
||||
],
|
||||
],
|
||||
'failure_summary' => [
|
||||
['reason_code' => BaselineReasonCodes::CAPTURE_INVENTORY_FAILED, 'message' => 'Capture blocked because the latest inventory sync failed.'],
|
||||
],
|
||||
'completed_at' => now(),
|
||||
]);
|
||||
|
||||
$truth = app(ArtifactTruthPresenter::class)->forOperationRun($run->fresh());
|
||||
$explanation = $truth->operatorExplanation;
|
||||
|
||||
Filament::setTenant(null, true);
|
||||
$this->withSession([WorkspaceContext::SESSION_KEY => (int) $tenant->workspace_id]);
|
||||
session([WorkspaceContext::SESSION_KEY => (int) $tenant->workspace_id]);
|
||||
|
||||
Livewire::actingAs($user)
|
||||
->test(TenantlessOperationRunViewer::class, ['run' => $run])
|
||||
->assertSee('Blocked by prerequisite')
|
||||
->assertSee($explanation?->evaluationResultLabel() ?? '')
|
||||
->assertSee($explanation?->trustworthinessLabel() ?? '')
|
||||
->assertSee('Latest inventory sync failed')
|
||||
->assertSee($explanation?->nextActionText ?? '');
|
||||
});
|
||||
|
||||
it('shows operator explanation facts for baseline compare runs with nested compare reason context', function (): void {
|
||||
[$user, $tenant] = createUserWithTenant(role: 'owner');
|
||||
|
||||
@ -366,9 +328,6 @@ function visibleLivewireText(Testable $component): string
|
||||
'workspace_id' => (int) $tenant->workspace_id,
|
||||
'scope_jsonb' => ['policy_types' => ['deviceConfiguration'], 'foundation_types' => []],
|
||||
]);
|
||||
$inventorySyncRun = createInventorySyncOperationRunWithCoverage($tenant, [
|
||||
'deviceConfiguration' => 'succeeded',
|
||||
]);
|
||||
|
||||
$result = app(BaselineCaptureService::class)->startCapture($profile, $tenant, $user);
|
||||
|
||||
@ -385,10 +344,7 @@ function visibleLivewireText(Testable $component): string
|
||||
->and(data_get($effectiveScope, 'legacy_projection.foundation_types'))->toBe([])
|
||||
->and(data_get($effectiveScope, 'selected_type_keys'))->toBe(['deviceConfiguration'])
|
||||
->and(data_get($effectiveScope, 'allowed_type_keys'))->toBe(['deviceConfiguration'])
|
||||
->and(data_get($effectiveScope, 'unsupported_type_keys'))->toBe([])
|
||||
->and(data_get($run->context, 'baseline_capture.inventory_sync_run_id'))->toBe((int) $inventorySyncRun->getKey())
|
||||
->and(data_get($run->context, 'baseline_capture.eligibility.phase'))->toBe('preflight')
|
||||
->and(data_get($run->context, 'baseline_capture.eligibility.ok'))->toBeTrue();
|
||||
->and(data_get($effectiveScope, 'unsupported_type_keys'))->toBe([]);
|
||||
});
|
||||
|
||||
it('normalizes legacy compare assignment overrides into canonical effective scope without rewriting the override row', function (): void {
|
||||
|
||||
@ -16,7 +16,7 @@
|
||||
|
||||
$this->actingAs($user);
|
||||
|
||||
$run = OperationRun::factory()->create([
|
||||
OperationRun::factory()->create([
|
||||
'workspace_id' => (int) $tenant->workspace_id,
|
||||
'tenant_id' => (int) $tenant->getKey(),
|
||||
'type' => 'provider.connection.check',
|
||||
@ -32,8 +32,6 @@
|
||||
->assertSee('Open operation')
|
||||
->assertSee(OperationRunLinks::openCollectionLabel())
|
||||
->assertSee(OperationRunLinks::collectionScopeDescription())
|
||||
->assertSee(OperationRunLinks::index($tenant), false)
|
||||
->assertSee(OperationRunLinks::tenantlessView($run), false)
|
||||
->assertSee('No action needed.')
|
||||
->assertDontSee('No operations yet.');
|
||||
});
|
||||
|
||||
@ -25,8 +25,6 @@
|
||||
role: 'owner',
|
||||
);
|
||||
|
||||
expect($tenant->fresh()->app_status)->toBe('consent_required');
|
||||
|
||||
$this->actingAs($user);
|
||||
|
||||
Filament::setTenant($tenant, true);
|
||||
@ -35,14 +33,11 @@
|
||||
->assertSee('Lifecycle summary')
|
||||
->assertSee('This tenant is still onboarding. It remains visible on management and review surfaces, but it is not selectable as active context until onboarding completes.')
|
||||
->assertDontSee('App status')
|
||||
->assertDontSee('Consent required')
|
||||
->assertSee('RBAC status')
|
||||
->assertSee('Failed');
|
||||
});
|
||||
|
||||
it('keeps referenced tenant lifecycle context separate from run status in the tenantless operations viewer', function (): void {
|
||||
expect(array_key_exists('app_status', Tenant::factory()->onboarding()->raw()))->toBeFalse();
|
||||
|
||||
$tenant = Tenant::factory()->onboarding()->create([
|
||||
'name' => 'Viewer Separation Tenant',
|
||||
]);
|
||||
|
||||
@ -38,8 +38,6 @@
|
||||
'verification_status' => ProviderVerificationStatus::Unknown->value,
|
||||
]);
|
||||
|
||||
expect($tenant->fresh()->app_status)->toBe('ok');
|
||||
|
||||
$this->actingAs($user);
|
||||
|
||||
session()->put(WorkspaceContext::SESSION_KEY, (int) $tenant->workspace_id);
|
||||
@ -63,17 +61,6 @@
|
||||
->and($visibleColumnNames)->not->toContain('provider_connection_state');
|
||||
});
|
||||
|
||||
it('keeps legacy app status as opt-in test setup instead of a factory default', function (): void {
|
||||
expect(array_key_exists('app_status', Tenant::factory()->raw()))->toBeFalse();
|
||||
|
||||
$tenant = Tenant::factory()->create([
|
||||
'name' => 'Explicit Historical App Status Tenant',
|
||||
'app_status' => 'error',
|
||||
]);
|
||||
|
||||
expect($tenant->fresh()->app_status)->toBe('error');
|
||||
});
|
||||
|
||||
it('keeps lifecycle and rbac separate while leading the provider summary with consent and verification', function (): void {
|
||||
$tenant = Tenant::factory()->create([
|
||||
'status' => Tenant::STATUS_ONBOARDING,
|
||||
@ -99,8 +86,6 @@
|
||||
'verification_status' => ProviderVerificationStatus::Blocked->value,
|
||||
]);
|
||||
|
||||
expect($tenant->fresh()->app_status)->toBe('consent_required');
|
||||
|
||||
$this->actingAs($user);
|
||||
|
||||
session()->put(WorkspaceContext::SESSION_KEY, (int) $tenant->workspace_id);
|
||||
@ -112,7 +97,6 @@
|
||||
->assertSee('RBAC status')
|
||||
->assertSee('Failed')
|
||||
->assertDontSee('App status')
|
||||
->assertDontSee('Consent required')
|
||||
->assertSee('Truth Cleanup Connection')
|
||||
->assertSee('Lifecycle')
|
||||
->assertSee('Disabled')
|
||||
|
||||
@ -71,5 +71,5 @@
|
||||
->assertSee('Recent operations');
|
||||
});
|
||||
|
||||
expect(count(DB::getQueryLog()))->toBeLessThanOrEqual(86);
|
||||
expect(count(DB::getQueryLog()))->toBeLessThan(80);
|
||||
});
|
||||
|
||||
@ -1,160 +0,0 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use Tests\Support\OpsUx\SourceFileScanner;
|
||||
|
||||
/**
|
||||
* @return array<string, string>
|
||||
*/
|
||||
function operationRunLinkContractIncludePaths(): array
|
||||
{
|
||||
$root = SourceFileScanner::projectRoot();
|
||||
|
||||
return [
|
||||
'tenant_recent_operations_summary' => $root.'/app/Filament/Widgets/Tenant/RecentOperationsSummary.php',
|
||||
'inventory_coverage' => $root.'/app/Filament/Pages/InventoryCoverage.php',
|
||||
'inventory_item_resource' => $root.'/app/Filament/Resources/InventoryItemResource.php',
|
||||
'review_pack_resource' => $root.'/app/Filament/Resources/ReviewPackResource.php',
|
||||
'tenantless_operation_run_viewer' => $root.'/app/Filament/Pages/Operations/TenantlessOperationRunViewer.php',
|
||||
'related_navigation_resolver' => $root.'/app/Support/Navigation/RelatedNavigationResolver.php',
|
||||
'system_directory_tenant' => $root.'/app/Filament/System/Pages/Directory/ViewTenant.php',
|
||||
'system_directory_workspace' => $root.'/app/Filament/System/Pages/Directory/ViewWorkspace.php',
|
||||
'system_ops_runs' => $root.'/app/Filament/System/Pages/Ops/Runs.php',
|
||||
'system_ops_view_run' => $root.'/app/Filament/System/Pages/Ops/ViewRun.php',
|
||||
'admin_panel_provider' => $root.'/app/Providers/Filament/AdminPanelProvider.php',
|
||||
'tenant_panel_provider' => $root.'/app/Providers/Filament/TenantPanelProvider.php',
|
||||
'ensure_filament_tenant_selected' => $root.'/app/Support/Middleware/EnsureFilamentTenantSelected.php',
|
||||
'clear_tenant_context_controller' => $root.'/app/Http/Controllers/ClearTenantContextController.php',
|
||||
'operation_run_url_delegate' => $root.'/app/Support/OpsUx/OperationRunUrl.php',
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, string>
|
||||
*/
|
||||
function operationRunLinkContractAllowlist(): array
|
||||
{
|
||||
$paths = operationRunLinkContractIncludePaths();
|
||||
|
||||
return [
|
||||
$paths['admin_panel_provider'] => 'Admin panel navigation is bootstrapping infrastructure and intentionally links to the canonical collection route before request-scoped navigation context exists.',
|
||||
$paths['tenant_panel_provider'] => 'Tenant panel navigation is bootstrapping infrastructure and intentionally links to the canonical collection route before tenant-specific helper context is owned by the source surface.',
|
||||
$paths['ensure_filament_tenant_selected'] => 'Tenant-selection middleware owns redirect/navigation fallback infrastructure and must not fabricate source-surface navigation context.',
|
||||
$paths['clear_tenant_context_controller'] => 'Clear-tenant redirects preserve an explicit redirect contract and cannot depend on UI helper context.',
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, string> $paths
|
||||
* @param array<string, string> $allowlist
|
||||
* @return list<array{file: string, line: int, snippet: string, expectedHelper: string, reason: string}>
|
||||
*/
|
||||
function operationRunLinkContractViolations(array $paths, array $allowlist = []): array
|
||||
{
|
||||
$patterns = [
|
||||
[
|
||||
'pattern' => '/route\(\s*[\'"]admin\.operations\.index[\'"]/',
|
||||
'expectedHelper' => 'OperationRunLinks::index(...)',
|
||||
'reason' => 'Raw admin operations collection route assembly bypasses the canonical admin link helper.',
|
||||
],
|
||||
[
|
||||
'pattern' => '/route\(\s*[\'"]admin\.operations\.view[\'"]/',
|
||||
'expectedHelper' => 'OperationRunLinks::view(...) or OperationRunLinks::tenantlessView(...)',
|
||||
'reason' => 'Raw admin operation detail route assembly bypasses the canonical admin link helper.',
|
||||
],
|
||||
[
|
||||
'pattern' => '/[\'"]\/system\/ops\/runs(?:\/[^\'"]*)?[\'"]/',
|
||||
'expectedHelper' => 'SystemOperationRunLinks::index() or SystemOperationRunLinks::view(...)',
|
||||
'reason' => 'Direct system operations path assembly bypasses the canonical system link helper.',
|
||||
],
|
||||
[
|
||||
'pattern' => '/\b(?:Runs|ViewRun)::getUrl\(/',
|
||||
'expectedHelper' => 'SystemOperationRunLinks::index() or SystemOperationRunLinks::view(...)',
|
||||
'reason' => 'Direct system operations page URL generation belongs behind the canonical system link helper.',
|
||||
],
|
||||
];
|
||||
|
||||
$violations = [];
|
||||
|
||||
foreach ($paths as $path) {
|
||||
if (array_key_exists($path, $allowlist)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$source = SourceFileScanner::read($path);
|
||||
$lines = preg_split('/\R/', $source) ?: [];
|
||||
|
||||
foreach ($lines as $index => $line) {
|
||||
foreach ($patterns as $pattern) {
|
||||
if (preg_match($pattern['pattern'], $line) !== 1) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$violations[] = [
|
||||
'file' => SourceFileScanner::relativePath($path),
|
||||
'line' => $index + 1,
|
||||
'snippet' => SourceFileScanner::snippet($source, $index + 1),
|
||||
'expectedHelper' => $pattern['expectedHelper'],
|
||||
'reason' => $pattern['reason'],
|
||||
];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return $violations;
|
||||
}
|
||||
|
||||
it('keeps covered operation run link producers on canonical helper families', function (): void {
|
||||
$paths = operationRunLinkContractIncludePaths();
|
||||
$allowlist = operationRunLinkContractAllowlist();
|
||||
|
||||
$violations = operationRunLinkContractViolations($paths, $allowlist);
|
||||
|
||||
expect($violations)->toBeEmpty();
|
||||
})->group('surface-guard');
|
||||
|
||||
it('keeps the operation run link exception boundary explicit and infrastructure-owned', function (): void {
|
||||
$allowlist = operationRunLinkContractAllowlist();
|
||||
|
||||
expect(array_keys($allowlist))->toHaveCount(4);
|
||||
|
||||
foreach ($allowlist as $reason) {
|
||||
expect($reason)
|
||||
->not->toBe('')
|
||||
->not->toContain('convenience');
|
||||
}
|
||||
|
||||
foreach (array_keys($allowlist) as $path) {
|
||||
expect(SourceFileScanner::read($path))->toContain("route('admin.operations.index')");
|
||||
}
|
||||
})->group('surface-guard');
|
||||
|
||||
it('reports actionable file and snippet output for a representative raw bypass', function (): void {
|
||||
$probePath = storage_path('framework/testing/OperationRunLinkContractProbe.php');
|
||||
|
||||
if (! is_dir(dirname($probePath))) {
|
||||
mkdir(dirname($probePath), 0777, true);
|
||||
}
|
||||
|
||||
file_put_contents($probePath, <<<'PHP'
|
||||
<?php
|
||||
|
||||
return route('admin.operations.view', ['run' => 123]);
|
||||
PHP);
|
||||
|
||||
try {
|
||||
$violations = operationRunLinkContractViolations([
|
||||
'probe' => $probePath,
|
||||
]);
|
||||
} finally {
|
||||
@unlink($probePath);
|
||||
}
|
||||
|
||||
expect($violations)->toHaveCount(1)
|
||||
->and($violations[0]['file'])->toContain('OperationRunLinkContractProbe.php')
|
||||
->and($violations[0]['line'])->toBe(3)
|
||||
->and($violations[0]['snippet'])->toContain("route('admin.operations.view'")
|
||||
->and($violations[0]['expectedHelper'])->toContain('OperationRunLinks::view')
|
||||
->and($violations[0]['reason'])->toContain('bypasses the canonical admin link helper');
|
||||
})->group('surface-guard');
|
||||
@ -17,7 +17,7 @@
|
||||
'finding_lifecycle',
|
||||
'tenant_lifecycle',
|
||||
])
|
||||
->and(array_keys($rules))->toHaveCount(17)
|
||||
->and(array_keys($rules))->toHaveCount(16)
|
||||
->and($bindings)->not->toBeEmpty();
|
||||
|
||||
foreach ($bindings as $binding) {
|
||||
|
||||
@ -198,197 +198,6 @@
|
||||
->assertSee('The provider connection will be used for all Graph API calls.');
|
||||
});
|
||||
|
||||
it('renders selected bootstrap actions in the review summary before any bootstrap run starts', function (): void {
|
||||
$workspace = Workspace::factory()->create();
|
||||
$user = User::factory()->create();
|
||||
|
||||
WorkspaceMembership::factory()->create([
|
||||
'workspace_id' => (int) $workspace->getKey(),
|
||||
'user_id' => (int) $user->getKey(),
|
||||
'role' => 'owner',
|
||||
]);
|
||||
|
||||
session()->put(WorkspaceContext::SESSION_KEY, (int) $workspace->getKey());
|
||||
|
||||
$entraTenantId = 'cdcdcdcd-cdcd-cdcd-cdcd-cdcdcdcdcdcd';
|
||||
|
||||
$tenant = Tenant::factory()->create([
|
||||
'workspace_id' => (int) $workspace->getKey(),
|
||||
'tenant_id' => $entraTenantId,
|
||||
'status' => Tenant::STATUS_ONBOARDING,
|
||||
'name' => 'Bootstrap Selected Tenant',
|
||||
]);
|
||||
|
||||
$user->tenants()->syncWithoutDetaching([
|
||||
$tenant->getKey() => ['role' => 'owner'],
|
||||
]);
|
||||
|
||||
$connection = ProviderConnection::factory()->platform()->consentGranted()->create([
|
||||
'workspace_id' => (int) $workspace->getKey(),
|
||||
'tenant_id' => (int) $tenant->getKey(),
|
||||
'provider' => 'microsoft',
|
||||
'entra_tenant_id' => $entraTenantId,
|
||||
'display_name' => 'Platform onboarding connection',
|
||||
'is_default' => true,
|
||||
'is_enabled' => true,
|
||||
]);
|
||||
|
||||
$run = OperationRun::factory()->create([
|
||||
'workspace_id' => (int) $workspace->getKey(),
|
||||
'tenant_id' => (int) $tenant->getKey(),
|
||||
'type' => 'provider.connection.check',
|
||||
'status' => OperationRunStatus::Completed->value,
|
||||
'outcome' => OperationRunOutcome::Succeeded->value,
|
||||
'context' => [
|
||||
'provider_connection_id' => (int) $connection->getKey(),
|
||||
'target_scope' => [
|
||||
'entra_tenant_id' => $entraTenantId,
|
||||
'entra_tenant_name' => 'Bootstrap Selected Tenant',
|
||||
],
|
||||
'verification_report' => VerificationReportWriter::build('provider.connection.check', [
|
||||
[
|
||||
'key' => 'consent',
|
||||
'title' => 'Required application permissions',
|
||||
'status' => 'pass',
|
||||
'severity' => 'low',
|
||||
'blocking' => false,
|
||||
'reason_code' => 'ok',
|
||||
'message' => 'Consent is ready.',
|
||||
'evidence' => [],
|
||||
'next_steps' => [],
|
||||
],
|
||||
]),
|
||||
],
|
||||
]);
|
||||
|
||||
$session = TenantOnboardingSession::query()->create([
|
||||
'workspace_id' => (int) $workspace->getKey(),
|
||||
'tenant_id' => (int) $tenant->getKey(),
|
||||
'entra_tenant_id' => $entraTenantId,
|
||||
'current_step' => 'complete',
|
||||
'state' => [
|
||||
'provider_connection_id' => (int) $connection->getKey(),
|
||||
'verification_operation_run_id' => (int) $run->getKey(),
|
||||
'bootstrap_operation_types' => ['inventory_sync', 'compliance.snapshot'],
|
||||
],
|
||||
'started_by_user_id' => (int) $user->getKey(),
|
||||
'updated_by_user_id' => (int) $user->getKey(),
|
||||
]);
|
||||
|
||||
$component = Livewire::actingAs($user)
|
||||
->test(ManagedTenantOnboardingWizard::class, ['onboardingDraft' => (int) $session->getKey()]);
|
||||
|
||||
$component
|
||||
->assertDontSee('Bootstrap needs attention')
|
||||
->assertDontSee('Selected bootstrap actions must complete before activation. Return to Bootstrap to remove the selected actions and skip this optional step, or resolve the required permission and start the blocked action again.');
|
||||
|
||||
$summaryMethod = new \ReflectionMethod($component->instance(), 'completionSummaryBootstrapSummary');
|
||||
$summaryMethod->setAccessible(true);
|
||||
|
||||
expect($summaryMethod->invoke($component->instance()))->toBe('Selected - 2 action(s) selected');
|
||||
});
|
||||
|
||||
it('renders blocked bootstrap runs as action required in the review summary', function (): void {
|
||||
$workspace = Workspace::factory()->create();
|
||||
$user = User::factory()->create();
|
||||
|
||||
WorkspaceMembership::factory()->create([
|
||||
'workspace_id' => (int) $workspace->getKey(),
|
||||
'user_id' => (int) $user->getKey(),
|
||||
'role' => 'owner',
|
||||
]);
|
||||
|
||||
session()->put(WorkspaceContext::SESSION_KEY, (int) $workspace->getKey());
|
||||
|
||||
$entraTenantId = 'efefefef-efef-efef-efef-efefefefefef';
|
||||
|
||||
$tenant = Tenant::factory()->create([
|
||||
'workspace_id' => (int) $workspace->getKey(),
|
||||
'tenant_id' => $entraTenantId,
|
||||
'status' => Tenant::STATUS_ONBOARDING,
|
||||
'name' => 'Bootstrap Blocked Tenant',
|
||||
]);
|
||||
|
||||
$user->tenants()->syncWithoutDetaching([
|
||||
$tenant->getKey() => ['role' => 'owner'],
|
||||
]);
|
||||
|
||||
$connection = ProviderConnection::factory()->platform()->consentGranted()->create([
|
||||
'workspace_id' => (int) $workspace->getKey(),
|
||||
'tenant_id' => (int) $tenant->getKey(),
|
||||
'provider' => 'microsoft',
|
||||
'entra_tenant_id' => $entraTenantId,
|
||||
'display_name' => 'Platform onboarding connection',
|
||||
'is_default' => true,
|
||||
'is_enabled' => true,
|
||||
]);
|
||||
|
||||
$verificationRun = OperationRun::factory()->create([
|
||||
'workspace_id' => (int) $workspace->getKey(),
|
||||
'tenant_id' => (int) $tenant->getKey(),
|
||||
'type' => 'provider.connection.check',
|
||||
'status' => OperationRunStatus::Completed->value,
|
||||
'outcome' => OperationRunOutcome::Succeeded->value,
|
||||
'context' => [
|
||||
'provider_connection_id' => (int) $connection->getKey(),
|
||||
'target_scope' => [
|
||||
'entra_tenant_id' => $entraTenantId,
|
||||
'entra_tenant_name' => 'Bootstrap Blocked Tenant',
|
||||
],
|
||||
'verification_report' => VerificationReportWriter::build('provider.connection.check', [
|
||||
[
|
||||
'key' => 'consent',
|
||||
'title' => 'Required application permissions',
|
||||
'status' => 'pass',
|
||||
'severity' => 'low',
|
||||
'blocking' => false,
|
||||
'reason_code' => 'ok',
|
||||
'message' => 'Consent is ready.',
|
||||
'evidence' => [],
|
||||
'next_steps' => [],
|
||||
],
|
||||
]),
|
||||
],
|
||||
]);
|
||||
|
||||
$bootstrapRun = OperationRun::factory()->create([
|
||||
'workspace_id' => (int) $workspace->getKey(),
|
||||
'tenant_id' => (int) $tenant->getKey(),
|
||||
'type' => 'inventory_sync',
|
||||
'status' => OperationRunStatus::Completed->value,
|
||||
'outcome' => OperationRunOutcome::Blocked->value,
|
||||
'context' => [
|
||||
'provider_connection_id' => (int) $connection->getKey(),
|
||||
'reason_translation' => [
|
||||
'operator_label' => 'Permission required',
|
||||
],
|
||||
],
|
||||
]);
|
||||
|
||||
$session = TenantOnboardingSession::query()->create([
|
||||
'workspace_id' => (int) $workspace->getKey(),
|
||||
'tenant_id' => (int) $tenant->getKey(),
|
||||
'entra_tenant_id' => $entraTenantId,
|
||||
'current_step' => 'complete',
|
||||
'state' => [
|
||||
'provider_connection_id' => (int) $connection->getKey(),
|
||||
'verification_operation_run_id' => (int) $verificationRun->getKey(),
|
||||
'bootstrap_operation_types' => ['inventory_sync', 'compliance.snapshot'],
|
||||
'bootstrap_operation_runs' => ['inventory_sync' => (int) $bootstrapRun->getKey()],
|
||||
],
|
||||
'started_by_user_id' => (int) $user->getKey(),
|
||||
'updated_by_user_id' => (int) $user->getKey(),
|
||||
]);
|
||||
|
||||
$component = Livewire::actingAs($user)
|
||||
->test(ManagedTenantOnboardingWizard::class, ['onboardingDraft' => (int) $session->getKey()]);
|
||||
|
||||
$summaryMethod = new \ReflectionMethod($component->instance(), 'completionSummaryBootstrapSummary');
|
||||
$summaryMethod->setAccessible(true);
|
||||
|
||||
expect($summaryMethod->invoke($component->instance()))->toBe('Action required - Permission required');
|
||||
});
|
||||
|
||||
it('initializes entangled wizard state keys to avoid Livewire entangle errors', function (): void {
|
||||
$workspace = Workspace::factory()->create();
|
||||
$user = User::factory()->create();
|
||||
@ -404,198 +213,10 @@
|
||||
Livewire::actingAs($user)
|
||||
->test(ManagedTenantOnboardingWizard::class)
|
||||
->assertSet('data.notes', '')
|
||||
->assertSet('data.bootstrap_operation_types', [])
|
||||
->assertSet('data.override_blocked', false)
|
||||
->assertSet('data.override_reason', '');
|
||||
});
|
||||
|
||||
it('persists selected bootstrap actions in the onboarding draft state', function (): void {
|
||||
$workspace = Workspace::factory()->create();
|
||||
$user = User::factory()->create();
|
||||
|
||||
WorkspaceMembership::factory()->create([
|
||||
'workspace_id' => (int) $workspace->getKey(),
|
||||
'user_id' => (int) $user->getKey(),
|
||||
'role' => 'owner',
|
||||
]);
|
||||
|
||||
session()->put(WorkspaceContext::SESSION_KEY, (int) $workspace->getKey());
|
||||
|
||||
$entraTenantId = 'dededede-dede-dede-dede-dededededede';
|
||||
|
||||
$tenant = Tenant::factory()->create([
|
||||
'workspace_id' => (int) $workspace->getKey(),
|
||||
'tenant_id' => $entraTenantId,
|
||||
'status' => Tenant::STATUS_ONBOARDING,
|
||||
'name' => 'Persist Bootstrap Tenant',
|
||||
]);
|
||||
|
||||
$user->tenants()->syncWithoutDetaching([
|
||||
$tenant->getKey() => ['role' => 'owner'],
|
||||
]);
|
||||
|
||||
$connection = ProviderConnection::factory()->platform()->consentGranted()->create([
|
||||
'workspace_id' => (int) $workspace->getKey(),
|
||||
'tenant_id' => (int) $tenant->getKey(),
|
||||
'provider' => 'microsoft',
|
||||
'entra_tenant_id' => $entraTenantId,
|
||||
'display_name' => 'Platform onboarding connection',
|
||||
'is_default' => true,
|
||||
'is_enabled' => true,
|
||||
]);
|
||||
|
||||
$run = OperationRun::factory()->create([
|
||||
'workspace_id' => (int) $workspace->getKey(),
|
||||
'tenant_id' => (int) $tenant->getKey(),
|
||||
'type' => 'provider.connection.check',
|
||||
'status' => OperationRunStatus::Completed->value,
|
||||
'outcome' => OperationRunOutcome::Succeeded->value,
|
||||
'context' => [
|
||||
'provider_connection_id' => (int) $connection->getKey(),
|
||||
'verification_report' => VerificationReportWriter::build('provider.connection.check', [
|
||||
[
|
||||
'key' => 'consent',
|
||||
'title' => 'Required application permissions',
|
||||
'status' => 'pass',
|
||||
'severity' => 'low',
|
||||
'blocking' => false,
|
||||
'reason_code' => 'ok',
|
||||
'message' => 'Consent is ready.',
|
||||
'evidence' => [],
|
||||
'next_steps' => [],
|
||||
],
|
||||
]),
|
||||
],
|
||||
]);
|
||||
|
||||
$session = TenantOnboardingSession::query()->create([
|
||||
'workspace_id' => (int) $workspace->getKey(),
|
||||
'tenant_id' => (int) $tenant->getKey(),
|
||||
'entra_tenant_id' => $entraTenantId,
|
||||
'current_step' => 'bootstrap',
|
||||
'state' => [
|
||||
'provider_connection_id' => (int) $connection->getKey(),
|
||||
'verification_operation_run_id' => (int) $run->getKey(),
|
||||
],
|
||||
'started_by_user_id' => (int) $user->getKey(),
|
||||
'updated_by_user_id' => (int) $user->getKey(),
|
||||
]);
|
||||
|
||||
$component = Livewire::actingAs($user)
|
||||
->test(ManagedTenantOnboardingWizard::class, ['onboardingDraft' => (int) $session->getKey()]);
|
||||
|
||||
$persistMethod = new \ReflectionMethod($component->instance(), 'persistBootstrapSelection');
|
||||
$persistMethod->setAccessible(true);
|
||||
$persistMethod->invoke($component->instance(), ['inventory_sync', 'compliance.snapshot']);
|
||||
|
||||
$session->refresh();
|
||||
|
||||
expect($session->state['bootstrap_operation_types'] ?? null)->toBe(['inventory_sync', 'compliance.snapshot']);
|
||||
});
|
||||
|
||||
it('filters unsupported bootstrap selections from persisted onboarding drafts', function (): void {
|
||||
$workspace = Workspace::factory()->create();
|
||||
$user = User::factory()->create();
|
||||
|
||||
WorkspaceMembership::factory()->create([
|
||||
'workspace_id' => $workspace->getKey(),
|
||||
'user_id' => $user->getKey(),
|
||||
'role' => 'owner',
|
||||
]);
|
||||
|
||||
session()->put(WorkspaceContext::SESSION_KEY, (int) $workspace->getKey());
|
||||
|
||||
$tenantGuid = '12121212-1212-1212-1212-121212121212';
|
||||
|
||||
$tenant = Tenant::factory()->create([
|
||||
'workspace_id' => (int) $workspace->getKey(),
|
||||
'tenant_id' => $tenantGuid,
|
||||
'status' => Tenant::STATUS_ONBOARDING,
|
||||
'name' => 'Acme',
|
||||
]);
|
||||
|
||||
$user->tenants()->syncWithoutDetaching([
|
||||
$tenant->getKey() => ['role' => 'owner'],
|
||||
]);
|
||||
|
||||
$connection = ProviderConnection::factory()->platform()->consentGranted()->create([
|
||||
'workspace_id' => (int) $workspace->getKey(),
|
||||
'tenant_id' => (int) $tenant->getKey(),
|
||||
'provider' => 'microsoft',
|
||||
'entra_tenant_id' => $tenantGuid,
|
||||
'display_name' => 'Platform onboarding connection',
|
||||
'is_default' => true,
|
||||
'is_enabled' => true,
|
||||
]);
|
||||
|
||||
$run = OperationRun::factory()->create([
|
||||
'workspace_id' => (int) $workspace->getKey(),
|
||||
'tenant_id' => (int) $tenant->getKey(),
|
||||
'type' => 'provider.connection.check',
|
||||
'status' => OperationRunStatus::Completed->value,
|
||||
'outcome' => OperationRunOutcome::Succeeded->value,
|
||||
'context' => [
|
||||
'provider_connection_id' => (int) $connection->getKey(),
|
||||
'target_scope' => [
|
||||
'entra_tenant_id' => $tenantGuid,
|
||||
'entra_tenant_name' => 'Acme',
|
||||
],
|
||||
'verification_report' => VerificationReportWriter::build('provider.connection.check', [
|
||||
[
|
||||
'key' => 'consent',
|
||||
'title' => 'Required application permissions',
|
||||
'status' => 'pass',
|
||||
'severity' => 'low',
|
||||
'blocking' => false,
|
||||
'reason_code' => 'ok',
|
||||
'message' => 'Consent is ready.',
|
||||
'evidence' => [],
|
||||
'next_steps' => [],
|
||||
],
|
||||
]),
|
||||
],
|
||||
]);
|
||||
|
||||
$session = TenantOnboardingSession::query()->create([
|
||||
'workspace_id' => (int) $workspace->getKey(),
|
||||
'tenant_id' => (int) $tenant->getKey(),
|
||||
'entra_tenant_id' => $tenantGuid,
|
||||
'current_step' => 'complete',
|
||||
'state' => [
|
||||
'provider_connection_id' => (int) $connection->getKey(),
|
||||
'verification_operation_run_id' => (int) $run->getKey(),
|
||||
'bootstrap_operation_types' => [
|
||||
'inventory_sync',
|
||||
'compliance.snapshot',
|
||||
'restore.execute',
|
||||
'entra_group_sync',
|
||||
'directory_role_definitions.sync',
|
||||
],
|
||||
],
|
||||
'started_by_user_id' => (int) $user->getKey(),
|
||||
'updated_by_user_id' => (int) $user->getKey(),
|
||||
]);
|
||||
|
||||
$component = Livewire::actingAs($user)
|
||||
->test(ManagedTenantOnboardingWizard::class, ['onboardingDraft' => (int) $session->getKey()]);
|
||||
|
||||
$normalizeMethod = new \ReflectionMethod($component->instance(), 'normalizeBootstrapOperationTypes');
|
||||
$normalizeMethod->setAccessible(true);
|
||||
|
||||
expect($normalizeMethod->invoke($component->instance(), [
|
||||
'inventory_sync',
|
||||
'compliance.snapshot',
|
||||
'restore.execute',
|
||||
'entra_group_sync',
|
||||
'directory_role_definitions.sync',
|
||||
]))->toBe(['inventory_sync', 'compliance.snapshot']);
|
||||
|
||||
$optionsMethod = new \ReflectionMethod($component->instance(), 'bootstrapOperationOptions');
|
||||
$optionsMethod->setAccessible(true);
|
||||
|
||||
expect(array_keys($optionsMethod->invoke($component->instance())))->toBe(['inventory_sync', 'compliance.snapshot']);
|
||||
});
|
||||
|
||||
it('returns resumable drafts with missing provider connections to the provider connection step', function (): void {
|
||||
$workspace = Workspace::factory()->create();
|
||||
$user = User::factory()->create();
|
||||
@ -1424,10 +1045,7 @@
|
||||
]),
|
||||
]);
|
||||
|
||||
$component->call('startBootstrap', [
|
||||
'inventory_sync' => true,
|
||||
'compliance.snapshot' => true,
|
||||
]);
|
||||
$component->call('startBootstrap', ['inventory_sync', 'compliance.snapshot']);
|
||||
|
||||
Bus::assertDispatchedTimes(\App\Jobs\ProviderInventorySyncJob::class, 1);
|
||||
Bus::assertNotDispatched(\App\Jobs\ProviderComplianceSnapshotJob::class);
|
||||
|
||||
@ -13,7 +13,6 @@
|
||||
use App\Support\Audit\AuditActorType;
|
||||
use App\Support\Audit\AuditOutcome;
|
||||
use App\Support\Baselines\BaselineCaptureMode;
|
||||
use App\Support\Baselines\BaselineReasonCodes;
|
||||
|
||||
it('derives summary-first audit semantics for baseline capture workflow events', function (): void {
|
||||
[$user, $tenant] = createUserWithTenant(role: 'owner');
|
||||
@ -80,58 +79,3 @@
|
||||
->and($completed?->targetDisplayLabel())->not->toBeNull()
|
||||
->and((int) $completed?->operation_run_id)->toBe((int) $run->getKey());
|
||||
});
|
||||
|
||||
it('records no-data baseline capture audit metadata without claiming baseline truth changed', function (): void {
|
||||
[$user, $tenant] = createUserWithTenant(role: 'owner');
|
||||
|
||||
$profile = BaselineProfile::factory()->active()->create([
|
||||
'workspace_id' => (int) $tenant->workspace_id,
|
||||
'capture_mode' => BaselineCaptureMode::Opportunistic->value,
|
||||
'scope_jsonb' => ['policy_types' => ['deviceConfiguration'], 'foundation_types' => []],
|
||||
]);
|
||||
|
||||
$inventorySyncRun = createInventorySyncOperationRunWithCoverage($tenant, [
|
||||
'deviceConfiguration' => 'succeeded',
|
||||
]);
|
||||
|
||||
$operationRunService = app(OperationRunService::class);
|
||||
$run = $operationRunService->ensureRunWithIdentity(
|
||||
tenant: $tenant,
|
||||
type: 'baseline_capture',
|
||||
identityInputs: ['baseline_profile_id' => (int) $profile->getKey()],
|
||||
context: [
|
||||
'baseline_profile_id' => (int) $profile->getKey(),
|
||||
'source_tenant_id' => (int) $tenant->getKey(),
|
||||
'effective_scope' => ['policy_types' => ['deviceConfiguration'], 'foundation_types' => []],
|
||||
'baseline_capture' => [
|
||||
'inventory_sync_run_id' => (int) $inventorySyncRun->getKey(),
|
||||
'eligibility' => [
|
||||
'phase' => 'preflight',
|
||||
'ok' => true,
|
||||
'inventory_sync_run_id' => (int) $inventorySyncRun->getKey(),
|
||||
'covered_types' => ['deviceConfiguration'],
|
||||
'uncovered_types' => [],
|
||||
],
|
||||
],
|
||||
],
|
||||
initiator: $user,
|
||||
);
|
||||
|
||||
(new CaptureBaselineSnapshotJob($run))->handle(
|
||||
app(BaselineSnapshotIdentity::class),
|
||||
app(InventoryMetaContract::class),
|
||||
app(AuditLogger::class),
|
||||
$operationRunService,
|
||||
);
|
||||
|
||||
$completed = AuditLog::query()
|
||||
->where('tenant_id', (int) $tenant->getKey())
|
||||
->where('action', 'baseline.capture.completed')
|
||||
->latest('id')
|
||||
->first();
|
||||
|
||||
expect($completed)->not->toBeNull();
|
||||
expect(data_get($completed?->metadata, 'reason_code'))->toBe(BaselineReasonCodes::CAPTURE_ZERO_SUBJECTS);
|
||||
expect(data_get($completed?->metadata, 'current_baseline_changed'))->toBeFalse();
|
||||
expect(data_get($completed?->metadata, 'snapshot_id'))->not->toBeNull();
|
||||
});
|
||||
|
||||
@ -5,7 +5,6 @@
|
||||
use App\Filament\Pages\Operations\TenantlessOperationRunViewer;
|
||||
use App\Models\OperationRun;
|
||||
use App\Models\Tenant;
|
||||
use App\Support\Baselines\BaselineReasonCodes;
|
||||
use App\Support\Workspaces\WorkspaceContext;
|
||||
use Filament\Facades\Filament;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
@ -176,77 +175,3 @@ function governanceRunViewer(TestCase $testCase, $user, Tenant $tenant, Operatio
|
||||
expect(mb_strpos($pageText, 'Missing sections'))->toBeLessThan(mb_strpos($pageText, 'Secondary causes'))
|
||||
->and($pageText)->toContain('stale evidence');
|
||||
});
|
||||
|
||||
it('shows failed-latest-inventory baseline capture summaries before diagnostics', function (): void {
|
||||
$tenant = Tenant::factory()->create();
|
||||
[$user, $tenant] = createUserWithTenant(tenant: $tenant, role: 'owner');
|
||||
|
||||
$run = OperationRun::factory()->create([
|
||||
'tenant_id' => (int) $tenant->getKey(),
|
||||
'workspace_id' => (int) $tenant->workspace_id,
|
||||
'type' => 'baseline_capture',
|
||||
'status' => 'completed',
|
||||
'outcome' => 'blocked',
|
||||
'context' => [
|
||||
'reason_code' => BaselineReasonCodes::CAPTURE_INVENTORY_FAILED,
|
||||
'baseline_capture' => [
|
||||
'reason_code' => BaselineReasonCodes::CAPTURE_INVENTORY_FAILED,
|
||||
'subjects_total' => 0,
|
||||
'current_baseline_changed' => false,
|
||||
],
|
||||
],
|
||||
'failure_summary' => [[
|
||||
'reason_code' => BaselineReasonCodes::CAPTURE_INVENTORY_FAILED,
|
||||
'message' => 'Capture blocked because the latest inventory sync failed.',
|
||||
]],
|
||||
'completed_at' => now(),
|
||||
]);
|
||||
|
||||
$component = governanceRunViewer($this, $user, $tenant, $run)
|
||||
->assertSee('The baseline capture was blocked because the latest inventory sync failed.')
|
||||
->assertSee('Latest inventory sync failed')
|
||||
->assertSee('Artifact impact')
|
||||
->assertSee('Dominant cause');
|
||||
|
||||
$pageText = governanceVisibleText($component);
|
||||
|
||||
expect(mb_strpos($pageText, 'The baseline capture was blocked because the latest inventory sync failed.'))
|
||||
->toBeLessThan(mb_strpos($pageText, 'Artifact truth details'));
|
||||
});
|
||||
|
||||
it('shows zero-subject baseline capture summaries before diagnostics', function (): void {
|
||||
$tenant = Tenant::factory()->create();
|
||||
[$user, $tenant] = createUserWithTenant(tenant: $tenant, role: 'owner');
|
||||
|
||||
$run = OperationRun::factory()->create([
|
||||
'tenant_id' => (int) $tenant->getKey(),
|
||||
'workspace_id' => (int) $tenant->workspace_id,
|
||||
'type' => 'baseline_capture',
|
||||
'status' => 'completed',
|
||||
'outcome' => 'partially_succeeded',
|
||||
'context' => [
|
||||
'reason_code' => BaselineReasonCodes::CAPTURE_ZERO_SUBJECTS,
|
||||
'baseline_capture' => [
|
||||
'reason_code' => BaselineReasonCodes::CAPTURE_ZERO_SUBJECTS,
|
||||
'subjects_total' => 0,
|
||||
'current_baseline_changed' => false,
|
||||
],
|
||||
],
|
||||
'summary_counts' => [
|
||||
'total' => 0,
|
||||
'processed' => 0,
|
||||
'failed' => 0,
|
||||
],
|
||||
'completed_at' => now(),
|
||||
]);
|
||||
|
||||
$component = governanceRunViewer($this, $user, $tenant, $run)
|
||||
->assertSee('The baseline capture finished without a usable baseline because no governed subjects were in scope.')
|
||||
->assertSee('No subjects were in scope')
|
||||
->assertSee('Primary next step');
|
||||
|
||||
$pageText = governanceVisibleText($component);
|
||||
|
||||
expect(mb_strpos($pageText, 'The baseline capture finished without a usable baseline because no governed subjects were in scope.'))
|
||||
->toBeLessThan(mb_strpos($pageText, 'Artifact truth details'));
|
||||
});
|
||||
|
||||
@ -5,7 +5,6 @@
|
||||
use App\Notifications\OperationRunCompleted;
|
||||
use App\Notifications\OperationRunQueued;
|
||||
use App\Services\OperationRunService;
|
||||
use App\Support\Baselines\BaselineReasonCodes;
|
||||
use App\Support\OperationRunLinks;
|
||||
use App\Support\Auth\PlatformCapabilities;
|
||||
use App\Support\System\SystemOperationRunLinks;
|
||||
@ -233,75 +232,6 @@ function spec230ExpectedNotificationIcon(string $status): string
|
||||
$this->get(data_get($notification?->data, 'actions.0.url'))->assertSuccessful();
|
||||
});
|
||||
|
||||
it('includes baseline truth status in blocked baseline-capture terminal notifications', function () {
|
||||
[$user, $tenant] = createUserWithTenant(role: 'owner');
|
||||
$this->actingAs($user);
|
||||
|
||||
$tenant->makeCurrent();
|
||||
Filament::setTenant($tenant, true);
|
||||
|
||||
$run = OperationRun::factory()->create([
|
||||
'tenant_id' => (int) $tenant->getKey(),
|
||||
'workspace_id' => (int) $tenant->workspace_id,
|
||||
'user_id' => (int) $user->getKey(),
|
||||
'initiator_name' => $user->name,
|
||||
'type' => 'baseline_capture',
|
||||
'status' => 'queued',
|
||||
'outcome' => 'pending',
|
||||
'context' => [
|
||||
'baseline_capture' => [
|
||||
'current_baseline_changed' => false,
|
||||
'previous_current_snapshot_exists' => true,
|
||||
],
|
||||
],
|
||||
]);
|
||||
|
||||
app(OperationRunService::class)->finalizeBlockedRun(
|
||||
run: $run,
|
||||
reasonCode: BaselineReasonCodes::CAPTURE_INVENTORY_FAILED,
|
||||
message: 'Capture blocked because the latest inventory sync failed.',
|
||||
);
|
||||
|
||||
$notification = $user->notifications()->latest('id')->first();
|
||||
|
||||
expect($notification)->not->toBeNull()
|
||||
->and(data_get($notification?->data, 'baseline_truth_changed'))->toBeFalse()
|
||||
->and(data_get($notification?->data, 'reason_translation.operator_label'))->toBe('Latest inventory sync failed')
|
||||
->and(data_get($notification?->data, 'diagnostic_reason_code'))->toBe(BaselineReasonCodes::CAPTURE_INVENTORY_FAILED)
|
||||
->and(array_values(data_get($notification?->data, 'supporting_lines', [])))->toContain('Current baseline truth was unchanged.');
|
||||
});
|
||||
|
||||
it('does not emit terminal notifications for initiator-null baseline-capture runs', function () {
|
||||
[$user, $tenant] = createUserWithTenant(role: 'owner');
|
||||
$this->actingAs($user);
|
||||
|
||||
$tenant->makeCurrent();
|
||||
Filament::setTenant($tenant, true);
|
||||
|
||||
$run = OperationRun::factory()->create([
|
||||
'tenant_id' => (int) $tenant->getKey(),
|
||||
'workspace_id' => (int) $tenant->workspace_id,
|
||||
'user_id' => null,
|
||||
'initiator_name' => 'System',
|
||||
'type' => 'baseline_capture',
|
||||
'status' => 'queued',
|
||||
'outcome' => 'pending',
|
||||
'context' => [
|
||||
'baseline_capture' => [
|
||||
'current_baseline_changed' => false,
|
||||
],
|
||||
],
|
||||
]);
|
||||
|
||||
app(OperationRunService::class)->finalizeBlockedRun(
|
||||
run: $run,
|
||||
reasonCode: BaselineReasonCodes::CAPTURE_ZERO_SUBJECTS,
|
||||
message: 'Capture completed without governed subjects in scope.',
|
||||
);
|
||||
|
||||
expect($user->notifications()->count())->toBe(0);
|
||||
});
|
||||
|
||||
it('uses the system operation route for completed notifications delivered to platform users', function (): void {
|
||||
$platformUser = PlatformUser::factory()->create([
|
||||
'capabilities' => [
|
||||
|
||||
@ -11,12 +11,7 @@
|
||||
use App\Models\User;
|
||||
use App\Models\Workspace;
|
||||
use App\Models\WorkspaceMembership;
|
||||
use App\Services\OperationRunService;
|
||||
use App\Support\OperationRunLinks;
|
||||
use App\Support\Operations\ExecutionAuthorityMode;
|
||||
use App\Support\Operations\ExecutionDenialReasonCode;
|
||||
use App\Support\Operations\QueuedExecutionContext;
|
||||
use App\Support\Operations\QueuedExecutionLegitimacyDecision;
|
||||
use App\Support\Verification\VerificationReportSchema;
|
||||
use App\Support\Verification\VerificationReportWriter;
|
||||
use App\Support\Workspaces\WorkspaceContext;
|
||||
@ -345,111 +340,6 @@
|
||||
->not->toContain('data-shared-zone="diagnostics"');
|
||||
});
|
||||
|
||||
it('renders a queued legitimacy blocked verification report in the wizard instead of the empty state', function (): void {
|
||||
$workspace = Workspace::factory()->create();
|
||||
$user = User::factory()->create();
|
||||
|
||||
WorkspaceMembership::factory()->create([
|
||||
'workspace_id' => (int) $workspace->getKey(),
|
||||
'user_id' => (int) $user->getKey(),
|
||||
'role' => 'owner',
|
||||
]);
|
||||
|
||||
session()->put(WorkspaceContext::SESSION_KEY, (int) $workspace->getKey());
|
||||
|
||||
$entraTenantId = '20202020-2020-2020-2020-202020202020';
|
||||
|
||||
$tenant = Tenant::factory()->create([
|
||||
'workspace_id' => (int) $workspace->getKey(),
|
||||
'tenant_id' => $entraTenantId,
|
||||
'status' => Tenant::STATUS_ONBOARDING,
|
||||
]);
|
||||
|
||||
$user->tenants()->syncWithoutDetaching([
|
||||
$tenant->getKey() => ['role' => 'owner'],
|
||||
]);
|
||||
|
||||
$connection = ProviderConnection::factory()->platform()->consentGranted()->create([
|
||||
'workspace_id' => (int) $workspace->getKey(),
|
||||
'tenant_id' => (int) $tenant->getKey(),
|
||||
'provider' => 'microsoft',
|
||||
'entra_tenant_id' => $entraTenantId,
|
||||
'display_name' => 'Blocked queued verification connection',
|
||||
'is_default' => true,
|
||||
]);
|
||||
|
||||
$run = OperationRun::factory()->create([
|
||||
'workspace_id' => (int) $workspace->getKey(),
|
||||
'tenant_id' => (int) $tenant->getKey(),
|
||||
'user_id' => (int) $user->getKey(),
|
||||
'initiator_name' => $user->name,
|
||||
'type' => 'provider.connection.check',
|
||||
'status' => 'queued',
|
||||
'outcome' => 'pending',
|
||||
'context' => [
|
||||
'provider_connection_id' => (int) $connection->getKey(),
|
||||
'target_scope' => [
|
||||
'workspace_id' => (int) $workspace->getKey(),
|
||||
'tenant_id' => (int) $tenant->getKey(),
|
||||
'provider_connection_id' => (int) $connection->getKey(),
|
||||
'entra_tenant_id' => $entraTenantId,
|
||||
],
|
||||
],
|
||||
]);
|
||||
|
||||
$context = new QueuedExecutionContext(
|
||||
run: $run,
|
||||
operationType: 'provider.connection.check',
|
||||
workspaceId: (int) $workspace->getKey(),
|
||||
tenant: $tenant,
|
||||
initiator: $user,
|
||||
authorityMode: ExecutionAuthorityMode::ActorBound,
|
||||
requiredCapability: 'providers.view',
|
||||
providerConnectionId: (int) $connection->getKey(),
|
||||
targetScope: [
|
||||
'workspace_id' => (int) $workspace->getKey(),
|
||||
'tenant_id' => (int) $tenant->getKey(),
|
||||
'provider_connection_id' => (int) $connection->getKey(),
|
||||
'entra_tenant_id' => $entraTenantId,
|
||||
],
|
||||
);
|
||||
|
||||
$decision = QueuedExecutionLegitimacyDecision::deny(
|
||||
context: $context,
|
||||
checks: [
|
||||
'workspace_scope' => 'passed',
|
||||
'tenant_scope' => 'passed',
|
||||
'capability' => 'not_applicable',
|
||||
'tenant_operability' => 'failed',
|
||||
'execution_prerequisites' => 'not_applicable',
|
||||
],
|
||||
reasonCode: ExecutionDenialReasonCode::TenantNotOperable,
|
||||
);
|
||||
|
||||
app(OperationRunService::class)->finalizeExecutionLegitimacyBlockedRun($run, $decision);
|
||||
|
||||
TenantOnboardingSession::query()->create([
|
||||
'workspace_id' => (int) $workspace->getKey(),
|
||||
'tenant_id' => (int) $tenant->getKey(),
|
||||
'entra_tenant_id' => $entraTenantId,
|
||||
'current_step' => 'verify',
|
||||
'state' => [
|
||||
'provider_connection_id' => (int) $connection->getKey(),
|
||||
'verification_operation_run_id' => (int) $run->getKey(),
|
||||
],
|
||||
'started_by_user_id' => (int) $user->getKey(),
|
||||
'updated_by_user_id' => (int) $user->getKey(),
|
||||
]);
|
||||
|
||||
$this->actingAs($user)
|
||||
->followingRedirects()
|
||||
->get('/admin/onboarding')
|
||||
->assertSuccessful()
|
||||
->assertSee('Status: Blocked')
|
||||
->assertSee(ExecutionDenialReasonCode::TenantNotOperable->message())
|
||||
->assertDontSee('Verification report unavailable');
|
||||
});
|
||||
|
||||
it('keeps one onboarding verification path per state while leaving workflow actions on the wizard step', function (): void {
|
||||
$workspace = Workspace::factory()->create();
|
||||
$user = User::factory()->create();
|
||||
|
||||
@ -9,7 +9,6 @@
|
||||
use App\Support\Operations\ExecutionDenialReasonCode;
|
||||
use App\Support\Operations\QueuedExecutionContext;
|
||||
use App\Support\Operations\QueuedExecutionLegitimacyDecision;
|
||||
use App\Support\Verification\VerificationReportSchema;
|
||||
|
||||
it('writes a blocked terminal audit trail with execution denial context', function (): void {
|
||||
[$user, $tenant] = createUserWithTenant(role: 'owner');
|
||||
@ -65,8 +64,6 @@
|
||||
->latest('id')
|
||||
->first();
|
||||
|
||||
$report = data_get($run->context, 'verification_report');
|
||||
|
||||
expect($audit)->not->toBeNull()
|
||||
->and($audit?->action)->toBe('operation.blocked')
|
||||
->and($audit?->status)->toBe('blocked')
|
||||
@ -77,10 +74,5 @@
|
||||
->and(data_get($audit?->metadata, 'denial_class'))->toBe('initiator_invalid')
|
||||
->and(data_get($audit?->metadata, 'authority_mode'))->toBe('actor_bound')
|
||||
->and(data_get($audit?->metadata, 'acting_identity_type'))->toBe('user')
|
||||
->and($run->summary_counts)->toBe(['total' => 4])
|
||||
->and($report)->toBeArray()
|
||||
->and(VerificationReportSchema::isValidReport($report))->toBeTrue()
|
||||
->and(data_get($report, 'checks.0.key'))->toBe('provider.connection.check')
|
||||
->and(data_get($report, 'summary.overall'))->toBe('blocked')
|
||||
->and(data_get($report, 'checks.0.message'))->toBe(ExecutionDenialReasonCode::InitiatorNotEntitled->message());
|
||||
->and($run->summary_counts)->toBe(['total' => 4]);
|
||||
});
|
||||
|
||||
@ -76,7 +76,7 @@
|
||||
->assertDontSee('Inventory sync');
|
||||
})->group('ops-ux');
|
||||
|
||||
it('shows likely stale runs in the progress overlay and keeps polling when only stale runs remain', function () {
|
||||
it('does not show likely stale runs in the progress overlay and stops polling when only stale runs remain', function () {
|
||||
[$user, $tenant] = createUserWithTenant(role: 'owner');
|
||||
$this->actingAs($user);
|
||||
Filament::setTenant($tenant, true);
|
||||
@ -94,10 +94,8 @@
|
||||
Livewire::actingAs($user)
|
||||
->test(BulkOperationProgress::class)
|
||||
->call('refreshRuns')
|
||||
->assertSet('hasActiveRuns', true)
|
||||
->assertSee('Inventory sync')
|
||||
->assertSee('Likely stale')
|
||||
->assertSee('This operation is past its lifecycle window.');
|
||||
->assertSet('hasActiveRuns', false)
|
||||
->assertDontSee('Inventory sync');
|
||||
})->group('ops-ux');
|
||||
|
||||
it('registers Alpine cleanup for the Ops UX poller to avoid stale listeners across re-renders', function () {
|
||||
|
||||
@ -5,8 +5,6 @@
|
||||
use App\Models\OperationRun;
|
||||
use App\Models\Tenant;
|
||||
use App\Support\OperationRunLinks;
|
||||
use App\Support\OpsUx\OperationRunUrl;
|
||||
use App\Support\System\SystemOperationRunLinks;
|
||||
use Illuminate\Support\Facades\File;
|
||||
|
||||
it('routes all OperationRun view links through OperationRunLinks', function (): void {
|
||||
@ -84,38 +82,3 @@
|
||||
'problemClass' => OperationRun::PROBLEM_CLASS_TERMINAL_FOLLOW_UP,
|
||||
]));
|
||||
})->group('ops-ux');
|
||||
|
||||
it('preserves helper-owned operation type filters on canonical operations collection links', function (): void {
|
||||
$tenant = Tenant::factory()->create();
|
||||
|
||||
expect(OperationRunLinks::index($tenant, operationType: 'inventory_sync'))
|
||||
->toBe(route('admin.operations.index', [
|
||||
'tenant_id' => (int) $tenant->getKey(),
|
||||
'tableFilters' => [
|
||||
'type' => [
|
||||
'value' => 'inventory_sync',
|
||||
],
|
||||
],
|
||||
]));
|
||||
})->group('ops-ux');
|
||||
|
||||
it('keeps the thin operation URL delegate on the canonical admin helpers', function (): void {
|
||||
$tenant = Tenant::factory()->create();
|
||||
$run = OperationRun::factory()->for($tenant)->create();
|
||||
|
||||
expect(OperationRunUrl::view($run, $tenant))
|
||||
->toBe(OperationRunLinks::view($run, $tenant))
|
||||
->and(OperationRunUrl::index($tenant))
|
||||
->toBe(OperationRunLinks::index($tenant));
|
||||
})->group('ops-ux');
|
||||
|
||||
it('resolves system operation links through the canonical system helper family', function (): void {
|
||||
$run = OperationRun::factory()->create();
|
||||
|
||||
expect(SystemOperationRunLinks::index())
|
||||
->toBe(\App\Filament\System\Pages\Ops\Runs::getUrl(panel: 'system'))
|
||||
->and(SystemOperationRunLinks::view($run))
|
||||
->toBe(\App\Filament\System\Pages\Ops\ViewRun::getUrl(['run' => (int) $run->getKey()], panel: 'system'))
|
||||
->and(SystemOperationRunLinks::view((int) $run->getKey()))
|
||||
->toBe(\App\Filament\System\Pages\Ops\ViewRun::getUrl(['run' => (int) $run->getKey()], panel: 'system'));
|
||||
})->group('ops-ux');
|
||||
|
||||
@ -46,7 +46,7 @@
|
||||
expect($runs->pluck('user_id')->all())->toContain($otherUser->id);
|
||||
})->group('ops-ux');
|
||||
|
||||
it('keeps stale backup set update runs visible in the progress widget', function (string $operationType): void {
|
||||
it('suppresses stale backup set update runs from the progress widget', function (string $operationType): void {
|
||||
[$user, $tenant] = createUserWithTenant(role: 'owner');
|
||||
$this->actingAs($user);
|
||||
Filament::setTenant($tenant, true);
|
||||
@ -67,9 +67,8 @@
|
||||
->call('refreshRuns');
|
||||
|
||||
expect($component->get('runs'))->toBeInstanceOf(Collection::class)
|
||||
->and($component->get('runs'))->toHaveCount(1)
|
||||
->and($component->get('runs')->first()->freshnessState()->value)->toBe('likely_stale')
|
||||
->and($component->get('hasActiveRuns'))->toBeTrue();
|
||||
->and($component->get('runs'))->toHaveCount(0)
|
||||
->and($component->get('hasActiveRuns'))->toBeFalse();
|
||||
})->with([
|
||||
'backup set update' => 'backup_set.update',
|
||||
])->group('ops-ux');
|
||||
|
||||
@ -10,22 +10,10 @@
|
||||
$this->actingAs($user);
|
||||
Filament::setTenant($tenant, true);
|
||||
|
||||
OperationRun::factory()->count(4)->create([
|
||||
OperationRun::factory()->count(7)->create([
|
||||
'tenant_id' => $tenant->id,
|
||||
'workspace_id' => (int) $tenant->workspace_id,
|
||||
'type' => 'inventory_sync',
|
||||
'status' => 'queued',
|
||||
'outcome' => 'pending',
|
||||
'created_at' => now(),
|
||||
]);
|
||||
|
||||
OperationRun::factory()->count(3)->create([
|
||||
'tenant_id' => $tenant->id,
|
||||
'workspace_id' => (int) $tenant->workspace_id,
|
||||
'type' => 'inventory_sync',
|
||||
'status' => 'queued',
|
||||
'outcome' => 'pending',
|
||||
'created_at' => now()->subHour(),
|
||||
]);
|
||||
|
||||
$component = Livewire::actingAs($user)
|
||||
@ -34,6 +22,4 @@
|
||||
|
||||
expect($component->get('runs'))->toHaveCount(6);
|
||||
expect($component->get('overflowCount'))->toBe(2);
|
||||
expect($component->get('runs')->map(fn (OperationRun $run): string => $run->freshnessState()->value)->unique()->values()->all())
|
||||
->toEqualCanonicalizing(['fresh_active', 'likely_stale']);
|
||||
})->group('ops-ux');
|
||||
|
||||
@ -15,7 +15,6 @@
|
||||
use App\Services\ReviewPackService;
|
||||
use App\Support\Auth\UiTooltips;
|
||||
use App\Support\Evidence\EvidenceSnapshotStatus;
|
||||
use App\Support\OperationRunLinks;
|
||||
use App\Support\ReviewPackStatus;
|
||||
use Filament\Actions\ActionGroup;
|
||||
use Filament\Facades\Filament;
|
||||
@ -321,16 +320,12 @@ function seedReviewPackEvidence(Tenant $tenant): EvidenceSnapshot
|
||||
$tenant = Tenant::factory()->create();
|
||||
[$user, $tenant] = createUserWithTenant(tenant: $tenant, role: 'owner');
|
||||
$snapshot = seedReviewPackEvidence($tenant);
|
||||
$run = OperationRun::factory()->forTenant($tenant)->create([
|
||||
'type' => 'tenant.review_pack.generate',
|
||||
]);
|
||||
|
||||
$pack = ReviewPack::factory()->ready()->create([
|
||||
'tenant_id' => (int) $tenant->getKey(),
|
||||
'workspace_id' => (int) $tenant->workspace_id,
|
||||
'initiated_by_user_id' => (int) $user->getKey(),
|
||||
'evidence_snapshot_id' => (int) $snapshot->getKey(),
|
||||
'operation_run_id' => (int) $run->getKey(),
|
||||
'summary' => [
|
||||
'finding_count' => 5,
|
||||
'report_count' => 2,
|
||||
@ -357,7 +352,6 @@ function seedReviewPackEvidence(Tenant $tenant): EvidenceSnapshot
|
||||
->assertDontSee('Artifact truth')
|
||||
->assertSee('Publishable')
|
||||
->assertSee('#'.$snapshot->getKey())
|
||||
->assertSee(OperationRunLinks::view($run, $tenant), false)
|
||||
->assertSee('resolved');
|
||||
});
|
||||
|
||||
|
||||
@ -2,11 +2,9 @@
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Models\OperationRun;
|
||||
use App\Models\PlatformUser;
|
||||
use App\Models\User;
|
||||
use App\Support\Auth\PlatformCapabilities;
|
||||
use App\Support\System\SystemOperationRunLinks;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
|
||||
uses(RefreshDatabase::class);
|
||||
@ -37,46 +35,6 @@
|
||||
'/system/ops/runs',
|
||||
]);
|
||||
|
||||
it('returns 404 when a tenant session accesses a system operation detail route', function () {
|
||||
$user = User::factory()->create();
|
||||
$run = OperationRun::factory()->create();
|
||||
|
||||
$this->actingAs($user)
|
||||
->get(SystemOperationRunLinks::view($run))
|
||||
->assertNotFound();
|
||||
});
|
||||
|
||||
it('returns 403 when a platform user lacks operations capability on system operation detail', function () {
|
||||
$platformUser = PlatformUser::factory()->create([
|
||||
'capabilities' => [
|
||||
PlatformCapabilities::ACCESS_SYSTEM_PANEL,
|
||||
],
|
||||
'is_active' => true,
|
||||
]);
|
||||
|
||||
$run = OperationRun::factory()->create();
|
||||
|
||||
$this->actingAs($platformUser, 'platform')
|
||||
->get(SystemOperationRunLinks::view($run))
|
||||
->assertForbidden();
|
||||
});
|
||||
|
||||
it('returns 200 on system operation detail when a platform user has operations capability', function () {
|
||||
$platformUser = PlatformUser::factory()->create([
|
||||
'capabilities' => [
|
||||
PlatformCapabilities::ACCESS_SYSTEM_PANEL,
|
||||
PlatformCapabilities::OPERATIONS_VIEW,
|
||||
],
|
||||
'is_active' => true,
|
||||
]);
|
||||
|
||||
$run = OperationRun::factory()->create();
|
||||
|
||||
$this->actingAs($platformUser, 'platform')
|
||||
->get(SystemOperationRunLinks::view($run))
|
||||
->assertSuccessful();
|
||||
});
|
||||
|
||||
it('returns 200 when a platform user has the required capability', function () {
|
||||
$platformUser = PlatformUser::factory()->create([
|
||||
'capabilities' => [
|
||||
|
||||
@ -48,14 +48,3 @@
|
||||
expect(BadgeCatalog::mapper(BadgeDomain::BooleanEnabled))->not->toBeNull()
|
||||
->and($domainValues)->not->toContain('provider_connection.status', 'provider_connection.health');
|
||||
});
|
||||
|
||||
it('keeps retired tenant app-status out of the central catalog while active tenant domains remain registered', function (): void {
|
||||
$domainValues = collect(BadgeDomain::cases())
|
||||
->map(fn (BadgeDomain $domain): string => $domain->value)
|
||||
->all();
|
||||
|
||||
expect($domainValues)->not->toContain('tenant_app_status')
|
||||
->and(BadgeCatalog::mapper(BadgeDomain::TenantStatus))->not->toBeNull()
|
||||
->and(BadgeCatalog::mapper(BadgeDomain::TenantRbacStatus))->not->toBeNull()
|
||||
->and(BadgeCatalog::mapper(BadgeDomain::TenantPermissionStatus))->not->toBeNull();
|
||||
});
|
||||
|
||||
@ -23,15 +23,18 @@
|
||||
expect($error->color)->toBe('danger');
|
||||
});
|
||||
|
||||
it('does not expose retired app status as active tenant badge semantics', function (): void {
|
||||
$domainValues = collect(BadgeDomain::cases())
|
||||
->map(fn (BadgeDomain $domain): string => $domain->value)
|
||||
->all();
|
||||
it('maps tenant app status values to legacy diagnostic badge semantics', function (): void {
|
||||
$ok = BadgeCatalog::spec(BadgeDomain::TenantAppStatus, 'ok');
|
||||
expect($ok->label)->toBe('OK');
|
||||
expect($ok->color)->toBe('success');
|
||||
|
||||
expect($domainValues)->not->toContain('tenant_app_status')
|
||||
->and(BadgeCatalog::mapper(BadgeDomain::TenantStatus))->not->toBeNull()
|
||||
->and(BadgeCatalog::mapper(BadgeDomain::TenantRbacStatus))->not->toBeNull()
|
||||
->and(BadgeCatalog::mapper(BadgeDomain::TenantPermissionStatus))->not->toBeNull();
|
||||
$consentRequired = BadgeCatalog::spec(BadgeDomain::TenantAppStatus, 'consent_required');
|
||||
expect($consentRequired->label)->toBe('Consent required');
|
||||
expect($consentRequired->color)->toBe('warning');
|
||||
|
||||
$error = BadgeCatalog::spec(BadgeDomain::TenantAppStatus, 'error');
|
||||
expect($error->label)->toBe('Error');
|
||||
expect($error->color)->toBe('danger');
|
||||
});
|
||||
|
||||
it('maps tenant RBAC status values to canonical badge semantics', function (): void {
|
||||
|
||||
@ -5,13 +5,11 @@
|
||||
|
||||
beforeEach(function (): void {
|
||||
$this->originalPublicPath = public_path();
|
||||
$this->originalEnvironment = app()->environment();
|
||||
$this->temporaryPublicPath = null;
|
||||
});
|
||||
|
||||
afterEach(function (): void {
|
||||
app()->usePublicPath($this->originalPublicPath);
|
||||
app()->instance('env', $this->originalEnvironment);
|
||||
|
||||
if (is_string($this->temporaryPublicPath) && File::isDirectory($this->temporaryPublicPath)) {
|
||||
File::deleteDirectory($this->temporaryPublicPath);
|
||||
@ -71,27 +69,6 @@ function useTemporaryPublicPath(): string
|
||||
->not->toContain(':5173');
|
||||
});
|
||||
|
||||
it('falls back to the built manifest asset when the Vite hot server is unreachable', function (): void {
|
||||
$publicPath = useTemporaryPublicPath();
|
||||
|
||||
app()->instance('env', 'local');
|
||||
|
||||
File::ensureDirectoryExists($publicPath.'/build');
|
||||
File::put($publicPath.'/hot', 'http://127.0.0.1:1');
|
||||
File::put(
|
||||
$publicPath.'/build/manifest.json',
|
||||
json_encode([
|
||||
'resources/css/filament/admin/theme.css' => [
|
||||
'file' => 'assets/theme-test.css',
|
||||
],
|
||||
], JSON_THROW_ON_ERROR),
|
||||
);
|
||||
|
||||
expect(PanelThemeAsset::resolve('resources/css/filament/admin/theme.css'))
|
||||
->toEndWith('/build/assets/theme-test.css')
|
||||
->not->toContain(':1');
|
||||
});
|
||||
|
||||
it('returns null when the build manifest contains invalid json', function (): void {
|
||||
$publicPath = useTemporaryPublicPath();
|
||||
|
||||
|
||||
@ -3,8 +3,6 @@
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Models\OperationRun;
|
||||
use App\Models\ProviderConnection;
|
||||
use App\Support\Auth\Capabilities;
|
||||
use App\Services\Operations\QueuedExecutionLegitimacyGate;
|
||||
use App\Support\OperationRunOutcome;
|
||||
use App\Support\OperationRunStatus;
|
||||
@ -55,95 +53,6 @@
|
||||
]);
|
||||
});
|
||||
|
||||
it('allows onboarding verification runs for onboarding tenants when they originate from the onboarding wizard', function (): void {
|
||||
[$user, $tenant] = createUserWithTenant(role: 'owner');
|
||||
|
||||
$tenant->forceFill([
|
||||
'status' => 'onboarding',
|
||||
])->save();
|
||||
|
||||
$connection = ProviderConnection::factory()->create([
|
||||
'tenant_id' => (int) $tenant->getKey(),
|
||||
'workspace_id' => (int) $tenant->workspace_id,
|
||||
'provider' => 'microsoft',
|
||||
'entra_tenant_id' => (string) $tenant->tenant_id,
|
||||
]);
|
||||
|
||||
$run = OperationRun::factory()->create([
|
||||
'tenant_id' => (int) $tenant->getKey(),
|
||||
'workspace_id' => (int) $tenant->workspace_id,
|
||||
'user_id' => (int) $user->getKey(),
|
||||
'type' => 'provider.connection.check',
|
||||
'status' => OperationRunStatus::Queued->value,
|
||||
'outcome' => OperationRunOutcome::Pending->value,
|
||||
'context' => [
|
||||
'execution_authority_mode' => ExecutionAuthorityMode::ActorBound->value,
|
||||
'provider_connection_id' => (int) $connection->getKey(),
|
||||
'wizard' => [
|
||||
'flow' => 'managed_tenant_onboarding',
|
||||
'step' => 'verification',
|
||||
],
|
||||
],
|
||||
]);
|
||||
|
||||
$decision = app(QueuedExecutionLegitimacyGate::class)->evaluate($run);
|
||||
|
||||
expect($decision->allowed)->toBeTrue()
|
||||
->and($decision->reasonCode)->toBeNull()
|
||||
->and($decision->checks)->toMatchArray([
|
||||
'workspace_scope' => 'passed',
|
||||
'tenant_scope' => 'passed',
|
||||
'capability' => 'passed',
|
||||
'tenant_operability' => 'passed',
|
||||
'execution_prerequisites' => 'passed',
|
||||
]);
|
||||
});
|
||||
|
||||
it('allows workspace-scoped onboarding bootstrap capabilities during queued reauthorization', function (): void {
|
||||
[$user, $tenant] = createUserWithTenant(role: 'owner');
|
||||
|
||||
$tenant->forceFill([
|
||||
'status' => 'onboarding',
|
||||
])->save();
|
||||
|
||||
$connection = ProviderConnection::factory()->create([
|
||||
'tenant_id' => (int) $tenant->getKey(),
|
||||
'workspace_id' => (int) $tenant->workspace_id,
|
||||
'provider' => 'microsoft',
|
||||
'entra_tenant_id' => (string) $tenant->tenant_id,
|
||||
]);
|
||||
|
||||
$run = OperationRun::factory()->create([
|
||||
'tenant_id' => (int) $tenant->getKey(),
|
||||
'workspace_id' => (int) $tenant->workspace_id,
|
||||
'user_id' => (int) $user->getKey(),
|
||||
'type' => 'inventory_sync',
|
||||
'status' => OperationRunStatus::Queued->value,
|
||||
'outcome' => OperationRunOutcome::Pending->value,
|
||||
'context' => [
|
||||
'execution_authority_mode' => ExecutionAuthorityMode::ActorBound->value,
|
||||
'provider_connection_id' => (int) $connection->getKey(),
|
||||
'required_capability' => Capabilities::WORKSPACE_MANAGED_TENANT_ONBOARD_BOOTSTRAP_INVENTORY_SYNC,
|
||||
'wizard' => [
|
||||
'flow' => 'managed_tenant_onboarding',
|
||||
'step' => 'bootstrap',
|
||||
],
|
||||
],
|
||||
]);
|
||||
|
||||
$decision = app(QueuedExecutionLegitimacyGate::class)->evaluate($run);
|
||||
|
||||
expect($decision->allowed)->toBeTrue()
|
||||
->and($decision->reasonCode)->toBeNull()
|
||||
->and($decision->checks)->toMatchArray([
|
||||
'workspace_scope' => 'passed',
|
||||
'tenant_scope' => 'passed',
|
||||
'capability' => 'passed',
|
||||
'tenant_operability' => 'passed',
|
||||
'execution_prerequisites' => 'passed',
|
||||
]);
|
||||
});
|
||||
|
||||
it('denies actor-bound execution when the initiator loses capability', function (): void {
|
||||
[$user, $tenant] = createUserWithTenant(role: 'readonly');
|
||||
|
||||
|
||||
@ -3,7 +3,7 @@ # Product Roadmap
|
||||
> Strategic thematic blocks and release trajectory.
|
||||
> This is the "big picture" — not individual specs.
|
||||
|
||||
**Last updated**: 2026-04-24
|
||||
**Last updated**: 2026-04-22
|
||||
|
||||
---
|
||||
|
||||
@ -13,7 +13,7 @@ ## Release History
|
||||
|---------|-------|--------|
|
||||
| **R1 "Golden Master Governance"** | Baseline drift as production feature, operations polish | **Done** |
|
||||
| **R1 cont.** | Ops canonicalization, action surface contract, ops-ux enforcement | **Done** |
|
||||
| **R2 "Tenant Reviews, Evidence & Control Foundation"** | Evidence packs, stored reports, canonical control catalog, permission posture, alerts | **Partial** |
|
||||
| **R2 "Tenant Reviews & Evidence"** | Evidence packs, stored reports, permission posture, alerts | **Partial** |
|
||||
| **R2 cont.** | Alert escalation + notification routing | **Done** |
|
||||
|
||||
---
|
||||
@ -21,11 +21,11 @@ ## Release History
|
||||
## Active / Near-term
|
||||
|
||||
### Governance & Architecture Hardening
|
||||
Canonical run-view trust semantics, execution-time authorization continuity, tenant-owned query canon, findings workflow enforcement, Livewire trust-boundary reduction, operation-type canonicalization, provider-boundary hardening, target-scope neutrality, and governed-subject vocabulary enforcement.
|
||||
Goal: Turn the new audit constitution into enforceable backend and workflow guardrails before further governance surface area lands, while preventing the Governance-of-Record platform core from drifting into provider-specific or operation-type dual semantics.
|
||||
Canonical run-view trust semantics, execution-time authorization continuity, tenant-owned query canon, findings workflow enforcement, Livewire trust-boundary reduction.
|
||||
Goal: Turn the new audit constitution into enforceable backend and workflow guardrails before further governance surface area lands.
|
||||
|
||||
**Active specs**: 144
|
||||
**Specced follow-through (draft)**: 149 (queued execution reauthorization), 150 (tenant-owned query canon), 151 (findings workflow backstop), 152 (Livewire context locking), 214 (governance outcome compression), 216 (provider dispatch gate). Next foundation candidates: Canonical Operation Type Source of Truth, Provider Boundary Hardening, Provider Identity & Target Scope Neutrality, Platform Vocabulary Boundary Enforcement for Governed Subject Keys.
|
||||
**Specced follow-through (draft)**: 149 (queued execution reauthorization), 150 (tenant-owned query canon), 151 (findings workflow backstop), 152 (Livewire context locking), 214 (governance outcome compression), 216 (provider dispatch gate)
|
||||
**Operator truth initiative** (sequenced): Operator Outcome Taxonomy (Spec 156) → Reason Code Translation (Spec 157) → Artifact Truth Semantics (Spec 158) → Governance Operator Outcome Compression (Spec 214, draft). Humanized Diagnostic Summaries for Governance Operations is now Spec 220 (draft) as the run-detail adoption slice, while Provider Dispatch Gate Unification is now Spec 216 (draft) as the adjacent hardening lane.
|
||||
**Source**: architecture audit 2026-03-15, audit constitution, semantic clarity audit 2026-03-21, product spec-candidates
|
||||
|
||||
@ -79,25 +79,13 @@ ### R2.0 Canonical Control Catalog Foundation
|
||||
- Microsoft subject and workload bindings for tenant-near technical controls
|
||||
- Small seed catalog for v1 families such as strong authentication, conditional access, privileged access, endpoint baseline or hardening, sharing boundaries, mail protection, audit retention, and delegated admin boundaries
|
||||
- Referenceable from Baseline Profiles, Compare and Drift, Findings, Exceptions, StoredReports, and EvidenceItems
|
||||
- Foundation for later framework mappings, readiness views, customer review workspaces, and auditor packs
|
||||
- Explicitly not a late compliance feature: this is the semantic platform layer for tenant reviews, evidence packs, findings, exceptions, stored reports, and future readiness views
|
||||
|
||||
### R1.x Foundation Hardening — Governance Platform Anti-Drift
|
||||
Stabilize the Governance-of-Record platform semantics before additional Microsoft domains, compliance overlays, or multi-cloud execution expand the surface area.
|
||||
**Goal**: Keep Golden Master Governance from becoming provider-specific feature growth by hardening the platform seams underneath OperationRuns, ProviderConnections, governed subjects, and shared vocabulary.
|
||||
|
||||
- Canonical Operation Type Source of Truth for persistence, dispatch, UI labels, audit, alerts, notifications, and reporting
|
||||
- Provider Boundary Hardening so provider-specific behavior stays inside provider adapters and registries
|
||||
- Provider Identity & Target Scope Neutrality so Entra-specific identifiers do not become generic platform truth
|
||||
- Platform Vocabulary Boundary Enforcement for Governed Subject Keys so `policy_type` and similar provider/domain terms do not leak into the platform core
|
||||
- No AWS/GCP/SaaS connector implementation in this slice; this is anti-drift foundation work only
|
||||
- Foundation for later framework mappings, readiness views, and auditor packs
|
||||
|
||||
### R2 Completion — Evidence & Exception Workflows
|
||||
- Review pack export (Spec 109 — done)
|
||||
- Exception/risk-acceptance workflow for Findings → Spec 154 (draft)
|
||||
- Formal evidence/review-pack entity foundation → Spec 153 (evidence snapshots, draft) + Spec 155 (tenant review layer / review packs, draft)
|
||||
- Workspace-level PII override for review packs → deferred from 109
|
||||
- Customer Review Workspace / Read-only View v1 → sharpen customer-facing review consumption: baseline status, latest reviews, findings, accepted risks, evidence/review-pack downloads, customer-safe redaction, and no admin/remediation actions
|
||||
|
||||
### Findings Workflow v2 / Execution Layer
|
||||
Turn findings from a reviewable register into an accountable operating flow with clear ownership, personal queues, intake, hygiene, and minimal escalation.
|
||||
@ -156,10 +144,10 @@ ### PSA / Ticketing Handoff
|
||||
**Scope direction**: start with one-way handoff and internal visibility, not full bidirectional sync or full ITSM modeling.
|
||||
|
||||
### Compliance Readiness & Executive Review Packs
|
||||
On-demand review packs that combine governance findings, accepted risks, evidence, baseline/drift posture, canonical control coverage, and key security signals into one coherent deliverable. CIS-aligned baseline libraries plus NIS2-/BSI-oriented readiness views depend on the Canonical Control Catalog and Evidence-to-Control mapping and remain explicitly without certification claims. Executive / CISO / customer-facing report surfaces alongside operator-facing detail views. Exportable auditor-ready and management-ready outputs.
|
||||
On-demand review packs that combine governance findings, accepted risks, evidence, baseline/drift posture, and key security signals into one coherent deliverable. CIS-aligned baseline libraries plus NIS2-/BSI-oriented readiness views (without certification claims). Executive / CISO / customer-facing report surfaces alongside operator-facing detail views. Exportable auditor-ready and management-ready outputs.
|
||||
**Goal**: Make TenantPilot sellable as an MSP-facing governance and review platform for German midmarket and compliance-oriented customers who want structured tenant reviews and management-ready outputs on demand.
|
||||
**Why it matters**: Turns existing governance data into a clear customer-facing value proposition. Strengthens MSP sales story beyond backup and restore. Creates a repeatable "review on demand" workflow for quarterly reviews, security health checks, and audit preparation.
|
||||
**Depends on**: Canonical Control Catalog Foundation, Evidence-to-Control mapping, StoredReports / EvidenceItems foundation, Tenant Review runs, Customer Review Workspace / Read-only View, Findings + Risk Acceptance workflow, evidence / signal ingestion, export pipeline maturity.
|
||||
**Depends on**: Canonical Control Catalog Foundation, StoredReports / EvidenceItems foundation, Tenant Review runs, Findings + Risk Acceptance workflow, evidence / signal ingestion, export pipeline maturity.
|
||||
**Scope direction**: Start as compliance readiness and review packaging. Avoid formal certification language or promises. Position as governance evidence, management reporting, and audit preparation.
|
||||
**Modeling principle**: Compliance and governance requirements are modeled through a framework-neutral canonical control catalog plus technical interpretations and versioned framework overlays, not as separate technical object worlds per framework. Readiness views, evidence packs, baseline libraries, and auditor outputs are generated from that shared domain model.
|
||||
|
||||
@ -226,7 +214,7 @@ ## Infrastructure & Platform Debt
|
||||
| Item | Risk | Status |
|
||||
|------|------|--------|
|
||||
| No `.env.example` in repo | Onboarding friction | Open |
|
||||
| CI pipeline config status drift | Roadmap debt list may be stale because workflow files exist and should be re-audited | Review needed |
|
||||
| No CI pipeline config | No automated quality gate | Open |
|
||||
| No PHPStan/Larastan | No static analysis | Open |
|
||||
| SQLite for tests vs PostgreSQL in prod | Schema drift risk | Open |
|
||||
| No formal release process | Manual deploys | Open |
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@ -1,36 +0,0 @@
|
||||
# Specification Quality Checklist: Operation Run Link Contract Enforcement
|
||||
|
||||
**Purpose**: Validate specification completeness and quality before proceeding to planning
|
||||
**Created**: 2026-04-23
|
||||
**Feature**: [spec.md](/Users/ahmeddarrazi/Documents/projects/wt-plattform/specs/232-operation-run-link-contract/spec.md)
|
||||
|
||||
## Content Quality
|
||||
|
||||
- [x] No implementation details (languages, frameworks, APIs)
|
||||
- [x] Focused on user value and business needs
|
||||
- [x] Written for non-technical stakeholders
|
||||
- [x] All mandatory sections completed
|
||||
|
||||
## Requirement Completeness
|
||||
|
||||
- [x] No [NEEDS CLARIFICATION] markers remain
|
||||
- [x] Requirements are testable and unambiguous
|
||||
- [x] Success criteria are measurable
|
||||
- [x] Success criteria are technology-agnostic (no implementation details)
|
||||
- [x] All acceptance scenarios are defined
|
||||
- [x] Edge cases are identified
|
||||
- [x] Scope is clearly bounded
|
||||
- [x] Dependencies and assumptions identified
|
||||
|
||||
## Feature Readiness
|
||||
|
||||
- [x] All functional requirements have clear acceptance criteria
|
||||
- [x] User scenarios cover primary flows
|
||||
- [x] Feature meets measurable outcomes defined in Success Criteria
|
||||
- [x] No implementation details leak into specification
|
||||
|
||||
## Notes
|
||||
|
||||
- Validation pass 1 completed on 2026-04-23.
|
||||
- The spec stays intentionally narrow: existing helper families remain the contract, and the feature only standardizes adoption plus a bounded allowlist guard.
|
||||
- A few requirement lines necessarily name existing shared contract classes and canonical routes because the subject of the spec is contract enforcement on those existing platform surfaces. The spec avoids prescribing implementation structure beyond reuse of the already-shipped canonical paths.
|
||||
@ -1,380 +0,0 @@
|
||||
openapi: 3.1.0
|
||||
info:
|
||||
title: Operation Run Link Contract Enforcement
|
||||
version: 1.0.0
|
||||
summary: Logical internal contract for Spec 232 canonical admin and system operation-run links plus bounded guard enforcement.
|
||||
description: |
|
||||
This contract documents the internal helper-owned URL semantics that Spec 232 enforces.
|
||||
It is intentionally logical rather than a public HTTP API because the feature reuses
|
||||
existing Filament pages, helper families, and route ownership instead of introducing
|
||||
a new controller namespace.
|
||||
servers:
|
||||
- url: https://logical.internal
|
||||
description: Non-routable placeholder used to describe internal repository contracts.
|
||||
paths:
|
||||
/internal/operation-run-links/admin/collection:
|
||||
post:
|
||||
summary: Build the canonical admin operations collection URL for a covered source surface.
|
||||
operationId: buildAdminOperationCollectionLink
|
||||
x-not-public-http: true
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/vnd.tenantpilot.admin-operation-collection-input+json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/AdminOperationCollectionLinkInput'
|
||||
responses:
|
||||
'200':
|
||||
description: Canonical admin collection link emitted through `OperationRunLinks::index(...)`.
|
||||
content:
|
||||
application/vnd.tenantpilot.operation-link+json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/CanonicalOperationLink'
|
||||
/internal/operation-run-links/admin/detail:
|
||||
post:
|
||||
summary: Build the canonical admin operation detail URL for a covered source surface.
|
||||
operationId: buildAdminOperationDetailLink
|
||||
x-not-public-http: true
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/vnd.tenantpilot.admin-operation-detail-input+json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/AdminOperationDetailLinkInput'
|
||||
responses:
|
||||
'200':
|
||||
description: Canonical admin detail link emitted through `OperationRunLinks::view(...)` or `tenantlessView(...)`.
|
||||
content:
|
||||
application/vnd.tenantpilot.operation-link+json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/CanonicalOperationLink'
|
||||
/internal/operation-run-links/system/collection:
|
||||
post:
|
||||
summary: Build the canonical system operations collection URL for a covered system source surface.
|
||||
operationId: buildSystemOperationCollectionLink
|
||||
x-not-public-http: true
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/vnd.tenantpilot.system-operation-collection-input+json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/SystemOperationCollectionLinkInput'
|
||||
responses:
|
||||
'200':
|
||||
description: Canonical system collection link emitted through `SystemOperationRunLinks::index()`.
|
||||
content:
|
||||
application/vnd.tenantpilot.operation-link+json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/CanonicalOperationLink'
|
||||
/internal/operation-run-links/system/detail:
|
||||
post:
|
||||
summary: Build the canonical system operation detail URL for a covered system source surface.
|
||||
operationId: buildSystemOperationDetailLink
|
||||
x-not-public-http: true
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/vnd.tenantpilot.system-operation-detail-input+json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/SystemOperationDetailLinkInput'
|
||||
responses:
|
||||
'200':
|
||||
description: Canonical system detail link emitted through `SystemOperationRunLinks::view(...)`.
|
||||
content:
|
||||
application/vnd.tenantpilot.operation-link+json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/CanonicalOperationLink'
|
||||
/internal/guards/operation-run-link-contract/check:
|
||||
post:
|
||||
summary: Scan the bounded app-side source surface for raw operation-run link bypasses.
|
||||
operationId: checkOperationRunLinkContract
|
||||
x-not-public-http: true
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/vnd.tenantpilot.operation-run-link-guard-input+json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/OperationRunLinkGuardCheck'
|
||||
responses:
|
||||
'200':
|
||||
description: Guard completed and found no violations inside the declared boundary.
|
||||
content:
|
||||
application/vnd.tenantpilot.operation-run-link-guard-report+json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/OperationRunLinkGuardReport'
|
||||
'422':
|
||||
description: Guard found one or more raw bypasses outside the allowlist.
|
||||
content:
|
||||
application/vnd.tenantpilot.operation-run-link-guard-report+json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/OperationRunLinkGuardReport'
|
||||
/admin/operations:
|
||||
get:
|
||||
summary: Existing canonical admin operations collection route.
|
||||
operationId: openAdminOperationsCollection
|
||||
responses:
|
||||
'200':
|
||||
description: Admin monitoring collection renders for an entitled workspace operator.
|
||||
content:
|
||||
text/html:
|
||||
schema:
|
||||
type: string
|
||||
'403':
|
||||
description: Actor is in scope but lacks the required capability.
|
||||
'404':
|
||||
description: Actor is not entitled to the workspace or tenant-bound records referenced by the current context.
|
||||
/admin/operations/{run}:
|
||||
get:
|
||||
summary: Existing canonical admin operation detail route.
|
||||
operationId: openAdminOperationDetail
|
||||
parameters:
|
||||
- name: run
|
||||
in: path
|
||||
required: true
|
||||
schema:
|
||||
type: integer
|
||||
responses:
|
||||
'200':
|
||||
description: Canonical admin run detail renders for an entitled operator.
|
||||
content:
|
||||
text/html:
|
||||
schema:
|
||||
type: string
|
||||
'403':
|
||||
description: Actor is a member in scope but lacks current capability.
|
||||
'404':
|
||||
description: Actor cannot access the run because of workspace, tenant, or plane isolation.
|
||||
/system/ops/runs:
|
||||
get:
|
||||
summary: Existing canonical system operations collection route.
|
||||
operationId: openSystemOperationsCollection
|
||||
responses:
|
||||
'200':
|
||||
description: System monitoring collection renders for an entitled platform user.
|
||||
content:
|
||||
text/html:
|
||||
schema:
|
||||
type: string
|
||||
'403':
|
||||
description: Platform user lacks the required system operations capability.
|
||||
'404':
|
||||
description: Actor is not entitled to the system plane.
|
||||
/system/ops/runs/{run}:
|
||||
get:
|
||||
summary: Existing canonical system operation detail route.
|
||||
operationId: openSystemOperationDetail
|
||||
parameters:
|
||||
- name: run
|
||||
in: path
|
||||
required: true
|
||||
schema:
|
||||
type: integer
|
||||
responses:
|
||||
'200':
|
||||
description: Canonical system run detail renders for an entitled platform user.
|
||||
content:
|
||||
text/html:
|
||||
schema:
|
||||
type: string
|
||||
'403':
|
||||
description: Platform user lacks the required system operations capability.
|
||||
'404':
|
||||
description: Actor is not entitled to the system plane or the run is not visible there.
|
||||
components:
|
||||
schemas:
|
||||
OperationPlane:
|
||||
type: string
|
||||
enum:
|
||||
- admin
|
||||
- system
|
||||
OperationLinkKind:
|
||||
type: string
|
||||
enum:
|
||||
- collection
|
||||
- detail
|
||||
CoveredSurfaceKey:
|
||||
type: string
|
||||
description: Stable identifier for the source surface that emits the canonical link.
|
||||
CanonicalNavigationContextInput:
|
||||
type: object
|
||||
description: |
|
||||
Opaque helper-owned navigation context payload passed through the admin helper family
|
||||
when a source surface needs canonical back-link or query continuity.
|
||||
additionalProperties: true
|
||||
AdminOperationCollectionLinkInput:
|
||||
type: object
|
||||
required:
|
||||
- surfaceKey
|
||||
properties:
|
||||
surfaceKey:
|
||||
$ref: '#/components/schemas/CoveredSurfaceKey'
|
||||
tenantId:
|
||||
type: integer
|
||||
tenantExternalId:
|
||||
type: string
|
||||
navigationContext:
|
||||
$ref: '#/components/schemas/CanonicalNavigationContextInput'
|
||||
activeTab:
|
||||
type: string
|
||||
problemClass:
|
||||
type: string
|
||||
operationType:
|
||||
type: string
|
||||
description: Optional helper-owned operations table type filter, used by inventory coverage history links.
|
||||
allTenants:
|
||||
type: boolean
|
||||
default: false
|
||||
description: Canonical input for `OperationRunLinks::index(...)`.
|
||||
AdminOperationDetailLinkInput:
|
||||
type: object
|
||||
required:
|
||||
- surfaceKey
|
||||
- runId
|
||||
properties:
|
||||
surfaceKey:
|
||||
$ref: '#/components/schemas/CoveredSurfaceKey'
|
||||
runId:
|
||||
type: integer
|
||||
navigationContext:
|
||||
$ref: '#/components/schemas/CanonicalNavigationContextInput'
|
||||
description: Canonical input for `OperationRunLinks::view(...)` or `tenantlessView(...)`.
|
||||
SystemOperationCollectionLinkInput:
|
||||
type: object
|
||||
required:
|
||||
- surfaceKey
|
||||
properties:
|
||||
surfaceKey:
|
||||
$ref: '#/components/schemas/CoveredSurfaceKey'
|
||||
description: Canonical input for `SystemOperationRunLinks::index()`.
|
||||
SystemOperationDetailLinkInput:
|
||||
type: object
|
||||
required:
|
||||
- surfaceKey
|
||||
- runId
|
||||
properties:
|
||||
surfaceKey:
|
||||
$ref: '#/components/schemas/CoveredSurfaceKey'
|
||||
runId:
|
||||
type: integer
|
||||
description: Canonical input for `SystemOperationRunLinks::view(...)`.
|
||||
CanonicalOperationLink:
|
||||
type: object
|
||||
required:
|
||||
- label
|
||||
- url
|
||||
- plane
|
||||
- kind
|
||||
- canonicalNoun
|
||||
properties:
|
||||
label:
|
||||
type: string
|
||||
url:
|
||||
type: string
|
||||
plane:
|
||||
$ref: '#/components/schemas/OperationPlane'
|
||||
kind:
|
||||
$ref: '#/components/schemas/OperationLinkKind'
|
||||
canonicalNoun:
|
||||
type: string
|
||||
example: Operation
|
||||
preservedQueryKeys:
|
||||
type: array
|
||||
items:
|
||||
type: string
|
||||
description: Helper-owned operator-facing link output for canonical operations destinations.
|
||||
OperationRunLinkGuardCheck:
|
||||
type: object
|
||||
required:
|
||||
- includePaths
|
||||
- allowlistedPaths
|
||||
- forbiddenPatterns
|
||||
properties:
|
||||
includePaths:
|
||||
type: array
|
||||
items:
|
||||
type: string
|
||||
examples:
|
||||
- - app/Filament/Widgets/Tenant/RecentOperationsSummary.php
|
||||
- app/Filament/Pages/InventoryCoverage.php
|
||||
- app/Filament/Resources/InventoryItemResource.php
|
||||
- app/Filament/Resources/ReviewPackResource.php
|
||||
- app/Filament/Pages/Operations/TenantlessOperationRunViewer.php
|
||||
- app/Support/Navigation/RelatedNavigationResolver.php
|
||||
- app/Filament/System/Pages/Directory/ViewTenant.php
|
||||
- app/Filament/System/Pages/Directory/ViewWorkspace.php
|
||||
- app/Filament/System/Pages/Ops/Runs.php
|
||||
- app/Filament/System/Pages/Ops/ViewRun.php
|
||||
- app/Providers/Filament/AdminPanelProvider.php
|
||||
- app/Providers/Filament/TenantPanelProvider.php
|
||||
- app/Support/Middleware/EnsureFilamentTenantSelected.php
|
||||
- app/Http/Controllers/ClearTenantContextController.php
|
||||
- app/Support/OpsUx/OperationRunUrl.php
|
||||
allowlistedPaths:
|
||||
type: array
|
||||
items:
|
||||
type: string
|
||||
examples:
|
||||
- - app/Providers/Filament/AdminPanelProvider.php
|
||||
- app/Providers/Filament/TenantPanelProvider.php
|
||||
- app/Support/Middleware/EnsureFilamentTenantSelected.php
|
||||
- app/Http/Controllers/ClearTenantContextController.php
|
||||
forbiddenPatterns:
|
||||
type: array
|
||||
items:
|
||||
type: string
|
||||
examples:
|
||||
- - "route('admin.operations.index'"
|
||||
- "route('admin.operations.view'"
|
||||
- "/system/ops/runs"
|
||||
- "Runs::getUrl("
|
||||
- "ViewRun::getUrl("
|
||||
acceptedDelegates:
|
||||
type: array
|
||||
items:
|
||||
type: string
|
||||
examples:
|
||||
- - app/Support/OpsUx/OperationRunUrl.php
|
||||
description: Declares the bounded source surface and explicit exceptions for the guard.
|
||||
OperationRunLinkGuardViolation:
|
||||
type: object
|
||||
required:
|
||||
- filePath
|
||||
- line
|
||||
- snippet
|
||||
- reason
|
||||
properties:
|
||||
filePath:
|
||||
type: string
|
||||
line:
|
||||
type: integer
|
||||
snippet:
|
||||
type: string
|
||||
expectedHelper:
|
||||
type: string
|
||||
reason:
|
||||
type: string
|
||||
description: Actionable failure output for one raw bypass.
|
||||
OperationRunLinkGuardReport:
|
||||
type: object
|
||||
required:
|
||||
- scannedPaths
|
||||
- allowlistedPaths
|
||||
- violations
|
||||
properties:
|
||||
scannedPaths:
|
||||
type: array
|
||||
items:
|
||||
type: string
|
||||
allowlistedPaths:
|
||||
type: array
|
||||
items:
|
||||
type: string
|
||||
acceptedDelegates:
|
||||
type: array
|
||||
items:
|
||||
type: string
|
||||
violations:
|
||||
type: array
|
||||
items:
|
||||
$ref: '#/components/schemas/OperationRunLinkGuardViolation'
|
||||
description: Report shape returned by the bounded guard check.
|
||||
@ -1,199 +0,0 @@
|
||||
# Data Model: Operation Run Link Contract Enforcement
|
||||
|
||||
## Overview
|
||||
|
||||
This feature introduces no new persisted business entity. Existing `OperationRun` records, workspace and tenant authorization truth, and canonical operations destination pages remain authoritative. The new work is a derived link-generation and guard contract over those existing records and helper families.
|
||||
|
||||
## Existing Persistent Entities
|
||||
|
||||
### OperationRun
|
||||
|
||||
**Purpose**: Canonical runtime and monitoring truth for operation collection and detail destinations.
|
||||
|
||||
**Key fields used by this feature**:
|
||||
|
||||
- `id`
|
||||
- `workspace_id`
|
||||
- `tenant_id`
|
||||
- `type`
|
||||
- `status`
|
||||
- `outcome`
|
||||
- `context`
|
||||
|
||||
**Rules relevant to this feature**:
|
||||
|
||||
- Admin-plane and system-plane detail links resolve to existing canonical monitoring surfaces; the feature does not add a new route family.
|
||||
- Tenant-bound runs remain subject to destination-side entitlement checks even when the source link carries canonical tenant continuity.
|
||||
- The feature changes how source surfaces build URLs, not how `OperationRun` lifecycle truth is persisted.
|
||||
|
||||
### Tenant
|
||||
|
||||
**Purpose**: Existing tenant scope and entitlement anchor for admin-plane collection continuity and tenant-bound run inspection.
|
||||
|
||||
**Key fields used by this feature**:
|
||||
|
||||
- `id`
|
||||
- `external_id`
|
||||
- `workspace_id`
|
||||
- `name`
|
||||
|
||||
**Rules relevant to this feature**:
|
||||
|
||||
- Admin-plane collection links may preserve entitled tenant context only through helper-supported parameters.
|
||||
- Detail links never create a tenant-prefixed duplicate route; tenant relevance is enforced at the destination against the run itself.
|
||||
|
||||
### Workspace
|
||||
|
||||
**Purpose**: Existing workspace isolation boundary for canonical admin monitoring routes.
|
||||
|
||||
**Key fields used by this feature**:
|
||||
|
||||
- `id`
|
||||
- membership and capability truth via existing authorization helpers
|
||||
|
||||
**Rules relevant to this feature**:
|
||||
|
||||
- Non-members remain `404` on canonical admin monitoring routes.
|
||||
- The feature does not add any new workspace-scoped persistence or copied navigation records.
|
||||
|
||||
## Derived Models
|
||||
|
||||
### AdminOperationCollectionLinkInput
|
||||
|
||||
**Purpose**: Canonical input model for helper-owned admin collection links.
|
||||
|
||||
**Fields**:
|
||||
|
||||
- `surfaceKey`
|
||||
- `tenantId` or `tenantExternalId` when the source surface owns entitled tenant continuity
|
||||
- `navigationContext`
|
||||
- `activeTab`
|
||||
- `problemClass`
|
||||
- `allTenants`
|
||||
|
||||
**Validation rules**:
|
||||
|
||||
- Tenant context is included only when the source surface already owns an entitled tenant.
|
||||
- `activeTab`, `problemClass`, and `allTenants` remain limited to current helper-supported semantics.
|
||||
- Collection URLs are always emitted by `OperationRunLinks::index(...)`.
|
||||
|
||||
### AdminOperationDetailLinkInput
|
||||
|
||||
**Purpose**: Canonical input model for helper-owned admin detail links.
|
||||
|
||||
**Fields**:
|
||||
|
||||
- `surfaceKey`
|
||||
- `runId`
|
||||
- `navigationContext`
|
||||
|
||||
**Validation rules**:
|
||||
|
||||
- Detail links are emitted only through `OperationRunLinks::view(...)` or `OperationRunLinks::tenantlessView(...)`.
|
||||
- No source surface may mint a tenant-prefixed or surface-local duplicate detail route.
|
||||
|
||||
### SystemOperationCollectionLinkInput
|
||||
|
||||
**Purpose**: Canonical input model for helper-owned system collection links.
|
||||
|
||||
**Fields**:
|
||||
|
||||
- `surfaceKey`
|
||||
|
||||
**Validation rules**:
|
||||
|
||||
- Collection links are emitted only through `SystemOperationRunLinks::index()`.
|
||||
- System-plane collection links never fall back to admin-plane monitoring.
|
||||
|
||||
### SystemOperationDetailLinkInput
|
||||
|
||||
**Purpose**: Canonical input model for helper-owned system detail links.
|
||||
|
||||
**Fields**:
|
||||
|
||||
- `surfaceKey`
|
||||
- `runId`
|
||||
|
||||
**Validation rules**:
|
||||
|
||||
- Detail links are emitted only through `SystemOperationRunLinks::view(...)`.
|
||||
- System-plane detail links never fall back to admin-plane monitoring.
|
||||
|
||||
### CoveredLinkProducer
|
||||
|
||||
**Purpose**: Planning and guard model for every app-side source that emits an `OperationRun` collection or detail link.
|
||||
|
||||
**Fields**:
|
||||
|
||||
- `surfaceKey`
|
||||
- `filePath`
|
||||
- `plane` (`admin`, `system`)
|
||||
- `linkKind` (`collection`, `detail`, `both`)
|
||||
- `contractState` (`migrated`, `verified_helper_backed`, `allowlisted_exception`)
|
||||
- `justification`
|
||||
|
||||
**State transitions**:
|
||||
|
||||
- `raw_bypass` -> `migrated`
|
||||
- `raw_bypass` -> `allowlisted_exception`
|
||||
- `existing_helper_path` -> `verified_helper_backed`
|
||||
- `thin_delegate` -> `verified_helper_backed`
|
||||
|
||||
**Rules**:
|
||||
|
||||
- Every first-slice producer must end in either `migrated`, `verified_helper_backed`, or `allowlisted_exception`.
|
||||
- `allowlisted_exception` is valid only for infrastructure or redirect code that should not absorb UI-context dependencies.
|
||||
- `verified_helper_backed` is valid for already-converged system producers and thin delegates that forward directly to the canonical helper family.
|
||||
|
||||
### OperationRunLinkGuardReport
|
||||
|
||||
**Purpose**: Derived failure output for the bounded regression guard.
|
||||
|
||||
**Fields**:
|
||||
|
||||
- `scannedPaths[]`
|
||||
- `allowlistedPaths[]`
|
||||
- `acceptedDelegates[]`
|
||||
- `violations[]`
|
||||
|
||||
### GuardViolation
|
||||
|
||||
**Purpose**: Actionable output for a newly detected raw bypass.
|
||||
|
||||
**Fields**:
|
||||
|
||||
- `filePath`
|
||||
- `line`
|
||||
- `snippet`
|
||||
- `expectedHelper` (optional)
|
||||
- `reason`
|
||||
|
||||
**Rules**:
|
||||
|
||||
- When present, `expectedHelper` points to a concrete replacement path such as `OperationRunLinks::index(...)`, `OperationRunLinks::view(...)`, or `SystemOperationRunLinks::view(...)`.
|
||||
- Violations are limited to the declared guard boundary and must not report tests or helper implementations themselves.
|
||||
|
||||
## Consumer Matrix
|
||||
|
||||
| Producer | Plane | Link kinds | Target state |
|
||||
|----------|-------|------------|--------------|
|
||||
| `RecentOperationsSummary` | admin | collection | migrated |
|
||||
| `InventoryCoverage` | admin | collection + detail | migrated |
|
||||
| `InventoryItemResource` | admin | detail | migrated |
|
||||
| `ReviewPackResource` | admin | detail | migrated |
|
||||
| `TenantlessOperationRunViewer` | admin | collection fallbacks | migrated |
|
||||
| `RelatedNavigationResolver` | admin | detail | migrated |
|
||||
| `AdminPanelProvider` | admin | collection nav shortcut | allowlisted exception |
|
||||
| `TenantPanelProvider` | admin | collection nav shortcut | allowlisted exception |
|
||||
| `EnsureFilamentTenantSelected` | admin | collection redirect shortcut | allowlisted exception |
|
||||
| `ClearTenantContextController` | admin | collection redirect fallback | allowlisted exception |
|
||||
| `ViewTenant` | system | collection + detail | verified helper-backed |
|
||||
| `ViewWorkspace` | system | collection + detail | verified helper-backed |
|
||||
| `Runs` | system | collection + detail | verified helper-backed |
|
||||
| `ViewRun` | system | collection + detail | verified helper-backed |
|
||||
|
||||
## Persistence Boundaries
|
||||
|
||||
- No new table, enum-backed state, cache record, or presentation-only persistence is introduced.
|
||||
- The producer inventory and allowlist are repository-level planning and guard artifacts, not product-domain records.
|
||||
- Canonical navigation context remains derived request state owned by existing helper and navigation abstractions.
|
||||
@ -1,230 +0,0 @@
|
||||
# Implementation Plan: Operation Run Link Contract Enforcement
|
||||
|
||||
**Branch**: `232-operation-run-link-contract` | **Date**: 2026-04-23 | **Spec**: [spec.md](./spec.md)
|
||||
**Input**: Feature specification from `/specs/232-operation-run-link-contract/spec.md`
|
||||
|
||||
## Summary
|
||||
|
||||
Enforce one canonical contract for platform-owned `OperationRun` collection and detail links by migrating confirmed raw admin-plane producers to `OperationRunLinks`, preserving and regression-protecting the existing system-plane helper path through `SystemOperationRunLinks`, recording only the narrow infrastructure exceptions that cannot safely depend on the helper family, and adding one bounded regression guard that blocks new raw bypasses inside the declared UI and shared-navigation boundary.
|
||||
|
||||
## Technical Context
|
||||
|
||||
**Language/Version**: PHP 8.4.15, Laravel 12, Filament v5, Livewire v4
|
||||
**Primary Dependencies**: Filament Resources/Pages/Widgets, Pest v4, `App\Support\OperationRunLinks`, `App\Support\System\SystemOperationRunLinks`, `App\Support\Navigation\CanonicalNavigationContext`, `App\Support\Navigation\RelatedNavigationResolver`, existing workspace and tenant authorization helpers
|
||||
**Storage**: PostgreSQL-backed existing `operation_runs`, `tenants`, and `workspaces` records plus current session-backed canonical navigation state; no new persistence
|
||||
**Testing**: Focused Pest feature tests for canonical link behavior, representative admin drill-throughs, admin and system authorization semantics, and one bounded guard test
|
||||
**Validation Lanes**: `fast-feedback`, `confidence`
|
||||
**Target Platform**: Laravel admin web application running in Sail Linux containers, with admin plane at `/admin` and platform plane at `/system`
|
||||
**Project Type**: Monorepo with one Laravel runtime in `apps/platform` and spec artifacts at repository root
|
||||
**Performance Goals**: Link generation remains helper-owned string construction with no new remote work, no new persisted navigation state, and no broadened destination queries beyond existing canonical helper semantics
|
||||
**Constraints**: No new operations route family, no second link presenter stack, no compatibility shim layer, no weakening of `404` versus `403` semantics, and no repo-wide guard broadening beyond platform-owned UI and shared navigation code in this slice
|
||||
**Scale/Scope**: Migrate confirmed raw admin-plane producers in widgets, pages, resources, and shared navigation; validate already-helper-backed system-plane producers; keep bootstrapping and redirect exceptions explicit and narrow
|
||||
|
||||
## Filament v5 Implementation Contract
|
||||
|
||||
- **Livewire v4.0+ compliance**: Preserved. The feature stays inside existing Filament v5 and Livewire v4 primitives and does not introduce legacy Livewire v3 patterns.
|
||||
- **Provider registration location**: Unchanged. Panel providers remain registered in `bootstrap/providers.php`, not `bootstrap/app.php`.
|
||||
- **Global search coverage**:
|
||||
- `InventoryItemResource` remains compatible with global search expectations because it already exposes a `view` page.
|
||||
- `ReviewPackResource` keeps global search disabled via `$isGloballySearchable = false` while still exposing a `view` page for direct navigation.
|
||||
- The affected pages, widgets, and shared navigation helpers do not introduce or change any additional global-search surface.
|
||||
- **Destructive actions**: No new destructive actions are introduced. Existing destructive or destructive-like actions on operations surfaces remain governed by their current `Action::make(...)->action(...)` definitions, and existing system run-detail actions already retain `->requiresConfirmation()`.
|
||||
- **Asset strategy**: No new panel-only or shared assets are planned. Deployment expectations remain unchanged, including `cd apps/platform && php artisan filament:assets` only when registered Filament assets change.
|
||||
- **Testing plan**: Prove the feature with focused feature coverage on canonical admin links, representative dashboard and shared-resolver drill-throughs, explicit admin `404`/`403` authorization preservation, the new bounded guard, and system-plane continuity plus authorization semantics. No browser or heavy-governance family is needed for this plan.
|
||||
|
||||
## UI / Surface Guardrail Plan
|
||||
|
||||
- **Guardrail scope**: Changed admin monitoring collection/detail link producers, shared related-navigation builders, helper-owned URL-query continuity, and system-plane regression protection for canonical operations links
|
||||
- **Native vs custom classification summary**: Mixed shared-family change using native Filament resources/pages/widgets plus existing shared link helpers
|
||||
- **Shared-family relevance**: Navigation, action links, related links, deep links, and canonical monitoring entry points
|
||||
- **State layers in scope**: `page`, `detail`, and helper-owned `URL-query` continuity
|
||||
- **Handling modes by drift class or surface**: Hard-stop for new raw bypasses inside the declared guard boundary; review-mandatory for explicit infrastructure exceptions
|
||||
- **Repository-signal treatment**: Review-mandatory because the feature adds a bounded repo guard and a named exception list
|
||||
- **Special surface test profiles**: `standard-native-filament`, `monitoring-state-page`
|
||||
- **Required tests or manual smoke**: `functional-core`, `state-contract`, `manual-smoke`
|
||||
- **Exception path and spread control**: One named exception boundary for bootstrapping, middleware, and redirect surfaces that cannot safely depend on runtime navigation context; each retained path must be file-specific and justified
|
||||
- **Active feature PR close-out entry**: `Guardrail`
|
||||
|
||||
## Shared Pattern & System Fit
|
||||
|
||||
- **Cross-cutting feature marker**: yes
|
||||
- **Systems touched**: `OperationRunLinks`, `SystemOperationRunLinks`, `CanonicalNavigationContext`, `RecentOperationsSummary`, `InventoryCoverage`, `InventoryItemResource`, `ReviewPackResource`, `TenantlessOperationRunViewer`, `RelatedNavigationResolver`, panel navigation providers, tenant-selection middleware, and clear-tenant-context redirect behavior
|
||||
- **Shared abstractions reused**: `App\Support\OperationRunLinks`, `App\Support\System\SystemOperationRunLinks`, `App\Support\Navigation\CanonicalNavigationContext`; the existing `App\Support\OpsUx\OperationRunUrl` wrapper remains acceptable where it simply delegates to `OperationRunLinks`
|
||||
- **New abstraction introduced? why?**: none; the only new structure is a test-local allowlist boundary for legitimate raw producers
|
||||
- **Why the existing abstraction was sufficient or insufficient**: The helper families already encode canonical labels, admin/system plane selection, entitled tenant continuity, and canonical query semantics. The only current-release gap is adoption and enforcement on specific producers that still assemble routes locally.
|
||||
- **Bounded deviation / spread control**: Infrastructure-only exceptions are explicit, file-scoped, and non-precedential. UI surfaces, shared navigation, and related-link builders stay on the helper path.
|
||||
|
||||
## Constitution Check
|
||||
|
||||
*GATE: Passed before Phase 0 research. Re-checked after Phase 1 design: still passed with one bounded guard and no new persisted truth.*
|
||||
|
||||
| Gate | Status | Plan Notes |
|
||||
|------|--------|------------|
|
||||
| Inventory-first / read-write separation | PASS | The feature changes link generation only. It introduces no new writes, no new restore or remediation flow, and no new persisted artifact. |
|
||||
| RBAC, workspace isolation, tenant isolation | PASS | Admin-plane destinations keep workspace and tenant entitlement checks; system-plane destinations remain platform-only; cross-plane access stays `404`. |
|
||||
| Run observability / Ops-UX | PASS | Existing operations pages remain the canonical monitoring destination, but the feature does not start, mutate, or reclassify `OperationRun` lifecycle behavior. |
|
||||
| Shared pattern first | PASS | The plan converges on `OperationRunLinks` and `SystemOperationRunLinks` instead of introducing a second link presenter or navigation framework. |
|
||||
| Proportionality / no premature abstraction | PASS | The change is confined to migrating confirmed producers plus one bounded guard allowlist. No new registry, resolver family, or persisted truth is introduced. |
|
||||
| UI semantics / Filament-native discipline | PASS | Existing Filament pages, resources, widgets, and navigation items remain intact; only helper-owned URLs change. No ad-hoc UI replacement or new action chrome is introduced. |
|
||||
| Test governance | PASS | Proof stays in focused feature lanes with a narrow guard boundary. No browser lane or heavy-governance promotion is required for this slice. |
|
||||
|
||||
## Test Governance Check
|
||||
|
||||
- **Test purpose / classification by changed surface**: `Feature` for link continuity, representative admin drill-throughs, authorization-path preservation, and the bounded guard
|
||||
- **Affected validation lanes**: `fast-feedback`, `confidence`
|
||||
- **Why this lane mix is the narrowest sufficient proof**: The business truth is helper-owned URL generation, correct plane and scope continuity, and bounded prevention of new raw bypasses. Those behaviors are fully provable with targeted feature tests and one repo-guard test; browser coverage would add cost without validating additional business logic.
|
||||
- **Narrowest proving command(s)**:
|
||||
- `export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && cd apps/platform && ./vendor/bin/sail bin pint --dirty --format agent`
|
||||
- `export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/OpsUx/CanonicalViewRunLinksTest.php tests/Feature/Monitoring/OperationsDashboardDrillthroughTest.php tests/Feature/Filament/RecentOperationsSummaryWidgetTest.php tests/Feature/Filament/InventoryCoverageRunContinuityTest.php tests/Feature/ReviewPack/ReviewPackResourceTest.php tests/Feature/144/CanonicalOperationViewerDeepLinkTrustTest.php tests/Feature/078/RelatedLinksOnDetailTest.php tests/Feature/RunAuthorizationTenantIsolationTest.php tests/Feature/System/Spec195/SystemDirectoryResidualSurfaceTest.php tests/Feature/System/Spec113/AuthorizationSemanticsTest.php tests/Feature/Guards/OperationRunLinkContractGuardTest.php`
|
||||
- **Fixture / helper / factory / seed / context cost risks**: Minimal. Existing `OperationRun` factories, workspace membership helpers, tenant fixtures, and platform-user fixtures are sufficient.
|
||||
- **Expensive defaults or shared helper growth introduced?**: No. The guard allowlist remains opt-in and local to this feature; no new global test helper is required.
|
||||
- **Heavy-family additions, promotions, or visibility changes**: none
|
||||
- **Surface-class relief / special coverage rule**: Standard native-Filament relief plus the existing `monitoring-state-page` profile for canonical operations destinations
|
||||
- **Closing validation and reviewer handoff**: Re-run `pint`, then the focused test command above. Reviewers should verify that each migrated source now uses the correct helper family, that remaining raw producers sit only in the named exception boundary, and that both admin and system authorization semantics are unchanged.
|
||||
- **Budget / baseline / trend follow-up**: none
|
||||
- **Review-stop questions**: Did the guard accidentally absorb tests or non-operator infrastructure? Did any admin-plane producer still hand-assemble `admin.operations` URLs? Did any system-plane producer start bypassing `SystemOperationRunLinks`? Did any exception remain convenience-based instead of infrastructure-based?
|
||||
- **Escalation path**: `document-in-feature`
|
||||
- **Active feature PR close-out entry**: `Guardrail`
|
||||
- **Why no dedicated follow-up spec is needed**: This is current-release contract enforcement around an existing shared interaction family. Only a later desire for repo-wide routing policy would justify a separate governance spec.
|
||||
|
||||
## Project Structure
|
||||
|
||||
### Documentation (this feature)
|
||||
|
||||
```text
|
||||
specs/232-operation-run-link-contract/
|
||||
├── plan.md
|
||||
├── research.md
|
||||
├── data-model.md
|
||||
├── quickstart.md
|
||||
├── contracts/
|
||||
│ └── operation-run-link-contract.logical.openapi.yaml
|
||||
└── tasks.md
|
||||
```
|
||||
|
||||
### Source Code (repository root)
|
||||
|
||||
```text
|
||||
apps/platform/
|
||||
├── app/
|
||||
│ ├── Filament/
|
||||
│ │ ├── Pages/
|
||||
│ │ │ ├── InventoryCoverage.php
|
||||
│ │ │ └── Operations/TenantlessOperationRunViewer.php
|
||||
│ │ ├── Resources/
|
||||
│ │ │ ├── InventoryItemResource.php
|
||||
│ │ │ └── ReviewPackResource.php
|
||||
│ │ └── Widgets/Tenant/RecentOperationsSummary.php
|
||||
│ ├── Http/Controllers/ClearTenantContextController.php
|
||||
│ ├── Providers/Filament/
|
||||
│ │ ├── AdminPanelProvider.php
|
||||
│ │ └── TenantPanelProvider.php
|
||||
│ └── Support/
|
||||
│ ├── Middleware/EnsureFilamentTenantSelected.php
|
||||
│ ├── Navigation/
|
||||
│ │ ├── CanonicalNavigationContext.php
|
||||
│ │ └── RelatedNavigationResolver.php
|
||||
│ ├── OperationRunLinks.php
|
||||
│ ├── OpsUx/OperationRunUrl.php
|
||||
│ └── System/SystemOperationRunLinks.php
|
||||
└── tests/
|
||||
└── Feature/
|
||||
├── 078/RelatedLinksOnDetailTest.php
|
||||
├── 144/CanonicalOperationViewerDeepLinkTrustTest.php
|
||||
├── Filament/
|
||||
│ ├── InventoryCoverageRunContinuityTest.php
|
||||
│ └── RecentOperationsSummaryWidgetTest.php
|
||||
├── Guards/OperationRunLinkContractGuardTest.php
|
||||
├── Monitoring/OperationsDashboardDrillthroughTest.php
|
||||
├── OpsUx/CanonicalViewRunLinksTest.php
|
||||
├── ReviewPack/ReviewPackResourceTest.php
|
||||
├── RunAuthorizationTenantIsolationTest.php
|
||||
└── System/
|
||||
├── Spec113/AuthorizationSemanticsTest.php
|
||||
└── Spec195/SystemDirectoryResidualSurfaceTest.php
|
||||
```
|
||||
|
||||
**Structure Decision**: Single Laravel application inside the monorepo. Runtime changes stay inside `apps/platform`, while planning artifacts remain under `specs/232-operation-run-link-contract`.
|
||||
|
||||
## Complexity Tracking
|
||||
|
||||
No constitutional violation is planned. One bounded review artifact is tracked explicitly because the feature adds an allowlisted guard boundary.
|
||||
|
||||
| Violation | Why Needed | Simpler Alternative Rejected Because |
|
||||
|-----------|------------|-------------------------------------|
|
||||
| BLOAT-001 bounded exception inventory | The guard needs a small explicit exception list so bootstrapping and redirect code does not have to fake helper usage or fabricate runtime context it does not own. | A raw repo-wide ban without named exceptions would create false positives, blur the operator-facing boundary, and push the feature into heavy-governance scope. |
|
||||
|
||||
## Proportionality Review
|
||||
|
||||
- **Current operator problem**: Platform-owned admin and system surfaces still have parallel ways to open the same canonical operations destinations, which creates plane and scope drift and keeps the next contributor free to reintroduce raw route assembly.
|
||||
- **Existing structure is insufficient because**: The helper families already exist, but they are optional in practice. Without a bounded enforcement slice, the repository keeps two competing link-generation paths.
|
||||
- **Narrowest correct implementation**: Migrate the confirmed raw admin producers, preserve the existing system helper path, record the few legitimate infrastructure exceptions, and add one route-bounded guard that fails on new bypasses.
|
||||
- **Ownership cost created**: Small ongoing maintenance of the exception list and targeted regression coverage for a shared interaction family.
|
||||
- **Alternative intentionally rejected**: A repo-wide route-string ban or a new navigation presenter stack. Both would be broader than the current operator problem and would add governance or architecture cost the release does not need.
|
||||
- **Release truth**: Current-release contract enforcement and cleanup.
|
||||
|
||||
## Phase 0 Research Summary
|
||||
|
||||
- Reuse `OperationRunLinks` for every admin-plane producer that opens `/admin/operations` or `/admin/operations/{run}`; do not create a second admin helper or presenter.
|
||||
- Treat the currently confirmed raw admin-plane producers as first-slice migration targets: `RecentOperationsSummary`, `InventoryCoverage`, `InventoryItemResource`, `ReviewPackResource`, `TenantlessOperationRunViewer`, and `RelatedNavigationResolver`.
|
||||
- Keep the initial explicit exception boundary narrow and infrastructure-only: panel providers, tenant-selection middleware, and clear-tenant-context redirects may stay raw if helper adoption would fabricate the wrong runtime context.
|
||||
- Preserve `SystemOperationRunLinks` as the canonical system-plane path. Current system widgets and pages are already largely converged, so the first slice focuses on regression prevention rather than inventing synthetic migration work.
|
||||
- Treat `OperationRunUrl` as an acceptable thin delegating seam because it forwards directly to `OperationRunLinks` and does not create parallel routing truth.
|
||||
- Keep the guard route-bounded to platform-owned UI and shared navigation code under `apps/platform/app`; do not scan tests, helpers themselves, or unrelated infrastructure.
|
||||
|
||||
## Phase 1 Design Summary
|
||||
|
||||
- `data-model.md` documents the feature as a derived contract over existing `OperationRun`, tenant, workspace, and canonical navigation truth plus a bounded producer inventory and explicit exception model.
|
||||
- `contracts/operation-run-link-contract.logical.openapi.yaml` defines the internal logical contract for canonical admin/system collection and detail links plus the bounded guard request and result shape.
|
||||
- `quickstart.md` provides the focused validation path for representative admin drill-throughs, system-plane continuity, allowlisted exceptions, and authorization semantics.
|
||||
|
||||
## Implementation Close-Out Inventory
|
||||
|
||||
- **Migrated admin producers**: `RecentOperationsSummary` collection links now use `OperationRunLinks::index(...)`; `InventoryCoverage` basis/detail and inventory-sync history links now use `OperationRunLinks::view(...)` and helper-owned type filtering; `InventoryItemResource` and `ReviewPackResource` detail links now use `OperationRunLinks::view(...)` or `tenantlessView(...)`; `TenantlessOperationRunViewer` default collection fallbacks now use `OperationRunLinks::index()`; `RelatedNavigationResolver` operation-run audit target links now use `OperationRunLinks::tenantlessView(...)`.
|
||||
- **Verified system producers**: `ViewTenant`, `ViewWorkspace`, `Runs`, and `ViewRun` remain on `SystemOperationRunLinks::index()` and `SystemOperationRunLinks::view(...)` with no admin-plane fallback.
|
||||
- **Accepted delegate**: `App\Support\OpsUx\OperationRunUrl` remains a thin delegate to `OperationRunLinks` and is explicitly covered by helper-contract tests.
|
||||
- **Allowlisted infrastructure exceptions**: `AdminPanelProvider`, `TenantPanelProvider`, `EnsureFilamentTenantSelected`, and `ClearTenantContextController` retain raw `admin.operations.index` routes because they own panel bootstrapping or redirect behavior rather than source-surface drill-through context.
|
||||
- **Guard boundary**: `OperationRunLinkContractGuardTest` scans the migrated admin producers, verified system producers, accepted delegate, and four explicit infrastructure exceptions. It blocks raw `admin.operations.index`, raw `admin.operations.view`, direct `/system/ops/runs` paths, and direct `Runs::getUrl(...)` / `ViewRun::getUrl(...)` use outside the allowlist.
|
||||
- **Test-governance disposition**: `document-in-feature`. The cost is contained to a focused feature guard and representative feature coverage; no follow-up spec or heavy-governance lane is needed.
|
||||
|
||||
## Phase 1 Agent Context Update
|
||||
|
||||
- Run `.specify/scripts/bash/update-agent-context.sh copilot` after the plan, research, data model, quickstart, and contract artifacts are written.
|
||||
- The update must preserve manual additions between generated markers and add only the new technology and change notes relevant to Spec 232.
|
||||
- The generated agent-context file is supporting output for the planning workflow, not a reason to widen the feature scope.
|
||||
|
||||
## Implementation Strategy
|
||||
|
||||
1. **Inventory and classify producers**
|
||||
- Freeze the first-slice inventory of raw admin-plane link producers and distinguish between migration targets, verified helper-backed system producers, and explicit infrastructure exceptions.
|
||||
|
||||
2. **Migrate direct admin collection and detail links**
|
||||
- Replace raw `route('admin.operations.index')` and `route('admin.operations.view')` usage in widgets, pages, and resources with `OperationRunLinks` methods.
|
||||
- Preserve canonical tenant continuity, problem-class filters, active-tab semantics, and current operator-facing labels through the helper contract only.
|
||||
|
||||
3. **Normalize shared related-navigation and back-link paths**
|
||||
- Refactor `RelatedNavigationResolver` and `TenantlessOperationRunViewer` to consume canonical helper methods rather than local route assembly.
|
||||
- Keep back-link context helper-owned when `CanonicalNavigationContext` is present and degrade cleanly to the canonical admin collection when it is absent.
|
||||
|
||||
4. **Retain only justified infrastructure exceptions**
|
||||
- Keep panel navigation providers, tenant-selection middleware, and clear-tenant-context redirects as the narrow allowlisted exception set for this slice.
|
||||
- Record each allowlisted exception with a reason tied to bootstrapping or redirect ownership, not convenience.
|
||||
|
||||
5. **Protect the already-converged system plane**
|
||||
- Audit verified helper-backed system pages and widgets and keep them on `SystemOperationRunLinks`, applying only minimal cleanup if a direct page URL slips through.
|
||||
- Use the guard and authorization tests to prove that no platform-facing system producer regresses to admin-plane or direct page URL assembly.
|
||||
|
||||
6. **Add bounded regression coverage**
|
||||
- Add one guard test that scans only the declared app-side boundary and fails with file-plus-snippet output on representative bypasses.
|
||||
- Extend or update focused feature tests so admin-plane continuity, explicit admin `404`/`403` preservation, system-plane continuity, and negative authorization behavior remain explicit.
|
||||
|
||||
## Risks and Mitigations
|
||||
|
||||
- **False-positive guard scope**: A broad scan would catch tests or infrastructure code. Mitigation: keep the boundary on platform-owned UI and shared navigation files only and maintain a file-scoped allowlist for legitimate exceptions.
|
||||
- **Tenant continuity drift**: Replacing raw URLs could accidentally drop canonical filters or navigation context. Mitigation: route collection and detail links through existing helper parameters and keep representative continuity tests in scope.
|
||||
- **Back-link regression on run detail**: `TenantlessOperationRunViewer` currently mixes raw fallbacks with helper-owned detail refresh behavior. Mitigation: migrate both the back-to-operations and show-all-operations fallbacks in the same slice so behavior stays coherent.
|
||||
- **Over-scoping into routing cleanup**: It is easy to turn this into a general route-string purge. Mitigation: keep the feature limited to `OperationRun` collection and detail link producers plus their bounded exception list.
|
||||
|
||||
## Post-Design Re-check
|
||||
|
||||
Phase 0 and Phase 1 outputs resolve the planning questions without introducing a new routing framework, new persisted navigation truth, or a repo-wide governance lane change. The plan remains constitution-compliant, helper-first, and ready for `/speckit.tasks`.
|
||||
@ -1,97 +0,0 @@
|
||||
# Quickstart: Operation Run Link Contract Enforcement
|
||||
|
||||
## Prerequisites
|
||||
|
||||
1. Start the local platform stack.
|
||||
|
||||
```bash
|
||||
export PATH="/bin:/usr/bin:/usr/local/bin:$PATH"
|
||||
cd apps/platform && ./vendor/bin/sail up -d
|
||||
```
|
||||
|
||||
2. Work with:
|
||||
- one workspace operator who can access canonical admin monitoring,
|
||||
- one entitled tenant with recent `OperationRun` records,
|
||||
- one second tenant that the operator must not be able to inspect, and
|
||||
- one platform user who can access `/system/ops/runs`.
|
||||
|
||||
3. Remember that this feature changes link generation only. No frontend asset build should be required unless unrelated platform assets changed.
|
||||
|
||||
## Automated Validation
|
||||
|
||||
Run formatting and the narrowest proving suites for this feature:
|
||||
|
||||
```bash
|
||||
export PATH="/bin:/usr/bin:/usr/local/bin:$PATH"
|
||||
cd apps/platform && ./vendor/bin/sail bin pint --dirty --format agent
|
||||
|
||||
export PATH="/bin:/usr/bin:/usr/local/bin:$PATH"
|
||||
cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/OpsUx/CanonicalViewRunLinksTest.php tests/Feature/Monitoring/OperationsDashboardDrillthroughTest.php tests/Feature/Filament/RecentOperationsSummaryWidgetTest.php tests/Feature/Filament/InventoryCoverageRunContinuityTest.php tests/Feature/ReviewPack/ReviewPackResourceTest.php tests/Feature/144/CanonicalOperationViewerDeepLinkTrustTest.php tests/Feature/078/RelatedLinksOnDetailTest.php tests/Feature/RunAuthorizationTenantIsolationTest.php tests/Feature/System/Spec195/SystemDirectoryResidualSurfaceTest.php tests/Feature/System/Spec113/AuthorizationSemanticsTest.php tests/Feature/Guards/OperationRunLinkContractGuardTest.php
|
||||
```
|
||||
|
||||
## Final Guard Boundary
|
||||
|
||||
The implemented guard is bounded to the first-slice source surfaces and explicit infrastructure exceptions:
|
||||
|
||||
- **Migrated admin producers**: `RecentOperationsSummary`, `InventoryCoverage`, `InventoryItemResource`, `ReviewPackResource`, `TenantlessOperationRunViewer`, and `RelatedNavigationResolver`.
|
||||
- **Verified system producers**: `ViewTenant`, `ViewWorkspace`, `Runs`, and `ViewRun`, all continuing through `SystemOperationRunLinks`.
|
||||
- **Accepted thin delegate**: `App\Support\OpsUx\OperationRunUrl`, which forwards to `OperationRunLinks`.
|
||||
- **Allowlisted infrastructure exceptions**: `AdminPanelProvider`, `TenantPanelProvider`, `EnsureFilamentTenantSelected`, and `ClearTenantContextController`.
|
||||
- **Forbidden bypasses inside the boundary**: raw `route('admin.operations.index')`, raw `route('admin.operations.view')`, direct `/system/ops/runs` strings, and direct `Runs::getUrl(...)` or `ViewRun::getUrl(...)` outside `SystemOperationRunLinks`.
|
||||
|
||||
## Manual Validation Flow
|
||||
|
||||
### 1. Validate tenant-aware admin collection continuity
|
||||
|
||||
1. Open a tenant-facing surface that exposes the recent-operations summary or an inventory coverage follow-up link.
|
||||
2. Follow the `Open operations` or equivalent history link.
|
||||
3. Confirm the destination stays on `/admin/operations` and preserves only helper-supported tenant or filter continuity.
|
||||
4. Confirm the page does not invent a tenant-prefixed duplicate operations route.
|
||||
|
||||
### 2. Validate canonical admin detail links from representative resource surfaces
|
||||
|
||||
1. Open one inventory item with a `last_seen_operation_run_id`.
|
||||
2. Follow the `Last inventory sync` link.
|
||||
3. Open one review pack with an associated `operation_run_id`.
|
||||
4. Confirm both links open canonical admin run detail, not a surface-local route or raw fallback URL.
|
||||
|
||||
### 3. Validate shared related-navigation and back-link behavior
|
||||
|
||||
1. Open a surface that renders an `operation_run` related link through `RelatedNavigationResolver`.
|
||||
2. Confirm the helper-generated label and URL match canonical admin run detail behavior.
|
||||
3. Open `TenantlessOperationRunViewer` through a source without an explicit back-link context.
|
||||
4. Confirm `Back to Operations` and `Show all operations` land on the canonical admin collection helper path.
|
||||
|
||||
### 4. Validate system-plane continuity
|
||||
|
||||
1. Open a system-plane widget or directory page with run drill-through.
|
||||
2. Follow collection and detail links into monitoring.
|
||||
3. Confirm the destination stays on `/system/ops/runs` or `/system/ops/runs/{run}` and does not fall back to `/admin/operations`.
|
||||
|
||||
### 5. Validate authorization semantics stayed unchanged
|
||||
|
||||
1. As a workspace member who is not entitled to a foreign tenant, request a canonical admin detail URL for that tenant’s run.
|
||||
2. Confirm the response remains `404`.
|
||||
3. As a non-platform user, request a system-plane operations URL.
|
||||
4. Confirm the response remains `404`.
|
||||
5. As an entitled actor missing the relevant capability, confirm current destination behavior still yields `403` where the route already distinguishes membership from capability denial.
|
||||
|
||||
### 6. Validate the explicit exception boundary
|
||||
|
||||
1. Confirm that navigation boot, middleware, and clear-tenant redirect behavior still function after the cleanup.
|
||||
2. Review the named allowlist entries and verify each remaining raw producer is infrastructure-owned rather than convenience-owned.
|
||||
3. Confirm no new operator-facing page, widget, or related-navigation builder remains on raw `admin.operations.*` assembly outside the allowlist.
|
||||
|
||||
### 7. Validate the guardrail
|
||||
|
||||
1. Use a temporary local probe or test fixture to simulate one representative raw `route('admin.operations.view', ...)` bypass inside the declared guard boundary without committing it.
|
||||
2. Run the guard test.
|
||||
3. Confirm it fails with actionable file and snippet output.
|
||||
4. Replace the bypass with the canonical helper or move it into an explicitly justified exception and confirm the guard passes again.
|
||||
|
||||
## Reviewer Notes
|
||||
|
||||
- The feature stays Livewire v4.0+ compatible and does not change provider registration in `bootstrap/providers.php`.
|
||||
- No new global-search surface is introduced; `InventoryItemResource` already has a view page and `ReviewPackResource` remains non-searchable.
|
||||
- No destructive action or new asset behavior is introduced.
|
||||
- The contract boundary is intentionally narrow: platform-owned UI and shared navigation code only.
|
||||
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue
Block a user