From 0e2adeab71743a6b762e7af77926084d852938cb Mon Sep 17 00:00:00 2001 From: ahmido Date: Mon, 9 Feb 2026 11:28:09 +0000 Subject: [PATCH] feat(verification): unify verification surfaces (Spec 084) (#102) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implements Spec 084 (verification-surfaces-unification). Highlights - Unifies tenant + onboarding verification start on `provider.connection.check` (OperationRun-based, enqueue-only). - Ensures completed blocked runs persist a schema-valid `context.verification_report` stub (DB-only viewers never show “unavailable”). - Adds tenant embedded verification report widget with DB-only rendering + canonical tenantless “View run” links. - Enforces 404/403 semantics for tenantless run viewing (workspace membership + tenant entitlement required; otherwise 404). - Fixes admin panel widgets to resolve tenant from record context so Owners can start verification and recent operations renders correctly. Tests - Ran: `vendor/bin/sail artisan test --compact tests/Feature/Verification/ tests/Feature/ProviderConnections/ProviderOperationBlockedGuidanceSpec081Test.php tests/Feature/Onboarding/OnboardingVerificationTest.php tests/Feature/RunAuthorizationTenantIsolationTest.php tests/Feature/Filament/TenantVerificationReportWidgetTest.php tests/Feature/Filament/RecentOperationsSummaryWidgetTest.php` Notes - Filament v5 / Livewire v4 compatible. - No new assets; no changes to provider registration. Co-authored-by: Ahmed Darrazi Reviewed-on: https://git.cloudarix.de/ahmido/TenantAtlas/pulls/102 --- .github/agents/copilot-instructions.md | 5 +- .specify/memory/constitution.md | 35 ++- .specify/templates/plan-template.md | 1 + .specify/templates/spec-template.md | 2 +- .specify/templates/tasks-template.md | 2 +- .../TenantlessOperationRunViewer.php | 17 +- .../ManagedTenantOnboardingWizard.php | 59 ++++- app/Filament/Resources/TenantResource.php | 204 +++++++-------- .../TenantResource/Pages/ViewTenant.php | 173 +++++++++---- .../Tenant/RecentOperationsSummary.php | 15 +- .../Tenant/TenantVerificationReport.php | 223 ++++++++++++++++ app/Policies/OperationRunPolicy.php | 18 +- .../Providers/ProviderOperationStartGate.php | 12 + .../Verification/StartVerification.php | 72 +++++- .../BlockedVerificationReportFactory.php | 119 +++++++++ .../tenant-verification-report.blade.php | 150 +++++++++++ .../checklists/requirements.md | 35 +++ ...text.provider-connection-check.schema.json | 40 +++ .../contracts/verification-surfaces.routes.md | 57 ++++ .../data-model.md | 82 ++++++ .../plan.md | 131 ++++++++++ .../quickstart.md | 57 ++++ .../research.md | 70 +++++ .../spec.md | 173 +++++++++++++ .../tasks.md | 160 ++++++++++++ .../RecentOperationsSummaryWidgetTest.php | 27 ++ tests/Feature/Filament/TenantSetupTest.php | 149 ++++------- .../TenantVerificationReportWidgetTest.php | 244 ++++++++++++++++++ .../Onboarding/OnboardingVerificationTest.php | 95 ++++++- .../ProviderConnectionCutoverSpec081Test.php | 10 +- ...derOperationBlockedGuidanceSpec081Test.php | 19 ++ .../RunAuthorizationTenantIsolationTest.php | 29 +++ .../BlockedVerificationReportStubTest.php | 43 +++ .../VerificationStartDedupeTest.php | 46 ++++ .../ProviderOperationStartGateTest.php | 3 + 35 files changed, 2261 insertions(+), 316 deletions(-) create mode 100644 app/Filament/Widgets/Tenant/TenantVerificationReport.php create mode 100644 app/Support/Verification/BlockedVerificationReportFactory.php create mode 100644 resources/views/filament/widgets/tenant/tenant-verification-report.blade.php create mode 100644 specs/084-verification-surfaces-unification/checklists/requirements.md create mode 100644 specs/084-verification-surfaces-unification/contracts/operation-run-context.provider-connection-check.schema.json create mode 100644 specs/084-verification-surfaces-unification/contracts/verification-surfaces.routes.md create mode 100644 specs/084-verification-surfaces-unification/data-model.md create mode 100644 specs/084-verification-surfaces-unification/plan.md create mode 100644 specs/084-verification-surfaces-unification/quickstart.md create mode 100644 specs/084-verification-surfaces-unification/research.md create mode 100644 specs/084-verification-surfaces-unification/spec.md create mode 100644 specs/084-verification-surfaces-unification/tasks.md create mode 100644 tests/Feature/Filament/RecentOperationsSummaryWidgetTest.php create mode 100644 tests/Feature/Filament/TenantVerificationReportWidgetTest.php create mode 100644 tests/Feature/Verification/BlockedVerificationReportStubTest.php diff --git a/.github/agents/copilot-instructions.md b/.github/agents/copilot-instructions.md index 0e60843..1af0594 100644 --- a/.github/agents/copilot-instructions.md +++ b/.github/agents/copilot-instructions.md @@ -22,6 +22,8 @@ ## Active Technologies - PostgreSQL (via Sail) (080-workspace-managed-tenant-admin) - PHP 8.4.15 + Laravel 12, Filament v5, Livewire v4, Socialite v5 (081-provider-connection-cutover) - PHP 8.4.x + Laravel 12, Filament v5, Livewire v4 (082-action-surface-contract) +- PHP 8.4 (Laravel 12) + Filament v5 (Livewire v4), Queue/Jobs (Laravel), Microsoft Graph via `GraphClientInterface` (084-verification-surfaces-unification) +- PostgreSQL (JSONB-backed `OperationRun.context`) (084-verification-surfaces-unification) - PHP 8.4.15 (feat/005-bulk-operations) @@ -41,9 +43,8 @@ ## Code Style PHP 8.4.15: Follow standard conventions ## Recent Changes +- 084-verification-surfaces-unification: Added PHP 8.4 (Laravel 12) + Filament v5 (Livewire v4), Queue/Jobs (Laravel), Microsoft Graph via `GraphClientInterface` - 082-action-surface-contract: Added PHP 8.4.x + Laravel 12, Filament v5, Livewire v4 -- 081-provider-connection-cutover: Added PHP 8.4.15 + Laravel 12, Filament v5, Livewire v4, Socialite v5 -- 080-workspace-managed-tenant-admin: Added PHP 8.4.15 (Laravel 12) + Filament v5, Livewire v4, Tailwind v4 diff --git a/.specify/memory/constitution.md b/.specify/memory/constitution.md index afba721..a81d4f1 100644 --- a/.specify/memory/constitution.md +++ b/.specify/memory/constitution.md @@ -1,10 +1,14 @@ + +### User Story 1 - Verify tenant configuration consistently (Priority: P1) + +As a workspace member with the appropriate permission, I can start a tenant verification from the tenant detail view and immediately see the latest stored verification results, without the page performing external provider calls during rendering. + +**Why this priority**: This is the primary operational entry-point and must be fast, safe, and predictable. + +**Independent Test**: Can be fully tested by starting verification from the tenant detail view, asserting a run record exists, and asserting the embedded viewer renders using stored data. + +**Acceptance Scenarios**: + +1. **Given** a tenant with an eligible provider connection, **When** I click “Verify configuration”, **Then** a verification run is started or deduped, and the tenant page renders the report viewer using stored data only. +2. **Given** a tenant where verification cannot proceed (e.g., missing consent/credentials), **When** I click “Verify configuration”, **Then** a completed “blocked” run exists and the embedded viewer shows a stub/preflight report (not an “unavailable” state). + + + +### User Story 2 - Onboarding verify access behaves identically (Priority: P2) + +As a workspace member with onboarding verification capability, I can start onboarding verification and receive the same run, dedupe/busy, blocked-report, and canonical link behavior as the tenant verification surfaces. + +**Why this priority**: Removes confusion and reduces operational variance between onboarding and day-2 operations. + +**Independent Test**: Can be tested by starting verification in onboarding and asserting identical run outcomes and viewer behavior to the tenant surface. + +**Acceptance Scenarios**: + +1. **Given** onboarding verification is started while another verification run is already active for the same target, **When** I start verification again, **Then** I receive a busy/deduped result that points to the existing active run. + +--- + +### User Story 3 - Use canonical run links everywhere (Priority: P3) + +As a workspace member, I can open the canonical run viewer link from any verification surface and it consistently resolves to the same “tenantless” run route. + +**Why this priority**: Improves supportability and prevents broken/ambiguous deep links. + +**Independent Test**: Can be tested by starting verification, then asserting all “View run” links point to the canonical run route and the page is accessible only to authorized members. + +**Acceptance Scenarios**: + +1. **Given** a verification run exists, **When** I click “View run”, **Then** I am taken to the canonical run viewer route for that run. + +--- + +### Edge Cases +- Verification is started while an existing run is queued/running for the same target (dedupe/busy behavior must be consistent across surfaces). +- Verification cannot proceed due to missing consent/credentials (a completed blocked run must still have a schema-valid report). +- Viewer is opened by a non-member (deny-as-not-found behavior). +- Stored report data is present but incomplete/invalid (viewer must fail safe with a clear non-leaky message and no external calls). + +## Non-Functional Requirements + +- **NFR-001 (DB-only render)**: Tenant detail, onboarding verification display, and canonical run viewer rendering MUST be DB-only, with no external provider or Graph calls during mount/render/poll/refresh interactions. +- **NFR-002 (start path latency)**: Verification start interactions (`started`, `deduped`, `scope_busy`, `blocked`) SHOULD complete request handling in under 1 second under normal local/staging conditions because they only authorize, create/dedupe run state, enqueue, and notify. +- **NFR-003 (refresh/polling discipline)**: Verification UI refresh behavior MUST read persisted `OperationRun` state only and MUST NOT trigger inventory refresh or Graph permission reconciliation during display refresh. + +## Requirements *(mandatory)* + +**Constitution alignment (required):** If this feature introduces any Microsoft Graph calls, any write/change behavior, +or any long-running/queued/scheduled work, the spec MUST describe contract registry updates, safety gates +(preview/confirmation/audit), tenant isolation, run observability (`OperationRun` type/identity/visibility), and tests. +If security-relevant DB-only actions intentionally skip `OperationRun`, the spec MUST describe `AuditLog` entries. + +**Constitution alignment (RBAC-UX):** If this feature introduces or changes authorization behavior, the spec MUST: +- state which authorization plane(s) are involved (tenant `/admin/t/{tenant}` vs platform `/system`), +- ensure any cross-plane access is deny-as-not-found (404), +- explicitly define 404 vs 403 semantics: + - non-member / not entitled to tenant scope → 404 (deny-as-not-found) + - member but missing capability → 403 +- describe how authorization is enforced server-side (Gates/Policies) for every mutation/operation-start/credential change, +- reference the canonical capability registry (no raw capability strings; no role-string checks in feature code), +- ensure global search is tenant-scoped and non-member-safe (no hints; inaccessible results treated as 404 semantics), +- ensure destructive-like actions require confirmation (`->requiresConfirmation()`), +- include at least one positive and one negative authorization test, and note any RBAC regression tests added/updated. + +**Constitution alignment (OPS-EX-AUTH-001):** OIDC/SAML login handshakes may perform synchronous outbound HTTP (e.g., token exchange) +on `/auth/*` endpoints without an `OperationRun`. This MUST NOT be used for Monitoring/Operations pages. + +**Constitution alignment (BADGE-001):** If this feature changes status-like badges (status/outcome/severity/risk/availability/boolean), +the spec MUST describe how badge semantics stay centralized (no ad-hoc mappings) and which tests cover any new/changed values. + +**Constitution alignment (Filament Action Surfaces):** If this feature adds or modifies any Filament Resource / RelationManager / Page, +the spec MUST include a “UI Action Matrix” (see below) and explicitly state whether the Action Surface Contract is satisfied. +If the contract is not satisfied, the spec MUST include an explicit exemption with rationale. + + + +### Functional Requirements + +- **FR-001**: System MUST provide a single, unified verification start mechanism used by both the tenant detail “Verify configuration” and onboarding “Verify access” surfaces. +- **FR-002**: Starting verification MUST create (or dedupe to) a single “verification run” record for the target, and surface a stable link to view that run. +- **FR-003**: When a verification run cannot proceed due to missing prerequisites, the system MUST finalize the run as completed “blocked” and persist a schema-valid stub/preflight verification report. +- **DB-only render invariant**: Verification viewers are read-only projections of persisted run/report data; they MUST NOT perform provider/Graph calls and MUST NOT persist permission inventory updates. +- **FR-004**: For any verification run that is completed (including blocked), the embedded/onboarding viewers MUST render the verification report using stored data only. +- **FR-004a**: The tenant detail embedded viewer MUST select the latest verification run attempt for the tenant and verification type; if that run is active (queued/running) and no report is yet available, the UI MUST render a DB-only “in progress” state. +- **FR-004b**: If no verification run exists yet for the tenant and verification type, the tenant detail embedded section MUST show a DB-only empty state with a “Start verification” CTA. +- **FR-005**: UI page rendering (including mount/load/summary components) MUST NOT trigger external provider calls directly or indirectly. +- **FR-006**: Dedupe rules MUST ensure at most one active run (queued/running) exists per target and verification type; repeated starts during an active run MUST return a busy/deduped outcome. +- **FR-007**: The system MUST persist any permissions inventory updates only as part of the verification job’s execution, and MUST NOT persist these updates during page rendering. +- **FR-008**: All “View run” links exposed by verification surfaces MUST use the canonical tenantless run viewer route. +- **FR-009**: Authorization MUST be enforced server-side: + - missing workspace membership OR missing tenant entitlement MUST be deny-as-not-found (404) for tenant-scoped routes/actions and tenantless canonical views of tenant-associated records, + - members lacking the required capability to start verification MUST see the action visible-but-disabled with helper text, and MUST receive a forbidden response (403) if invoked. +- **FR-010**: The system MUST emit run observability sufficient for operations (run type, outcome, timestamps, target scope) and MUST be test-covered. + +### Assumptions & Dependencies + +- This change unifies surfaces and report availability; it does not expand the set of verification checks beyond what is already produced today. +- For tenant verification surfaces (`ViewTenant` header action, tenant embedded CTA, and tenant list verify action), “eligible provider connection” means the resolved **default** provider connection for provider `microsoft`. +- Onboarding verification continues to use the session-selected provider connection, and still runs through the same unified operation type and run orchestration. +- Verification reports are stored with the verification run record and are treated as the sole source for UI rendering. +- External provider calls are permitted only as part of explicit user-triggered verification runs and their execution (never during page rendering). +- Existing authorization capabilities and membership rules remain the source of truth; this feature standardizes how they apply across surfaces. + +## UI Action Matrix *(mandatory when Filament is changed)* + +If this feature adds/modifies any Filament Resource / RelationManager / Page, fill out the matrix below. + +For each surface, list the exact action labels, whether they are destructive (confirmation? typed confirmation?), +RBAC gating (capability + enforcement helper), and whether the mutation writes an audit log. + +| Surface | Location | Header Actions | Inspect Affordance (List/Table) | Row Actions (max 2 visible) | Bulk Actions (grouped) | Empty-State CTA(s) | View Header Actions | Create/Edit Save+Cancel | Audit log? | Notes / Exemptions | +|---|---|---|---|---|---|---|---|---|---|---| +| Tenant detail view | Tenant admin area | Verify configuration | Embedded viewer + View run link | n/a | n/a | Start verification (empty state) | n/a | n/a | Yes | DB-only render; starts/dupes run; if missing capability: visible but disabled with helper | +| Tenant list (Tenants table) | Tenant admin area | n/a | Clickable row + View action | Verify configuration (grouped action) | grouped | n/a | n/a | n/a | Yes | Uses same unified start path and canonical run links as tenant detail | +| Onboarding verification step | Onboarding wizard | Start verification | Embedded viewer + View run link | n/a | n/a | n/a | n/a | n/a | Yes | Same semantics as tenant surface | +| Tenantless run viewer | Operations area | n/a | n/a | n/a | n/a | n/a | n/a | n/a | Yes | Requires workspace membership + tenant entitlement; otherwise 404 | + +### Key Entities *(include if feature involves data)* + +- **Verification Run**: An immutable operational record representing one attempt to verify access/configuration for a target scope. It captures status/outcome and a canonical link for viewing. +- **Verification Report**: A schema-valid, stored report attached to a verification run. It is always present for completed runs, including blocked runs (stub/preflight). + +## Success Criteria *(mandatory)* +### Measurable Outcomes + +- **SC-001**: 100% of completed blocked verification runs display a usable stub report (no "report unavailable" state for completed blocked runs). +- **SC-002**: Tenant detail pages render without any external provider calls; verification-related external calls occur only after an explicit start action. +- **SC-003**: When a verification run is already active for the same target and type, repeated starts return a busy/deduped response in under 1 second. +- **SC-004**: All verification surfaces provide a canonical "View run" link, and support can use that single URL to review outcomes. diff --git a/specs/084-verification-surfaces-unification/tasks.md b/specs/084-verification-surfaces-unification/tasks.md new file mode 100644 index 0000000..44ba785 --- /dev/null +++ b/specs/084-verification-surfaces-unification/tasks.md @@ -0,0 +1,160 @@ +--- + +description: "Task list for Spec 084 implementation" +--- + +# Tasks: Verification Surfaces Unification + +**Input**: Design documents from `/specs/084-verification-surfaces-unification/` + +**Tests**: REQUIRED (Pest) — this feature changes runtime behavior. + +## Phase 1: Setup (Shared Infrastructure) + +**Purpose**: Establish a safe baseline and align on touched surfaces. + +- [X] T001 Capture baseline behavior by running existing verification tests in tests/Feature/Verification/VerificationStartDedupeTest.php and tests/Feature/ProviderConnections/ProviderOperationBlockedGuidanceSpec081Test.php +- [X] T002 Identify current tenant “Verify configuration” surface implementation in app/Filament/Resources/TenantResource/Pages/ViewTenant.php and document expected deltas in specs/084-verification-surfaces-unification/quickstart.md + +--- + +## Phase 2: Foundational (Blocking Prerequisites) + +**Purpose**: Shared primitives required by ALL user stories. + +**⚠️ CRITICAL**: No user story work should begin until this phase is complete. + +- [X] T003 Add schema-valid stub verification report for blocked provider.connection.check runs in app/Services/Providers/ProviderOperationStartGate.php (write context.verification_report after finalizeBlockedRun) +- [X] T004 [P] Add helper to build blocked/preflight verification checks payload in app/Support/Verification/ (e.g., new BlockedVerificationReportFactory.php used by ProviderOperationStartGate) +- [X] T005 Add a non-breaking tenant-default start helper in app/Services/Verification/StartVerification.php (keep existing explicit-connection API; delegate default-connection resolution to ProviderOperationStartGate) +- [X] T006 Ensure blocked stub report includes next steps links from context.next_steps and normalized reason_code from context.reason_code in app/Support/Verification/BlockedVerificationReportFactory.php +- [X] T007 Add Pest coverage for blocked stub report invariant in tests/Feature/Verification/ (new test file asserting completed blocked provider.connection.check has context.verification_report and it validates via VerificationReportSchema) +- [X] T027 Enforce tenant entitlement for tenant-associated runs in app/Policies/OperationRunPolicy.php and app/Filament/Pages/Operations/TenantlessOperationRunViewer.php (deny-as-not-found when workspace membership or tenant entitlement is missing) + +**Checkpoint**: A blocked provider.connection.check run always has a schema-valid stored report. + +--- + +## Phase 3: User Story 1 — Verify tenant configuration consistently (Priority: P1) 🎯 MVP + +**Goal**: Start verification from tenant detail view and render the latest stored report DB-only. + +**Independent Test**: Trigger the tenant verify action and confirm it creates/dedupes an OperationRun and the tenant embedded viewer renders from stored run context only. + +### Tests (write first) + +- [X] T008 [P] [US1] Update tests/Feature/ProviderConnections/ProviderOperationBlockedGuidanceSpec081Test.php to assert tenant verify start now creates a blocked OperationRun and includes a schema-valid verification_report stub +- [X] T009 [P] [US1] Add tenant verify happy-path test in tests/Feature/Filament/ (new test file calling ViewTenant action and asserting ProviderConnectionHealthCheckJob dispatch + canonical view-run link) +- [X] T010 [P] [US1] Add tenant embedded viewer DB-only test in tests/Feature/Filament/ (new test asserting the widget/view reads OperationRun.context only; no provider calls during render) +- [X] T032 [P] [US1] Add FR-007 regression test in tests/Feature/Filament/ asserting tenant view render path never triggers synchronous verification or permission inventory persistence + +### Implementation + +- [X] T011 [US1] Refactor “Verify configuration” action in app/Filament/Resources/TenantResource/Pages/ViewTenant.php to start/dedupe provider.connection.check via app/Services/Verification/StartVerification.php (no synchronous Graph work) +- [X] T012 [US1] Apply UI enforcement to the tenant verify action in app/Filament/Resources/TenantResource/Pages/ViewTenant.php using App\Support\Rbac\UiEnforcement with Capabilities::PROVIDER_RUN (visible-but-disabled; server still 403) +- [X] T013 [US1] Ensure tenant verify notifications include a canonical tenantless “View run” link using App\Support\OperationRunLinks::tenantlessView in app/Filament/Resources/TenantResource/Pages/ViewTenant.php +- [X] T033 [US1] Refactor tenant list “Verify configuration” action in app/Filament/Resources/TenantResource.php to the same unified provider.connection.check start path + canonical tenantless run links +- [X] T014 [P] [US1] Create a tenant verification viewer widget class in app/Filament/Widgets/Tenant/TenantVerificationReport.php (select latest provider.connection.check run for the current tenant) +- [X] T015 [P] [US1] Create widget view resources/views/filament/widgets/tenant/tenant-verification-report.blade.php with states: empty (Start verification CTA), in-progress, completed (render filament.components.verification-report-viewer) +- [X] T016 [US1] Register the tenant verification viewer widget on the tenant view page by updating app/Filament/Resources/TenantResource/Pages/ViewTenant.php getHeaderWidgets() to include TenantVerificationReport +- [X] T017 [US1] Implement the embedded viewer “Start verification” CTA to invoke the same unified start path (StartVerification) in app/Filament/Widgets/Tenant/TenantVerificationReport.php +- [X] T018 [US1] Ensure the embedded viewer “View run” link uses OperationRunLinks::tenantlessView in app/Filament/Widgets/Tenant/TenantVerificationReport.php and resources/views/filament/widgets/tenant/tenant-verification-report.blade.php + +**Checkpoint**: US1 works end-to-end using queued OperationRun and DB-only rendering. + +--- + +## Phase 4: User Story 2 — Onboarding verify access behaves identically (Priority: P2) + +**Goal**: Onboarding verification uses the same run type, dedupe/busy semantics, and viewer behavior. + +**Independent Test**: Start verification in onboarding twice and assert dedupe/busy behavior and canonical links match tenant surface. + +### Tests + +- [X] T019 [P] [US2] Update tests/Feature/Onboarding/OnboardingVerificationTest.php to assert busy/deduped notifications link to the canonical tenantless operations viewer route +- [X] T020 [P] [US2] Add regression test ensuring onboarding verification uses provider.connection.check and stores verification_report in tests/Feature/Onboarding/OnboardingVerificationTest.php + +### Implementation + +- [X] T021 [US2] Ensure app/Filament/Pages/Workspaces/ManagedTenantOnboardingWizard.php startVerification() uses the same unified provider.connection.check start mechanism (ProviderOperationStartGate) and remains enqueue-only +- [X] T022 [US2] Ensure onboarding “View run” links remain canonical via OperationRunLinks::tenantlessView (or equivalent tenantlessOperationRunUrl) in app/Filament/Pages/Workspaces/ManagedTenantOnboardingWizard.php +- [X] T023 [US2] Confirm onboarding verification report rendering uses App\Filament\Support\VerificationReportViewer only (no external calls) in app/Filament/Pages/Workspaces/ManagedTenantOnboardingWizard.php + +--- + +## Phase 5: User Story 3 — Use canonical run links everywhere (Priority: P3) + +**Goal**: Every verification surface links to the same tenantless run viewer route, and tenantless viewing enforces workspace + tenant entitlement (404 when missing). + +**Independent Test**: From both tenant and onboarding surfaces, follow “View run” and confirm it resolves to the same canonical route and is deny-as-not-found for non-entitled actors. + +### Tests + +- [X] T024 [P] [US3] Add canonical link assertions for tenant verify notifications in tests/Feature/Filament/ (update test from T009 to assert route name admin.operations.view) +- [X] T025 [P] [US3] Add canonical link assertions for onboarding notifications in tests/Feature/Onboarding/OnboardingVerificationTest.php +- [X] T026 [P] [US3] Add authorization test for tenantless run viewer requiring workspace membership + tenant entitlement (404 semantics) in tests/Feature/RunAuthorizationTenantIsolationTest.php + +### Implementation + +- [X] T028 [US3] Ensure all verification surfaces use OperationRunLinks::tenantlessView (or identical canonical route helper) in app/Filament/Resources/TenantResource/Pages/ViewTenant.php and app/Filament/Pages/Workspaces/ManagedTenantOnboardingWizard.php + +--- + +## Phase 6: Polish & Cross-Cutting Concerns + +**Purpose**: Formatting, stability, and quickstart validation. + +- [X] T034 Fix tenant page widgets to resolve tenant from record context (Admin panel) and add regression coverage for Recent operations + Start verification disabled state +- [X] T029 Run formatting on changed files using vendor/bin/sail bin pint --dirty (e.g., app/Filament/Resources/TenantResource/Pages/ViewTenant.php, app/Services/Providers/ProviderOperationStartGate.php, tests/Feature/Verification/*) +- [X] T030 Run targeted Pest suite for this feature using vendor/bin/sail artisan test --compact tests/Feature/Verification/ tests/Feature/ProviderConnections/ProviderOperationBlockedGuidanceSpec081Test.php tests/Feature/Onboarding/OnboardingVerificationTest.php tests/Feature/RunAuthorizationTenantIsolationTest.php +- [X] T031 Validate manual flows in specs/084-verification-surfaces-unification/quickstart.md and update it if any step text is now inaccurate + +--- + +## Dependencies & Execution Order + +- Phase 1 (Setup) → Phase 2 (Foundational) → user stories. +- User stories after Phase 2: + - **US1 (P1)** can proceed immediately after Phase 2. + - **US2 (P2)** can proceed after Phase 2 (independent of US1). + - **US3 (P3)** should run after US1 + US2 (to validate “every surface”). + +### Dependency Graph (stories) + +- Foundation → US1 +- Foundation → US2 +- US1 + US2 → US3 + +--- + +## Parallel Execution Examples + +### US1 parallelizable tasks + +- T008, T009, T010, and T032 can be developed in parallel (separate test files). +- T014 and T015 can be developed in parallel (widget class vs Blade view). + +### US2 parallelizable tasks + +- T019 and T020 can be developed in parallel within tests/Feature/Onboarding/. + +### US3 parallelizable tasks + +- T024, T025, T026 can be developed in parallel (different test targets). + +--- + +## Implementation Strategy + +### MVP First (US1) + +1. Complete Phase 1–2. +2. Implement US1 tests (T008–T010, T032) → ensure they fail. +3. Implement US1 code (T011–T018, T033) → ensure tests pass. +4. Run Phase 6 tasks to format + verify. + +### Incremental Delivery + +- Add US2 next to remove onboarding/tenant variance. +- Finish with US3 to unify canonical links + tenantless authorization guarantees across all surfaces. diff --git a/tests/Feature/Filament/RecentOperationsSummaryWidgetTest.php b/tests/Feature/Filament/RecentOperationsSummaryWidgetTest.php new file mode 100644 index 0000000..f3e4d13 --- /dev/null +++ b/tests/Feature/Filament/RecentOperationsSummaryWidgetTest.php @@ -0,0 +1,27 @@ +actingAs($user); + + OperationRun::factory()->create([ + 'workspace_id' => (int) $tenant->workspace_id, + 'tenant_id' => (int) $tenant->getKey(), + 'type' => 'provider.connection.check', + 'status' => 'completed', + 'outcome' => 'success', + ]); + + Livewire::actingAs($user) + ->test(RecentOperationsSummary::class, ['record' => $tenant]) + ->assertSee('Recent operations') + ->assertSee('Provider connection check') + ->assertDontSee('No operations yet.'); +}); diff --git a/tests/Feature/Filament/TenantSetupTest.php b/tests/Feature/Filament/TenantSetupTest.php index b631e9e..bcdcab9 100644 --- a/tests/Feature/Filament/TenantSetupTest.php +++ b/tests/Feature/Filament/TenantSetupTest.php @@ -2,57 +2,25 @@ use App\Filament\Resources\TenantResource\Pages\CreateTenant; use App\Filament\Resources\TenantResource\Pages\ViewTenant; +use App\Models\OperationRun; use App\Models\ProviderConnection; use App\Models\ProviderCredential; use App\Models\Tenant; use App\Models\TenantPermission; use App\Models\User; -use App\Services\Graph\GraphClientInterface; -use App\Services\Graph\GraphResponse; +use App\Support\OperationRunLinks; +use App\Support\Providers\ProviderReasonCodes; +use App\Support\Verification\VerificationReportSchema; use Filament\Facades\Filament; use Illuminate\Foundation\Testing\RefreshDatabase; +use Illuminate\Support\Facades\Queue; use Livewire\Livewire; uses(RefreshDatabase::class); -test('tenant can be created via filament and verified successfully', function () { - app()->bind(GraphClientInterface::class, fn () => new class implements GraphClientInterface - { - public function listPolicies(string $policyType, array $options = []): GraphResponse - { - return new GraphResponse(true, []); - } - - public function getPolicy(string $policyType, string $policyId, array $options = []): GraphResponse - { - return new GraphResponse(true, []); - } - - public function getOrganization(array $options = []): GraphResponse - { - return new GraphResponse(true, ['value' => [['id' => $options['tenant'] ?? 'tenant']]], 200); - } - - public function applyPolicy(string $policyType, string $policyId, array $payload, array $options = []): GraphResponse - { - return new GraphResponse(true, []); - } - - public function getServicePrincipalPermissions(array $options = []): GraphResponse - { - // Return all required permissions as granted - return new GraphResponse(true, [ - 'permissions' => collect(config('intune_permissions.permissions', [])) - ->pluck('key') - ->toArray(), - ]); - } - - public function request(string $method, string $path, array $options = []): GraphResponse - { - return new GraphResponse(true, []); - } - }); +test('tenant can be created via filament and verification start enqueues an operation run', function () { + Queue::fake(); + bindFailHardGraphClient(); $user = User::factory()->create(); $this->actingAs($user); @@ -99,56 +67,38 @@ public function request(string $method, string $path, array $options = []): Grap Livewire::test(ViewTenant::class, ['record' => $tenant->getRouteKey()]) ->callAction('verify'); - $tenant->refresh(); + $run = OperationRun::query() + ->where('tenant_id', (int) $tenant->getKey()) + ->where('type', 'provider.connection.check') + ->latest('id') + ->first(); - expect($tenant->app_status)->toBe('ok'); + expect($run)->not->toBeNull() + ->and($run?->status)->toBe('queued') + ->and($run?->outcome)->toBe('pending') + ->and($run?->context['provider_connection_id'] ?? null)->toBe((int) $connection->getKey()); - $this->assertDatabaseHas('audit_logs', [ + $notificationActionUrls = collect(session('filament.notifications', [])) + ->flatMap(static fn (array $notification): array => is_array($notification['actions'] ?? null) + ? $notification['actions'] + : []) + ->pluck('url') + ->filter(static fn (mixed $url): bool => is_string($url) && trim($url) !== '') + ->values() + ->all(); + + expect($notificationActionUrls)->toContain(OperationRunLinks::tenantlessView($run)); + + Queue::assertPushed(\App\Jobs\ProviderConnectionHealthCheckJob::class, 1); + + $this->assertDatabaseMissing('audit_logs', [ 'tenant_id' => $tenant->id, 'action' => 'tenant.config.verified', - 'status' => 'success', - ]); - - $this->assertDatabaseHas('tenant_permissions', [ - 'tenant_id' => $tenant->id, - 'status' => 'granted', ]); }); -test('verify configuration records error when graph fails', function () { - app()->bind(GraphClientInterface::class, fn () => new class implements GraphClientInterface - { - public function listPolicies(string $policyType, array $options = []): GraphResponse - { - return new GraphResponse(true, []); - } - - public function getPolicy(string $policyType, string $policyId, array $options = []): GraphResponse - { - return new GraphResponse(true, []); - } - - public function getOrganization(array $options = []): GraphResponse - { - return new GraphResponse(false, [], 401, ['auth failed']); - } - - public function applyPolicy(string $policyType, string $policyId, array $payload, array $options = []): GraphResponse - { - return new GraphResponse(true, []); - } - - public function getServicePrincipalPermissions(array $options = []): GraphResponse - { - // Return error for permissions check - return new GraphResponse(false, [], 403, ['Permission denied']); - } - - public function request(string $method, string $path, array $options = []): GraphResponse - { - return new GraphResponse(true, []); - } - }); +test('verify configuration creates a blocked run when default connection credentials are missing', function () { + Queue::fake(); $user = User::factory()->create(); @@ -168,35 +118,26 @@ public function request(string $method, string $path, array $options = []): Grap 'provider' => 'microsoft', 'entra_tenant_id' => (string) $tenant->tenant_id, 'is_default' => true, - 'status' => 'enabled', - ]); - - ProviderCredential::factory()->create([ - 'provider_connection_id' => (int) $connection->getKey(), - 'type' => 'client_secret', - 'payload' => [ - 'client_id' => 'client-id', - 'client_secret' => 'client-secret', - ], + 'status' => 'connected', ]); Livewire::test(ViewTenant::class, ['record' => $tenant->getRouteKey()]) ->callAction('verify'); - $tenant->refresh(); + $run = OperationRun::query() + ->where('tenant_id', (int) $tenant->getKey()) + ->where('type', 'provider.connection.check') + ->latest('id') + ->first(); - expect($tenant->app_status)->toBe('error'); + expect($run)->not->toBeNull() + ->and($run?->outcome)->toBe('blocked') + ->and($run?->context['provider_connection_id'] ?? null)->toBe((int) $connection->getKey()) + ->and($run?->context['reason_code'] ?? null)->toBe(ProviderReasonCodes::ProviderCredentialMissing); - $this->assertDatabaseHas('audit_logs', [ - 'tenant_id' => $tenant->id, - 'action' => 'tenant.config.verified', - 'status' => 'error', - ]); + expect(VerificationReportSchema::isValidReport($run?->context['verification_report'] ?? []))->toBeTrue(); - $this->assertDatabaseHas('tenant_permissions', [ - 'tenant_id' => $tenant->id, - 'status' => 'error', - ]); + Queue::assertNothingPushed(); }); test('tenant detail shows required permissions with statuses', function () { diff --git a/tests/Feature/Filament/TenantVerificationReportWidgetTest.php b/tests/Feature/Filament/TenantVerificationReportWidgetTest.php new file mode 100644 index 0000000..a6c3302 --- /dev/null +++ b/tests/Feature/Filament/TenantVerificationReportWidgetTest.php @@ -0,0 +1,244 @@ +actingAs($user); + + $tenant->makeCurrent(); + Filament::setTenant($tenant, true); + + $connection = ProviderConnection::factory()->create([ + 'tenant_id' => (int) $tenant->getKey(), + 'workspace_id' => (int) $tenant->workspace_id, + 'provider' => 'microsoft', + 'entra_tenant_id' => (string) $tenant->tenant_id, + 'is_default' => true, + 'status' => 'connected', + ]); + + ProviderCredential::factory()->create([ + 'provider_connection_id' => (int) $connection->getKey(), + 'type' => 'client_secret', + 'payload' => [ + 'client_id' => 'client-id', + 'client_secret' => 'client-secret', + ], + ]); + + Livewire::test(ViewTenant::class, ['record' => $tenant->getRouteKey()]) + ->callAction('verify'); + + $run = OperationRun::query() + ->where('tenant_id', (int) $tenant->getKey()) + ->where('type', 'provider.connection.check') + ->latest('id') + ->first(); + + expect($run)->not->toBeNull(); + + $notifications = collect(session('filament.notifications', [])); + expect($notifications)->not->toBeEmpty(); + + $last = $notifications->last(); + $actionUrls = collect($last['actions'] ?? [])->pluck('url')->filter()->values()->all(); + + expect($actionUrls)->toContain(OperationRunLinks::tenantlessView($run)); + + Queue::assertPushed(ProviderConnectionHealthCheckJob::class, function (ProviderConnectionHealthCheckJob $job) use ($run, $connection): bool { + return (int) $job->providerConnectionId === (int) $connection->getKey() + && (int) ($job->operationRun?->getKey() ?? 0) === (int) ($run?->getKey() ?? 0); + }); +}); + +it('renders the tenant verification widget from stored run context only', function (): void { + [$user, $tenant] = createUserWithTenant(role: 'readonly'); + + Filament::setTenant($tenant, true); + + $report = VerificationReportWriter::build('provider.connection.check', [ + [ + 'key' => 'provider.connection.check', + 'title' => 'Provider connection preflight', + 'status' => 'fail', + 'severity' => 'critical', + 'blocking' => true, + 'reason_code' => 'provider_connection_missing', + 'message' => 'No provider connection configured.', + 'evidence' => [], + 'next_steps' => [], + ], + ]); + + OperationRun::factory()->create([ + 'workspace_id' => (int) $tenant->workspace_id, + 'tenant_id' => (int) $tenant->getKey(), + 'type' => 'provider.connection.check', + 'status' => 'completed', + 'outcome' => 'blocked', + 'context' => [ + 'target_scope' => [ + 'entra_tenant_id' => (string) $tenant->tenant_id, + ], + 'verification_report' => $report, + ], + ]); + + bindFailHardGraphClient(); + + assertNoOutboundHttp(function () use ($user): void { + Livewire::actingAs($user) + ->test(TenantVerificationReport::class) + ->assertSee('Provider connection preflight') + ->assertSee('Read-only:') + ->assertSee('Insufficient permission — ask a tenant Owner.'); + }); +}); + +it('renders tenant detail without invoking synchronous verification or permission persistence services', function (): void { + [$user, $tenant] = createUserWithTenant(role: 'owner'); + + Filament::setTenant($tenant, true); + + $this->mock(TenantConfigService::class, function ($mock): void { + $mock->shouldReceive('testConnectivity')->never(); + }); + + $this->mock(TenantPermissionService::class, function ($mock): void { + $mock->shouldReceive('compare')->never(); + }); + + $this->mock(RbacHealthService::class, function ($mock): void { + $mock->shouldReceive('check')->never(); + }); + + bindFailHardGraphClient(); + + assertNoOutboundHttp(function () use ($user, $tenant): void { + $this->actingAs($user) + ->get(route('filament.admin.resources.tenants.view', array_merge( + filamentTenantRouteParams($tenant), + ['record' => $tenant] + ))) + ->assertOk() + ->assertSee('Verification report'); + }); +}); + +it('starts verification from the embedded widget CTA and uses canonical view-run links', function (): void { + Queue::fake(); + + [$user, $tenant] = createUserWithTenant(role: 'operator'); + $this->actingAs($user); + + $connection = ProviderConnection::factory()->create([ + 'tenant_id' => (int) $tenant->getKey(), + 'workspace_id' => (int) $tenant->workspace_id, + 'provider' => 'microsoft', + 'entra_tenant_id' => (string) $tenant->tenant_id, + 'is_default' => true, + 'status' => 'connected', + ]); + + ProviderCredential::factory()->create([ + 'provider_connection_id' => (int) $connection->getKey(), + 'type' => 'client_secret', + 'payload' => [ + 'client_id' => 'client-id', + 'client_secret' => 'client-secret', + ], + ]); + + Livewire::test(TenantVerificationReport::class, ['record' => $tenant]) + ->assertSee('No verification run has been started yet.') + ->call('startVerification'); + + $run = OperationRun::query() + ->where('tenant_id', (int) $tenant->getKey()) + ->where('type', 'provider.connection.check') + ->latest('id') + ->first(); + + expect($run)->not->toBeNull(); + + $notifications = collect(session('filament.notifications', [])); + expect($notifications)->not->toBeEmpty(); + + $last = $notifications->last(); + $actionUrls = collect($last['actions'] ?? [])->pluck('url')->filter()->values()->all(); + + expect($actionUrls)->toContain(OperationRunLinks::tenantlessView($run)); + + Queue::assertPushed(ProviderConnectionHealthCheckJob::class, 1); +}); + +it('starts tenant verification from the tenant list row action via the unified run path', function (): void { + Queue::fake(); + + [$user, $tenant] = createUserWithTenant(role: 'operator'); + $this->actingAs($user); + + Filament::setTenant($tenant, true); + + $connection = ProviderConnection::factory()->create([ + 'tenant_id' => (int) $tenant->getKey(), + 'workspace_id' => (int) $tenant->workspace_id, + 'provider' => 'microsoft', + 'entra_tenant_id' => (string) $tenant->tenant_id, + 'is_default' => true, + 'status' => 'connected', + ]); + + ProviderCredential::factory()->create([ + 'provider_connection_id' => (int) $connection->getKey(), + 'type' => 'client_secret', + 'payload' => [ + 'client_id' => 'client-id', + 'client_secret' => 'client-secret', + ], + ]); + + Livewire::test(ListTenants::class) + ->callTableAction('verify', $tenant); + + $run = OperationRun::query() + ->where('tenant_id', (int) $tenant->getKey()) + ->where('type', 'provider.connection.check') + ->latest('id') + ->first(); + + expect($run)->not->toBeNull() + ->and($run?->context['surface']['kind'] ?? null)->toBe('tenant_list_row'); + + $notificationActionUrls = collect(session('filament.notifications', [])) + ->flatMap(static fn (array $notification): array => is_array($notification['actions'] ?? null) + ? $notification['actions'] + : []) + ->pluck('url') + ->filter(static fn (mixed $url): bool => is_string($url) && trim($url) !== '') + ->values() + ->all(); + + expect($notificationActionUrls)->toContain(OperationRunLinks::tenantlessView($run)); + + Queue::assertPushed(ProviderConnectionHealthCheckJob::class, 1); +}); diff --git a/tests/Feature/Onboarding/OnboardingVerificationTest.php b/tests/Feature/Onboarding/OnboardingVerificationTest.php index b040251..06bc470 100644 --- a/tests/Feature/Onboarding/OnboardingVerificationTest.php +++ b/tests/Feature/Onboarding/OnboardingVerificationTest.php @@ -3,14 +3,15 @@ declare(strict_types=1); use App\Filament\Pages\Workspaces\ManagedTenantOnboardingWizard; -use App\Jobs\ProviderConnectionHealthCheckJob; use App\Models\OperationRun; +use App\Models\ProviderConnection; use App\Models\Tenant; use App\Models\TenantOnboardingSession; use App\Models\User; -use App\Models\ProviderConnection; use App\Models\Workspace; use App\Models\WorkspaceMembership; +use App\Support\OperationRunLinks; +use App\Support\Verification\VerificationReportSchema; use App\Support\Verification\VerificationReportWriter; use App\Support\Workspaces\WorkspaceContext; use Illuminate\Support\Facades\Queue; @@ -47,13 +48,15 @@ 'is_default' => true, ]); - $component->call('startVerification'); - $component->call('startVerification'); - - Queue::assertPushed(ProviderConnectionHealthCheckJob::class, 1); - $tenant = Tenant::query()->where('tenant_id', $entraTenantId)->firstOrFail(); + ProviderConnection::query() + ->where('tenant_id', (int) $tenant->getKey()) + ->update(['status' => 'connected']); + + $component->call('startVerification'); + $component->call('startVerification'); + expect(OperationRun::query() ->where('tenant_id', (int) $tenant->getKey()) ->where('type', 'provider.connection.check') @@ -64,6 +67,17 @@ ->where('type', 'provider.connection.check') ->value('id'); + $notificationActionUrls = collect(session('filament.notifications', [])) + ->flatMap(static fn (array $notification): array => is_array($notification['actions'] ?? null) + ? $notification['actions'] + : []) + ->pluck('url') + ->filter(static fn (mixed $url): bool => is_string($url) && trim($url) !== '') + ->values() + ->all(); + + expect($notificationActionUrls)->toContain(OperationRunLinks::tenantlessView($runId)); + $session = TenantOnboardingSession::query() ->where('workspace_id', (int) $workspace->getKey()) ->where('entra_tenant_id', $entraTenantId) @@ -73,6 +87,73 @@ expect($session->state['verification_operation_run_id'] ?? null)->toBe($runId); }); +it('stores a blocked verification report and canonical link when onboarding verification cannot proceed', function (): void { + Queue::fake(); + + $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 = '72727272-7272-7272-7272-727272727272'; + + $component = Livewire::actingAs($user)->test(ManagedTenantOnboardingWizard::class); + + $component->call('identifyManagedTenant', [ + 'entra_tenant_id' => $entraTenantId, + 'environment' => 'prod', + 'name' => 'Blocked Tenant', + ]); + + $tenant = Tenant::query()->where('tenant_id', $entraTenantId)->firstOrFail(); + + $connection = ProviderConnection::factory()->create([ + 'workspace_id' => (int) $workspace->getKey(), + 'tenant_id' => (int) $tenant->getKey(), + 'provider' => 'microsoft', + 'entra_tenant_id' => $entraTenantId, + 'display_name' => 'Blocked connection', + 'is_default' => true, + 'status' => 'connected', + ]); + + $component->call('selectProviderConnection', (int) $connection->getKey()); + $component->call('startVerification'); + + $run = OperationRun::query() + ->where('tenant_id', (int) $tenant->getKey()) + ->where('type', 'provider.connection.check') + ->latest('id') + ->first(); + + expect($run)->not->toBeNull() + ->and($run?->outcome)->toBe('blocked'); + + $report = $run?->context['verification_report'] ?? null; + + expect($report)->toBeArray(); + expect(VerificationReportSchema::isValidReport($report))->toBeTrue(); + + $notificationActionUrls = collect(session('filament.notifications', [])) + ->flatMap(static fn (array $notification): array => is_array($notification['actions'] ?? null) + ? $notification['actions'] + : []) + ->pluck('url') + ->filter(static fn (mixed $url): bool => is_string($url) && trim($url) !== '') + ->values() + ->all(); + + expect($notificationActionUrls)->toContain(OperationRunLinks::tenantlessView((int) $run?->getKey())); + + Queue::assertNothingPushed(); +}); + it('renders stored verification findings in the wizard report section', function (): void { $workspace = Workspace::factory()->create(); $user = User::factory()->create(); diff --git a/tests/Feature/ProviderConnections/ProviderConnectionCutoverSpec081Test.php b/tests/Feature/ProviderConnections/ProviderConnectionCutoverSpec081Test.php index 259e157..cbdc0a4 100644 --- a/tests/Feature/ProviderConnections/ProviderConnectionCutoverSpec081Test.php +++ b/tests/Feature/ProviderConnections/ProviderConnectionCutoverSpec081Test.php @@ -9,6 +9,7 @@ use App\Support\OperationRunOutcome; use App\Support\OperationRunStatus; use App\Support\Providers\ProviderReasonCodes; +use App\Support\Verification\VerificationReportSchema; use Illuminate\Support\Facades\DB; it('Spec081 blocks provider operation starts when default connection is missing', function (): void { @@ -29,7 +30,8 @@ ->and($result->run->status)->toBe(OperationRunStatus::Completed->value) ->and($result->run->outcome)->toBe(OperationRunOutcome::Blocked->value) ->and($result->run->context['reason_code'] ?? null)->toBe(ProviderReasonCodes::ProviderConnectionMissing) - ->and($result->run->context['next_steps'] ?? [])->not->toBeEmpty(); + ->and($result->run->context['next_steps'] ?? [])->not->toBeEmpty() + ->and(VerificationReportSchema::isValidReport($result->run->context['verification_report'] ?? []))->toBeTrue(); }); it('Spec081 blocks provider operation starts when default connection has no credential', function (): void { @@ -52,7 +54,8 @@ expect($result->status)->toBe('blocked') ->and($result->run->context['provider_connection_id'] ?? null)->toBe((int) $connection->getKey()) ->and($result->run->context['reason_code'] ?? null)->toBe(ProviderReasonCodes::ProviderCredentialMissing) - ->and($result->run->outcome)->toBe(OperationRunOutcome::Blocked->value); + ->and($result->run->outcome)->toBe(OperationRunOutcome::Blocked->value) + ->and(VerificationReportSchema::isValidReport($result->run->context['verification_report'] ?? []))->toBeTrue(); }); it('Spec081 returns deterministic invalid reason when data corruption creates multiple defaults', function (): void { @@ -90,5 +93,6 @@ expect($result->status)->toBe('blocked') ->and($result->run->context['reason_code'] ?? null)->toBe(ProviderReasonCodes::ProviderConnectionInvalid) ->and($result->run->context['reason_code_extension'] ?? null)->toBe('ext.multiple_defaults_detected') - ->and($result->run->outcome)->toBe(OperationRunOutcome::Blocked->value); + ->and($result->run->outcome)->toBe(OperationRunOutcome::Blocked->value) + ->and(VerificationReportSchema::isValidReport($result->run->context['verification_report'] ?? []))->toBeTrue(); }); diff --git a/tests/Feature/ProviderConnections/ProviderOperationBlockedGuidanceSpec081Test.php b/tests/Feature/ProviderConnections/ProviderOperationBlockedGuidanceSpec081Test.php index c538f02..e72a8d6 100644 --- a/tests/Feature/ProviderConnections/ProviderOperationBlockedGuidanceSpec081Test.php +++ b/tests/Feature/ProviderConnections/ProviderOperationBlockedGuidanceSpec081Test.php @@ -8,6 +8,7 @@ use App\Models\OperationRun; use App\Models\ProviderConnection; use App\Support\Providers\ProviderReasonCodes; +use App\Support\Verification\VerificationReportSchema; use Filament\Facades\Filament; use Illuminate\Support\Facades\Queue; use Livewire\Livewire; @@ -41,6 +42,10 @@ ->and($run?->outcome)->toBe('blocked') ->and($run?->context['reason_code'] ?? null)->toBe(ProviderReasonCodes::ProviderCredentialMissing); + $report = $run?->context['verification_report'] ?? null; + expect($report)->toBeArray(); + expect(VerificationReportSchema::isValidReport($report))->toBeTrue(); + $notifications = collect(session('filament.notifications', [])); expect($notifications)->not->toBeEmpty(); @@ -64,6 +69,20 @@ Livewire::test(ViewTenant::class, ['record' => $tenant->getRouteKey()]) ->callAction('verify'); + $run = OperationRun::query() + ->where('tenant_id', (int) $tenant->getKey()) + ->where('type', 'provider.connection.check') + ->latest('id') + ->first(); + + expect($run)->not->toBeNull() + ->and($run?->outcome)->toBe('blocked') + ->and($run?->context['reason_code'] ?? null)->toBe(ProviderReasonCodes::ProviderConnectionMissing); + + $report = $run?->context['verification_report'] ?? null; + expect($report)->toBeArray(); + expect(VerificationReportSchema::isValidReport($report))->toBeTrue(); + $notifications = collect(session('filament.notifications', [])); expect($notifications)->not->toBeEmpty(); diff --git a/tests/Feature/RunAuthorizationTenantIsolationTest.php b/tests/Feature/RunAuthorizationTenantIsolationTest.php index e35cf6e..6129b80 100644 --- a/tests/Feature/RunAuthorizationTenantIsolationTest.php +++ b/tests/Feature/RunAuthorizationTenantIsolationTest.php @@ -109,3 +109,32 @@ ->assertOk() ->assertSee('Operation run'); }); + +test('tenant-associated run viewer requires tenant entitlement even for workspace members', function (): void { + $workspace = Workspace::factory()->create(); + $tenant = Tenant::factory()->create([ + 'workspace_id' => (int) $workspace->getKey(), + 'status' => 'active', + ]); + + $run = OperationRun::factory()->create([ + 'tenant_id' => (int) $tenant->getKey(), + 'workspace_id' => (int) $workspace->getKey(), + 'type' => 'provider.connection.check', + 'status' => 'queued', + 'outcome' => 'pending', + ]); + + $user = User::factory()->create(); + + WorkspaceMembership::factory()->create([ + 'workspace_id' => (int) $workspace->getKey(), + 'user_id' => (int) $user->getKey(), + 'role' => 'owner', + ]); + + $this->actingAs($user) + ->withSession([WorkspaceContext::SESSION_KEY => (int) $workspace->getKey()]) + ->get(route('admin.operations.view', ['run' => (int) $run->getKey()])) + ->assertNotFound(); +}); diff --git a/tests/Feature/Verification/BlockedVerificationReportStubTest.php b/tests/Feature/Verification/BlockedVerificationReportStubTest.php new file mode 100644 index 0000000..855bb1f --- /dev/null +++ b/tests/Feature/Verification/BlockedVerificationReportStubTest.php @@ -0,0 +1,43 @@ +create(); + + $result = app(ProviderOperationStartGate::class)->start( + tenant: $tenant, + connection: null, + operationType: 'provider.connection.check', + dispatcher: static function (): void {}, + ); + + /** @var OperationRun $run */ + $run = $result->run->refresh(); + $context = is_array($run->context ?? null) ? $run->context : []; + + $report = $context['verification_report'] ?? null; + + expect($result->status)->toBe('blocked') + ->and($run->status)->toBe(OperationRunStatus::Completed->value) + ->and($run->outcome)->toBe(OperationRunOutcome::Blocked->value) + ->and($report)->toBeArray(); + + /** @var array $report */ + expect(VerificationReportSchema::isValidReport($report))->toBeTrue(); + + $checks = $report['checks'] ?? null; + $checks = is_array($checks) ? $checks : []; + + expect($checks)->toHaveCount(1) + ->and($checks[0]['key'] ?? null)->toBe('provider.connection.check') + ->and($checks[0]['reason_code'] ?? null)->toBe($context['reason_code'] ?? null) + ->and($checks[0]['next_steps'] ?? null)->toBe($context['next_steps'] ?? null); +}); diff --git a/tests/Feature/Verification/VerificationStartDedupeTest.php b/tests/Feature/Verification/VerificationStartDedupeTest.php index fedf043..c1837f9 100644 --- a/tests/Feature/Verification/VerificationStartDedupeTest.php +++ b/tests/Feature/Verification/VerificationStartDedupeTest.php @@ -56,3 +56,49 @@ Queue::assertPushed(ProviderConnectionHealthCheckJob::class, 1); }); + +it('dedupes tenant-default verification starts while a run is active', function (): void { + Queue::fake(); + + [$user, $tenant] = createUserWithTenant(role: 'operator'); + $this->actingAs($user); + + $tenant->makeCurrent(); + Filament::setTenant($tenant, true); + + $connection = ProviderConnection::factory()->create([ + 'tenant_id' => $tenant->getKey(), + 'provider' => 'microsoft', + 'entra_tenant_id' => fake()->uuid(), + 'status' => 'connected', + 'is_default' => true, + ]); + ProviderCredential::factory()->create([ + 'provider_connection_id' => (int) $connection->getKey(), + ]); + + $starter = app(StartVerification::class); + + $first = $starter->providerConnectionCheckForTenant( + tenant: $tenant, + initiator: $user, + extraContext: ['surface' => ['kind' => 'tenant_view_header']], + ); + + $second = $starter->providerConnectionCheckForTenant( + tenant: $tenant, + initiator: $user, + extraContext: ['surface' => ['kind' => 'tenant_view_header']], + ); + + expect($first->run->getKey())->toBe($second->run->getKey()); + expect($first->status)->toBe('started'); + expect($second->status)->toBe('deduped'); + + expect(OperationRun::query() + ->where('tenant_id', $tenant->getKey()) + ->where('type', 'provider.connection.check') + ->count())->toBe(1); + + Queue::assertPushed(ProviderConnectionHealthCheckJob::class, 1); +}); diff --git a/tests/Unit/Providers/ProviderOperationStartGateTest.php b/tests/Unit/Providers/ProviderOperationStartGateTest.php index 003a2ce..cc8f391 100644 --- a/tests/Unit/Providers/ProviderOperationStartGateTest.php +++ b/tests/Unit/Providers/ProviderOperationStartGateTest.php @@ -8,6 +8,7 @@ use App\Support\OperationRunOutcome; use App\Support\OperationRunStatus; use App\Support\Providers\ProviderReasonCodes; +use App\Support\Verification\VerificationReportSchema; use Illuminate\Foundation\Testing\RefreshDatabase; uses(RefreshDatabase::class); @@ -153,4 +154,6 @@ expect($result->run->outcome)->toBe(OperationRunOutcome::Blocked->value); expect($result->run->context['reason_code'] ?? null)->toBe(ProviderReasonCodes::ProviderConnectionMissing); expect($result->run->context['next_steps'] ?? [])->not->toBeEmpty(); + expect($result->run->context['verification_report'] ?? null)->toBeArray(); + expect(VerificationReportSchema::isValidReport($result->run->context['verification_report'] ?? []))->toBeTrue(); });