diff --git a/.github/agents/copilot-instructions.md b/.github/agents/copilot-instructions.md index 1a9df86..0fb9ff8 100644 --- a/.github/agents/copilot-instructions.md +++ b/.github/agents/copilot-instructions.md @@ -5,6 +5,7 @@ # TenantAtlas Development Guidelines ## Active Technologies - PHP 8.4.15 + Laravel 12, Filament v4, Livewire v3 (feat/005-bulk-operations) - PostgreSQL (app), SQLite in-memory (tests) (feat/005-bulk-operations) +- PostgreSQL (Sail locally) (feat/032-backup-scheduling-mvp) - PHP 8.4.15 (feat/005-bulk-operations) @@ -24,6 +25,7 @@ ## Code Style PHP 8.4.15: Follow standard conventions ## Recent Changes +- feat/032-backup-scheduling-mvp: Added PHP 8.4.15 + Laravel 12, Filament v4, Livewire v3 - feat/005-bulk-operations: Added PHP 8.4.15 + Laravel 12, Filament v4, Livewire v3 - feat/005-bulk-operations: Added PHP 8.4.15 diff --git a/.gitignore b/.gitignore index 1b59610..766ffe9 100644 --- a/.gitignore +++ b/.gitignore @@ -13,6 +13,9 @@ /.zed /auth.json /node_modules +dist/ +build/ +coverage/ /public/build /public/hot /public/storage @@ -22,4 +25,6 @@ Homestead.json Homestead.yaml Thumbs.db -/references \ No newline at end of file +/references +*.tmp +*.swp diff --git a/.specify/memory/constitution.md b/.specify/memory/constitution.md index a4670ff..8670192 100644 --- a/.specify/memory/constitution.md +++ b/.specify/memory/constitution.md @@ -1,50 +1,35 @@ -# [PROJECT_NAME] Constitution - +# TenantPilot Constitution ## Core Principles -### [PRINCIPLE_1_NAME] - -[PRINCIPLE_1_DESCRIPTION] - +### Safety-First Restore +- Any destructive action MUST support preview/dry-run, explicit confirmation, and a clear pre-execution summary. +- High-risk policy types default to `preview-only` restore unless explicitly enabled by a feature spec + tests + checklist. +- Restore must be defensive: validate inputs, detect conflicts, allow selective restore, and record outcomes per item. -### [PRINCIPLE_2_NAME] - -[PRINCIPLE_2_DESCRIPTION] - +### Auditability & Tenant Isolation +- Every operation is tenant-scoped and MUST write an audit log entry (no secrets, no tokens). +- Snapshots are immutable JSONB and MUST remain reproducible (who/when/what/source tenant). -### [PRINCIPLE_3_NAME] - -[PRINCIPLE_3_DESCRIPTION] - +### Graph Abstraction & Contracts +- All Microsoft Graph calls MUST go through `GraphClientInterface`. +- Contract assumptions are config-driven (`config/graph_contracts.php`); do not hardcode endpoints in feature code. +- Unknown/missing policy types MUST fail safe (preview-only / no Graph calls) rather than calling `deviceManagement/{type}`. -### [PRINCIPLE_4_NAME] - -[PRINCIPLE_4_DESCRIPTION] - +### Least Privilege +- Prefer least-privilege roles/scopes; surface warnings when higher privileges are selected. +- Never store secrets in code/config; never log credentials or tokens. -### [PRINCIPLE_5_NAME] - -[PRINCIPLE_5_DESCRIPTION] - +### Spec-First Workflow +- For any feature that changes runtime behavior, include or update `specs/-/` with `spec.md`, `plan.md`, `tasks.md`, and `checklists/requirements.md`. +- New work branches from `dev` using `feat/-` (spec + code in the same PR). -## [SECTION_2_NAME] - - -[SECTION_2_CONTENT] - - -## [SECTION_3_NAME] - - -[SECTION_3_CONTENT] - +## Quality Gates +- Changes MUST be programmatically tested (Pest) and run via targeted `php artisan test ...`. +- Run `./vendor/bin/pint --dirty` before finalizing. ## Governance - +- This constitution applies across the repo. Feature specs may add stricter constraints but not weaker ones. +- Restore semantics changes require: spec update, checklist update, and tests proving safety. -[GOVERNANCE_RULES] - - -**Version**: [CONSTITUTION_VERSION] | **Ratified**: [RATIFICATION_DATE] | **Last Amended**: [LAST_AMENDED_DATE] - +**Version**: 1.0.0 | **Ratified**: 2026-01-03 | **Last Amended**: 2026-01-03 diff --git a/.specify/plan.md b/.specify/plan.md index d5d5e5b..eb4bfe3 100644 --- a/.specify/plan.md +++ b/.specify/plan.md @@ -1,16 +1,16 @@ # Implementation Plan: TenantPilot v1 -**Branch**: `tenantpilot-v1` -**Date**: 2025-12-12 -**Spec Source**: `.specify/spec.md` (scope/restore matrix unchanged) +**Branch**: `dev` +**Date**: 2026-01-03 +**Spec Source**: `.specify/spec.md` (scope/restore matrix is config-driven) ## Summary -TenantPilot v1 already delivers tenant-scoped Intune inventory, immutable backups, version history with diffs, defensive restore flows, tenant setup, permissions/health, settings normalization/display, and Highlander enforcement. Remaining priority work is the delegated Intune RBAC onboarding wizard (US7) and afterwards the Graph Contract Registry & Drift Guard (US8). All Graph calls stay behind the abstraction with audit logging; snapshots remain JSONB with safety gates (preview-only for high-risk types). +TenantPilot v1 delivers tenant-scoped Intune inventory, immutable backups, version history with diffs, defensive restore flows, tenant setup, permissions/health, settings normalization/display, Highlander enforcement, the delegated RBAC onboarding wizard (US7), and the Graph Contract Registry & Drift Guard (US8). All Graph calls stay behind the abstraction with audit logging; snapshots remain JSONB with safety gates (preview-only for high-risk types). ## Status Snapshot (tasks.md is source of truth) -- **Done**: US1 inventory, US2 backups, US3 versions/diffs, US4 restore preview/exec, scope config, soft-deletes/housekeeping, Highlander single current tenant, tenant setup & verify (US6), permissions/health overview (US6), table ActionGroup UX, settings normalization/display (US1b), Dokploy/Sail runbooks. -- **Next up**: **US7** Intune RBAC onboarding wizard (delegated, synchronous Filament flow). -- **Upcoming**: **US8** Graph Contract Registry & Drift Guard (contract registry, type-family handling, verification command, fallback strategies). +- **Done**: Phases 1–15 (US1–US8, Settings Catalog hydration/display, restore rerun, Highlander, permissions/health, housekeeping/UX, ops). +- **Open**: T167 (optional) CLI/Job for CHECK/REPORT only (no grant). +- **Next up**: Feature 018 (Driver Updates / WUfB add-on) in `specs/018-driver-updates-wufb/`. ## Technical Baseline - Laravel 12, Filament 4, PHP 8.4; Sail-first with PostgreSQL. @@ -28,10 +28,12 @@ ## Completed Workstreams (no new action needed) - **US6 Tenant Setup & Highlander (Phases 8 & 12)**: Tenant CRUD/verify, INTUNE_TENANT_ID override, `is_current` unique enforcement, “Make current” action, block deactivated tenants. - **US6 Permissions/Health (Phase 9)**: Required permissions list, compare/check service, Verify action updates status and audit, permissions panel in Tenant detail. - **US1b Settings Display (Phase 13)**: PolicyNormalizer + SnapshotValidator, warnings for malformed snapshots, normalized settings and pretty JSON on policy/version detail, list badges, README section. +- **US7 RBAC Wizard (Phase 14)**: Delegated, synchronous onboarding wizard with post-verify canary checks and audit trail. +- **US8 Graph Contracts & Drift Guard (Phase 15)**: Config-driven contract registry, type-family handling, capability downgrade fallbacks, and a drift-check command. - **Housekeeping/UX (Phases 10–12)**: Soft/force deletes for tenants/backups/versions/restore runs with guards; table actions in ActionGroup per UX guideline. - **Ops (Phase 7)**: Sail runbook and Dokploy staging→prod guidance captured. -## Execution Plan: US7 Intune RBAC Onboarding Wizard (Phase 14) +## Completed: US7 Intune RBAC Onboarding Wizard (Phase 14) - Objectives: deliver delegated, tenant-scoped wizard that safely converges the Intune RBAC state for the configured service principal; fully audited, idempotent, least-privilege by default. - Scope alignment: FR-023–FR-030, constitution (Safety-First, Auditability, Tenant-Aware, Graph Abstraction). No secret/token persistence; delegated tokens stay request-local and are not stored in DB/cache. @@ -56,7 +58,7 @@ ## Execution Plan: US7 Intune RBAC Onboarding Wizard (Phase 14) - Health integration: Verify reflects RBAC status and prompts to run wizard when missing. - Deployment/ops: no new env vars; ensure migrations for tenant RBAC columns are applied; run targeted tests `php artisan test tests/Unit/RbacOnboardingServiceTest.php tests/Feature/Filament/TenantRbacWizardTest.php`; Pint on touched files. -## Upcoming: US8 Graph Contract Registry & Drift Guard (Phase 15) +## Completed: US8 Graph Contract Registry & Drift Guard (Phase 15) - Objectives: centralize Graph contract assumptions per supported type/endpoint and provide drift detection + safe fallbacks so preview/restore remain stable on Graph shape/capability changes. - Scope alignment: FR-031–FR-034 (spec), constitution (Safety-First, Auditability, Graph Abstraction, Tenant-Aware). @@ -74,7 +76,7 @@ ## Upcoming: US8 Graph Contract Registry & Drift Guard (Phase 15) - Testing outline: unit for registry lookups/type-family matching/fallback selection; integration/Pest to simulate capability errors and ensure downgrade path + correct routing for derived types. ## Testing & Quality Gates -- Continue using targeted Pest runs per change set; add/extend tests for US7 wizard now, and for US8 contracts when implemented. +- Continue using targeted Pest runs per change set; add/extend tests when RBAC/contract behavior changes. - Run Pint on touched files before finalizing. - Maintain tenant isolation, audit logging, and restore safety gates; validate snapshot shape and type-family compatibility prior to restore execution. @@ -83,6 +85,6 @@ ### Restore Safety Gate - Restore preview MAY still render details + warnings for out-of-family snapshots, but MUST NOT offer an apply action. ## Coordination -- Update `.specify/tasks.md` to reflect progress on US7 wizard and future US8 contract tasks; no new entities or scope changes introduced here. +- Keep `.specify/tasks.md` and per-feature specs under `specs/` aligned with implementation changes. - Stage validation required before production for any migration or restore-impacting change. -- Keep Graph integration behind abstraction; no secrets in logs; follow existing UX patterns (ActionGroup, warnings for risky ops). \ No newline at end of file +- Keep Graph integration behind abstraction; no secrets in logs; follow existing UX patterns (ActionGroup, warnings for risky ops). diff --git a/.specify/spec.md b/.specify/spec.md index 6ba9f44..227841a 100644 --- a/.specify/spec.md +++ b/.specify/spec.md @@ -1,20 +1,50 @@ # Feature Specification: TenantPilot v1 -**Feature Branch**: `tenantpilot-v1` +**Feature Branch**: `dev` **Created**: 2025-12-10 -**Status**: Draft +**Status**: Active +**Last Updated**: 2026-01-03 **Input**: TenantPilot v1 scope covering Intune configuration inventory (config, compliance, scripts, apps, conditional access, endpoint security, enrollment/autopilot, RBAC), backup, version history, and defensive restore for Intune administrators. ## Scope ```yaml scope: - description: "v1 muss folgende Intune-Objekttypen inventarisieren, sichern und – je nach Risikoklasse – wiederherstellen können." + description: "v1 muss folgende Intune-Objekttypen inventarisieren, sichern und – je nach Risikoklasse – wiederherstellen können. Single Source of Truth: config/tenantpilot.php + config/graph_contracts.php." supported_types: - key: deviceConfiguration name: "Device Configuration" graph_resource: "deviceManagement/deviceConfigurations" - notes: "Inklusive Custom OMA-URI, Administrative Templates und Settings Catalog." + filter: "not isof('microsoft.graph.windowsUpdateForBusinessConfiguration')" + notes: "Standard Device Config inkl. Custom OMA-URI; excludes WUfB Update Rings." + + - key: groupPolicyConfiguration + name: "Administrative Templates" + graph_resource: "deviceManagement/groupPolicyConfigurations" + notes: "Administrative Templates (Group Policy)." + + - key: settingsCatalogPolicy + name: "Settings Catalog Policy" + graph_resource: "deviceManagement/configurationPolicies" + notes: "Settings Catalog policies; settings are hydrated from the /settings subresource." + + - key: windowsUpdateRing + name: "Software Update Ring" + graph_resource: "deviceManagement/deviceConfigurations" + filter: "isof('microsoft.graph.windowsUpdateForBusinessConfiguration')" + notes: "Windows Update for Business (WUfB) update rings." + + - key: windowsFeatureUpdateProfile + name: "Feature Updates (Windows)" + graph_resource: "deviceManagement/windowsFeatureUpdateProfiles" + + - key: windowsQualityUpdateProfile + name: "Quality Updates (Windows)" + graph_resource: "deviceManagement/windowsQualityUpdateProfiles" + + - key: windowsDriverUpdateProfile + name: "Driver Updates (Windows)" + graph_resource: "deviceManagement/windowsDriverUpdateProfiles" - key: deviceCompliancePolicy name: "Device Compliance" @@ -25,6 +55,16 @@ ## Scope graph_resource: "deviceAppManagement/managedAppPolicies" notes: "iOS und Android Managed App Protection." + - key: mamAppConfiguration + name: "App Configuration (MAM)" + graph_resource: "deviceAppManagement/targetedManagedAppConfigurations" + notes: "App configuration targeting managed apps (MAM)." + + - key: managedDeviceAppConfiguration + name: "App Configuration (Device)" + graph_resource: "deviceAppManagement/mobileAppConfigurations" + notes: "Managed device app configuration profiles." + - key: conditionalAccessPolicy name: "Conditional Access" graph_resource: "identity/conditionalAccess/policies" @@ -35,6 +75,14 @@ ## Scope graph_resource: "deviceManagement/deviceManagementScripts" notes: "scriptContent wird beim Backup base64-decoded gespeichert und beim Restore wieder encoded (vgl. FR-020)." + - key: deviceShellScript + name: "macOS Shell Scripts" + graph_resource: "deviceManagement/deviceShellScripts" + + - key: deviceHealthScript + name: "Proactive Remediations" + graph_resource: "deviceManagement/deviceHealthScripts" + - key: enrollmentRestriction name: "Enrollment Restrictions" graph_resource: "deviceManagement/deviceEnrollmentConfigurations" @@ -46,22 +94,40 @@ ## Scope - key: windowsEnrollmentStatusPage name: "Enrollment Status Page (ESP)" graph_resource: "deviceManagement/deviceEnrollmentConfigurations" - filter: "odata.type eq '#microsoft.graph.windows10EnrollmentCompletionPageConfiguration'" + notes: "Filtered to #microsoft.graph.windows10EnrollmentCompletionPageConfiguration." - key: endpointSecurityIntent name: "Endpoint Security Intents" graph_resource: "deviceManagement/intents" notes: "Account Protection, Disk Encryption etc.; Zuordnung über bekannte Templates." + - key: endpointSecurityPolicy + name: "Endpoint Security Policies" + graph_resource: "deviceManagement/configurationPolicies" + notes: "Configuration policies classified via technologies/templateReference; restore execution enabled with template validation (Feature 023)." + + - key: securityBaselinePolicy + name: "Security Baselines" + graph_resource: "deviceManagement/configurationPolicies" + notes: "High risk; v1 restore stays preview-only." + - key: mobileApp name: "Applications (Metadata only)" graph_resource: "deviceAppManagement/mobileApps" notes: "Backup nur von Metadaten/Zuweisungen (kein Binary-Download in v1)." - - key: settingsCatalogPolicy - name: "Settings Catalog Policy" - graph_resource: "deviceManagement/configurationPolicies" - notes: "Intune Settings Catalog Policies liegen NICHT unter deviceConfigurations, sondern unter configurationPolicies. v1 behandelt sie als eigenen Typ." + foundation_types: + - key: assignmentFilter + name: "Assignment Filter" + graph_resource: "deviceManagement/assignmentFilters" + + - key: roleScopeTag + name: "Scope Tag" + graph_resource: "deviceManagement/roleScopeTags" + + - key: notificationMessageTemplate + name: "Notification Message Template" + graph_resource: "deviceManagement/notificationMessageTemplates" restore_matrix: deviceConfiguration: @@ -70,6 +136,37 @@ ## Scope risk: medium notes: "Standard-Case für Backup+Restore; starke Preview/Audit Pflicht." + groupPolicyConfiguration: + backup: full + restore: enabled + risk: medium + + settingsCatalogPolicy: + backup: full + restore: enabled + risk: medium + notes: "Settings are applied via configurationPolicies/{id}/settings; capability fallbacks may require manual follow-up." + + windowsUpdateRing: + backup: full + restore: enabled + risk: medium-high + + windowsFeatureUpdateProfile: + backup: full + restore: enabled + risk: high + + windowsQualityUpdateProfile: + backup: full + restore: enabled + risk: high + + windowsDriverUpdateProfile: + backup: full + restore: enabled + risk: high + deviceCompliancePolicy: backup: full restore: enabled @@ -82,6 +179,16 @@ ## Scope risk: medium-high notes: "MAM-Änderungen wirken auf Datenzugriff in Apps; Preview und Diff wichtig." + mamAppConfiguration: + backup: full + restore: enabled + risk: medium-high + + managedDeviceAppConfiguration: + backup: full + restore: enabled + risk: medium-high + conditionalAccessPolicy: backup: full restore: preview-only @@ -94,6 +201,16 @@ ## Scope risk: medium notes: "Script-Inhalt und Einstellungen werden gesichert; Decode/Encode beachten." + deviceShellScript: + backup: full + restore: enabled + risk: medium + + deviceHealthScript: + backup: full + restore: enabled + risk: medium + enrollmentRestriction: backup: full restore: preview-only @@ -118,17 +235,38 @@ ## Scope risk: high notes: "Security-relevante Einstellungen (z. B. Credential Guard); Preview + klare Konflikt-Warnungen nötig." - settingsCatalogPolicy: + endpointSecurityPolicy: backup: full - restore: enableds - risk: medium - notes: "Settings Catalog Policies sind Standard-Config-Policies (Settings Catalog). Preview/Audit Pflicht; Restore automatisierbar." + restore: enabled + risk: high + notes: "Enabled with template validation (Feature 023)." + + securityBaselinePolicy: + backup: full + restore: preview-only + risk: high + notes: "High risk; preview-only by default." mobileApp: backup: metadata-only restore: enabled risk: low-medium notes: "Nur Metadaten/Zuweisungen; kein Binary; Restore setzt Konfigurationen/Zuweisungen wieder." + + assignmentFilter: + backup: full + restore: enabled + risk: low + + roleScopeTag: + backup: full + restore: enabled + risk: low + + notificationMessageTemplate: + backup: full + restore: enabled + risk: low ``` ## User Scenarios & Testing *(mandatory)* diff --git a/.specify/tasks.md b/.specify/tasks.md index d369dae..701dd6f 100644 --- a/.specify/tasks.md +++ b/.specify/tasks.md @@ -8,9 +8,9 @@ # Tasks: TenantPilot v1 **Prerequisites**: plan.md (complete), spec.md (complete) **Status snapshot** -- Done: Phases 1–13 (US1–US4, Settings normalization/display, Highlander, US6 permissions/health, housekeeping/UX, ops) -- Next up: Phase 14 (US7) delegated Intune RBAC onboarding wizard (synchronous) -- Upcoming: Phase 15 (US8) Graph Contract Registry & Drift Guard +- Done: Phases 1–15 (US1–US8, Settings Catalog hydration/display, restore rerun, Highlander, US6 permissions/health, housekeeping/UX, ops) +- Open: T167 (optional) CLI/Job for CHECK/REPORT only (no grant) +- Next up: Feature 018 (Driver Updates / WUfB add-on) in `specs/018-driver-updates-wufb/` --- @@ -188,7 +188,7 @@ ## Acceptance Criteria - Restore von `settingsCatalogPolicy` scheitert nicht mehr an `Platforms`. - Results zeigen bei Fehlern weiterhin request-id/client-request-id (bleibt wie T177). -- [ ] T179 [US1b][Scope][settingsCatalogPolicy] Hydrate Settings Catalog “Configuration settings” for snapshots + normalized display +- [x] T179 [US1b][Scope][settingsCatalogPolicy] Hydrate Settings Catalog “Configuration settings” for snapshots + normalized display - **Goal:** Für `settingsCatalogPolicy` sollen die **Configuration settings** (wie im Intune Portal unter *Configuration settings*) im System sichtbar sein: - in **Policy Version Raw JSON** enthalten @@ -278,7 +278,7 @@ ## Verification -- [ ] T180 [US1b][Bug][settingsCatalogPolicy] Hydrate Settings Catalog settings in Version capture + Policy detail uses hydrated snapshot +- [x] T180 [US1b][Bug][settingsCatalogPolicy] Hydrate Settings Catalog settings in Version capture + Policy detail uses hydrated snapshot - **Goal:** `settingsCatalogPolicy` soll die *Configuration settings* nicht nur in Backups, sondern auch in **Policy Versions** enthalten, damit **Policy Detail**, Diff/Preview/Restore auf den echten Settings basieren. - **Why:** Aktuell hydriert nur `BackupService`, aber Policy Detail/Versions zeigen weiterhin nur Base-Metadaten. @@ -610,7 +610,7 @@ ## Acceptance Criteria -- [ ]T185 [UX][US1b][settingsCatalogPolicy] Make Settings Catalog settings readable (label/value parsing + table ergonomics) +- [x] T185 [UX][US1b][settingsCatalogPolicy] Make Settings Catalog settings readable (label/value parsing + table ergonomics) - **Goal:** Settings Catalog Policies sollen im Policy/Version Detail **für Admins lesbar** sein, ohne dass wir “alle Settings kennen müssen”. - Tabelle zeigt **sprechende Bezeichnung** + **kompakte Werte** @@ -699,7 +699,7 @@ ## Acceptance Criteria - **Readable Setting name** (not a cut-off vendor string) - **Readable Value preview** (True/False/12/etc.) -- [ ] T186 [US4][Bugfix][settingsCatalogPolicy] Fix settings_apply payload typing (@odata.type) + body shape for configurationPolicies/{id}/settings +- [x] T186 [US4][Bugfix][settingsCatalogPolicy] Fix settings_apply payload typing (@odata.type) + body shape for configurationPolicies/{id}/settings **Goal:** Restore für `settingsCatalogPolicy` soll Settings zuverlässig anwenden können, ohne ModelValidationFailure wegen fehlender/entfernter `@odata.type`. @@ -787,7 +787,7 @@ ### Implementation for User Story 4 - [x] T023 [US4] Implement restore service with preview/dry-run and selective item application in `app/Services/Intune/RestoreService.php`, integrating Graph adapter and conflict detection. - [x] T024 [US4] Add Filament restore UI (wizard or pages) showing preview, warnings, and confirmation gate in `app/Filament/Resources/RestoreRunResource.php`. - [x] T025 [US4] Record restore run lifecycle (start, per-item result, completion) and audit events in `restore_runs` and `audit_logs`. -- [ ] T156 [US4][UX] Add “Rerun” action to RestoreRun row actions (ActionGroup): creates a new RestoreRun cloned from selected run (same backup_set_id, same selected items, same dry_run flag), enforces same safety gates/confirmations as original execution path, writes audit event restore_run.rerun_created with source_restore_run_id. +- [x] T156 [US4][UX] Add “Rerun” action to RestoreRun row actions (ActionGroup): creates a new RestoreRun cloned from selected run (same backup_set_id, same selected items, same dry_run flag), enforces same safety gates/confirmations as original execution path, writes audit event restore_run.rerun_created with source_restore_run_id. ## Phase 7: User Story 5 - Operational readiness and environments (Priority: P2) diff --git a/README.md b/README.md index ddc34ef..edb6af5 100644 --- a/README.md +++ b/README.md @@ -35,6 +35,13 @@ ## Bulk operations (Feature 005) - Destructive operations require type-to-confirm at higher thresholds (e.g. `DELETE`). - Long-running bulk ops are queued; the bottom-right progress widget polls for active runs. +### Troubleshooting + +- **Progress stuck on “Queued…”** usually means the queue worker is not running (or not processing the queue you expect). + - Prefer using the Sail/Docker worker (see `docker-compose.yml`) rather than starting an additional local `php artisan queue:work`. + - Check worker status/logs: `./vendor/bin/sail ps` and `./vendor/bin/sail logs -f queue`. +- **Exit code 137** for `queue:work` typically means the process was killed (often OOM). Increase Docker memory/limits or run the worker inside the container. + ### Configuration - `TENANTPILOT_BULK_CHUNK_SIZE` (default `10`): job refresh/progress chunk size. diff --git a/app/Console/Commands/ReclassifyEnrollmentConfigurations.php b/app/Console/Commands/ReclassifyEnrollmentConfigurations.php new file mode 100644 index 0000000..ce0c783 --- /dev/null +++ b/app/Console/Commands/ReclassifyEnrollmentConfigurations.php @@ -0,0 +1,162 @@ +resolveTenantOrNull(); + $dryRun = ! (bool) $this->option('write'); + + $query = Policy::query() + ->with(['tenant']) + ->active() + ->where('policy_type', 'enrollmentRestriction'); + + if ($tenant) { + $query->where('tenant_id', $tenant->id); + } + + $candidates = $query->get(); + + $changedVersions = 0; + $changedPolicies = 0; + $ignoredPolicies = 0; + + foreach ($candidates as $policy) { + $latestVersion = $policy->versions()->latest('version_number')->first(); + $snapshot = $latestVersion?->snapshot; + + if (! is_array($snapshot)) { + $snapshot = $this->fetchSnapshotOrNull($policy); + } + + if (! is_array($snapshot)) { + continue; + } + + if (! $this->isEspSnapshot($snapshot)) { + continue; + } + + $this->line(sprintf( + 'ESP detected: policy=%s tenant_id=%s external_id=%s', + (string) $policy->getKey(), + (string) $policy->tenant_id, + (string) $policy->external_id, + )); + + if ($dryRun) { + continue; + } + + $existingTarget = Policy::query() + ->where('tenant_id', $policy->tenant_id) + ->where('external_id', $policy->external_id) + ->where('policy_type', 'windowsEnrollmentStatusPage') + ->first(); + + if ($existingTarget) { + $policy->forceFill(['ignored_at' => now()])->save(); + $ignoredPolicies++; + + continue; + } + + $policy->forceFill([ + 'policy_type' => 'windowsEnrollmentStatusPage', + ])->save(); + $changedPolicies++; + + $changedVersions += PolicyVersion::query() + ->where('policy_id', $policy->id) + ->where('policy_type', 'enrollmentRestriction') + ->update(['policy_type' => 'windowsEnrollmentStatusPage']); + } + + $this->info('Done.'); + $this->info('PolicyVersions changed: '.$changedVersions); + $this->info('Policies changed: '.$changedPolicies); + $this->info('Policies ignored: '.$ignoredPolicies); + $this->info('Mode: '.($dryRun ? 'dry-run' : 'write')); + + return Command::SUCCESS; + } + + private function isEspSnapshot(array $snapshot): bool + { + $odataType = $snapshot['@odata.type'] ?? null; + $configurationType = $snapshot['deviceEnrollmentConfigurationType'] ?? null; + + return (is_string($odataType) && strcasecmp($odataType, '#microsoft.graph.windows10EnrollmentCompletionPageConfiguration') === 0) + || (is_string($configurationType) && $configurationType === 'windows10EnrollmentCompletionPageConfiguration'); + } + + private function fetchSnapshotOrNull(Policy $policy): ?array + { + $tenant = $policy->tenant; + + if (! $tenant) { + return null; + } + + $tenantIdentifier = $tenant->tenant_id ?? $tenant->external_id; + + $response = $this->graphClient->getPolicy('enrollmentRestriction', $policy->external_id, [ + 'tenant' => $tenantIdentifier, + 'client_id' => $tenant->app_client_id, + 'client_secret' => $tenant->app_client_secret, + 'platform' => $policy->platform, + ]); + + if ($response->failed()) { + return null; + } + + $payload = $response->data['payload'] ?? null; + + return is_array($payload) ? $payload : null; + } + + private function resolveTenantOrNull(): ?Tenant + { + $tenantOption = $this->option('tenant'); + + if (! $tenantOption) { + return null; + } + + return Tenant::query() + ->forTenant($tenantOption) + ->firstOrFail(); + } +} diff --git a/app/Console/Commands/TenantpilotDispatchBackupSchedules.php b/app/Console/Commands/TenantpilotDispatchBackupSchedules.php new file mode 100644 index 0000000..6c2e12b --- /dev/null +++ b/app/Console/Commands/TenantpilotDispatchBackupSchedules.php @@ -0,0 +1,29 @@ +option('tenant'); + + $result = $dispatcher->dispatchDue($tenantIdentifiers); + + $this->info(sprintf( + 'Scanned %d schedule(s), created %d run(s), skipped %d duplicate run(s).', + $result['scanned_schedules'], + $result['created_runs'], + $result['skipped_runs'], + )); + + return self::SUCCESS; + } +} diff --git a/app/Console/Commands/TenantpilotPurgeNonPersistentData.php b/app/Console/Commands/TenantpilotPurgeNonPersistentData.php new file mode 100644 index 0000000..4b35693 --- /dev/null +++ b/app/Console/Commands/TenantpilotPurgeNonPersistentData.php @@ -0,0 +1,164 @@ +resolveTenants(); + + if ($tenants->isEmpty()) { + $this->error('No tenants selected. Provide {tenant} or use --all.'); + + return self::FAILURE; + } + + $isDryRun = ! (bool) $this->option('force'); + + if ($isDryRun) { + $this->warn('Dry run: no rows will be deleted. Re-run with --force to apply.'); + } else { + $this->warn('This will PERMANENTLY delete non-persistent tenant data.'); + + if ($this->input->isInteractive() && ! $this->confirm('Proceed?', false)) { + $this->info('Aborted.'); + + return self::SUCCESS; + } + } + + foreach ($tenants as $tenant) { + $counts = $this->countsForTenant($tenant); + + $this->line(''); + $this->info("Tenant: {$tenant->id} ({$tenant->name})"); + $this->table( + ['Table', 'Rows'], + collect($counts) + ->map(fn (int $count, string $table) => [$table, $count]) + ->values() + ->all(), + ); + + if ($isDryRun) { + continue; + } + + DB::transaction(function () use ($tenant): void { + BackupScheduleRun::query() + ->where('tenant_id', $tenant->id) + ->delete(); + + BackupSchedule::query() + ->where('tenant_id', $tenant->id) + ->delete(); + + BulkOperationRun::query() + ->where('tenant_id', $tenant->id) + ->delete(); + + AuditLog::query() + ->where('tenant_id', $tenant->id) + ->delete(); + + RestoreRun::withTrashed() + ->where('tenant_id', $tenant->id) + ->forceDelete(); + + BackupItem::withTrashed() + ->where('tenant_id', $tenant->id) + ->forceDelete(); + + BackupSet::withTrashed() + ->where('tenant_id', $tenant->id) + ->forceDelete(); + + PolicyVersion::withTrashed() + ->where('tenant_id', $tenant->id) + ->forceDelete(); + + Policy::query() + ->where('tenant_id', $tenant->id) + ->delete(); + }); + + $this->info('Purged.'); + } + + return self::SUCCESS; + } + + private function resolveTenants() + { + if ((bool) $this->option('all')) { + return Tenant::query()->get(); + } + + $tenantArg = $this->argument('tenant'); + + if ($tenantArg !== null && $tenantArg !== '') { + $tenant = Tenant::query()->forTenant($tenantArg)->first(); + + return $tenant ? collect([$tenant]) : collect(); + } + + try { + return collect([Tenant::current()]); + } catch (RuntimeException) { + return collect(); + } + } + + /** + * @return array + */ + private function countsForTenant(Tenant $tenant): array + { + return [ + 'backup_schedule_runs' => BackupScheduleRun::query()->where('tenant_id', $tenant->id)->count(), + 'backup_schedules' => BackupSchedule::query()->where('tenant_id', $tenant->id)->count(), + 'bulk_operation_runs' => BulkOperationRun::query()->where('tenant_id', $tenant->id)->count(), + 'audit_logs' => AuditLog::query()->where('tenant_id', $tenant->id)->count(), + 'restore_runs' => RestoreRun::withTrashed()->where('tenant_id', $tenant->id)->count(), + 'backup_items' => BackupItem::withTrashed()->where('tenant_id', $tenant->id)->count(), + 'backup_sets' => BackupSet::withTrashed()->where('tenant_id', $tenant->id)->count(), + 'policy_versions' => PolicyVersion::withTrashed()->where('tenant_id', $tenant->id)->count(), + 'policies' => Policy::query()->where('tenant_id', $tenant->id)->count(), + ]; + } +} diff --git a/app/Exceptions/InvalidPolicyTypeException.php b/app/Exceptions/InvalidPolicyTypeException.php new file mode 100644 index 0000000..0c751e4 --- /dev/null +++ b/app/Exceptions/InvalidPolicyTypeException.php @@ -0,0 +1,17 @@ +unknownPolicyTypes = array_values($unknownPolicyTypes); + + parent::__construct('Unknown policy types: '.implode(', ', $this->unknownPolicyTypes)); + } +} diff --git a/app/Filament/Pages/Tenancy/RegisterTenant.php b/app/Filament/Pages/Tenancy/RegisterTenant.php new file mode 100644 index 0000000..b39b512 --- /dev/null +++ b/app/Filament/Pages/Tenancy/RegisterTenant.php @@ -0,0 +1,83 @@ +schema([ + Forms\Components\TextInput::make('name') + ->required() + ->maxLength(255), + Forms\Components\Select::make('environment') + ->options([ + 'prod' => 'PROD', + 'dev' => 'DEV', + 'staging' => 'STAGING', + 'other' => 'Other', + ]) + ->default('other') + ->required(), + Forms\Components\TextInput::make('tenant_id') + ->label('Tenant ID (GUID)') + ->required() + ->maxLength(255) + ->unique(ignoreRecord: true), + Forms\Components\TextInput::make('domain') + ->label('Primary domain') + ->maxLength(255), + Forms\Components\TextInput::make('app_client_id') + ->label('App Client ID') + ->maxLength(255), + Forms\Components\TextInput::make('app_client_secret') + ->label('App Client Secret') + ->password() + ->dehydrateStateUsing(fn ($state) => filled($state) ? $state : null) + ->dehydrated(fn ($state) => filled($state)), + Forms\Components\TextInput::make('app_certificate_thumbprint') + ->label('Certificate thumbprint') + ->maxLength(255), + Forms\Components\Textarea::make('app_notes') + ->label('Notes') + ->rows(3), + ]); + } + + /** + * @param array $data + */ + protected function handleRegistration(array $data): Model + { + $tenant = Tenant::create($data); + + $user = auth()->user(); + + if ($user instanceof User) { + $user->tenants()->syncWithoutDetaching([ + $tenant->getKey() => ['role' => TenantRole::Owner->value], + ]); + } + + return $tenant; + } +} diff --git a/app/Filament/Resources/BackupScheduleResource.php b/app/Filament/Resources/BackupScheduleResource.php new file mode 100644 index 0000000..a16c996 --- /dev/null +++ b/app/Filament/Resources/BackupScheduleResource.php @@ -0,0 +1,856 @@ +user(); + + if (! $user instanceof User) { + return null; + } + + return $user->tenantRole(Tenant::current()); + } + + public static function canViewAny(): bool + { + return static::currentTenantRole() !== null; + } + + public static function canView(Model $record): bool + { + return static::currentTenantRole() !== null; + } + + public static function canCreate(): bool + { + return static::currentTenantRole()?->canManageBackupSchedules() ?? false; + } + + public static function canEdit(Model $record): bool + { + return static::currentTenantRole()?->canManageBackupSchedules() ?? false; + } + + public static function canDelete(Model $record): bool + { + return static::currentTenantRole()?->canManageBackupSchedules() ?? false; + } + + public static function canDeleteAny(): bool + { + return static::currentTenantRole()?->canManageBackupSchedules() ?? false; + } + + public static function form(Schema $schema): Schema + { + return $schema + ->schema([ + TextInput::make('name') + ->label('Schedule Name') + ->required() + ->maxLength(255), + + Toggle::make('is_enabled') + ->label('Enabled') + ->default(true), + + Select::make('timezone') + ->label('Timezone') + ->options(static::timezoneOptions()) + ->searchable() + ->default('UTC') + ->required(), + + Select::make('frequency') + ->label('Frequency') + ->options([ + 'daily' => 'Daily', + 'weekly' => 'Weekly', + ]) + ->default('daily') + ->required() + ->reactive(), + + TextInput::make('time_of_day') + ->label('Time of day') + ->type('time') + ->required() + ->extraInputAttributes(['step' => 60]), + + CheckboxList::make('days_of_week') + ->label('Days of the week') + ->options(static::dayOfWeekOptions()) + ->columns(2) + ->visible(fn (Get $get): bool => $get('frequency') === 'weekly') + ->required(fn (Get $get): bool => $get('frequency') === 'weekly') + ->rules(['array', 'min:1']), + + CheckboxList::make('policy_types') + ->label('Policy types') + ->options(static::policyTypeOptions()) + ->columns(2) + ->required() + ->helperText('Select the Microsoft Graph policy types that should be included in each run.') + ->rules([ + 'array', + 'min:1', + new SupportedPolicyTypesRule, + ]) + ->columnSpanFull(), + + Toggle::make('include_foundations') + ->label('Include foundation types') + ->default(true), + + TextInput::make('retention_keep_last') + ->label('Retention (keep last N Backup Sets)') + ->type('number') + ->default(30) + ->minValue(1) + ->required(), + ]); + } + + public static function table(Table $table): Table + { + return $table + ->defaultSort('next_run_at', 'asc') + ->columns([ + IconColumn::make('is_enabled') + ->label('Enabled') + ->boolean() + ->alignCenter(), + + TextColumn::make('name') + ->searchable() + ->label('Schedule'), + + TextColumn::make('frequency') + ->label('Frequency') + ->badge() + ->formatStateUsing(fn (?string $state): string => match ($state) { + 'daily' => 'Daily', + 'weekly' => 'Weekly', + default => (string) $state, + }) + ->color(fn (?string $state): string => match ($state) { + 'daily' => 'success', + 'weekly' => 'warning', + default => 'gray', + }), + + TextColumn::make('time_of_day') + ->label('Time') + ->formatStateUsing(fn (?string $state): ?string => $state ? trim($state) : null), + + TextColumn::make('timezone') + ->label('Timezone'), + + TextColumn::make('policy_types') + ->label('Policy types') + ->getStateUsing(fn (BackupSchedule $record): string => static::policyTypesPreviewLabel($record)) + ->tooltip(fn (BackupSchedule $record): string => static::policyTypesFullLabel($record)), + + TextColumn::make('retention_keep_last') + ->label('Retention') + ->suffix(' sets'), + + TextColumn::make('last_run_status') + ->label('Last run status') + ->badge() + ->formatStateUsing(fn (?string $state): string => match ($state) { + BackupScheduleRun::STATUS_RUNNING => 'Running', + BackupScheduleRun::STATUS_SUCCESS => 'Success', + BackupScheduleRun::STATUS_PARTIAL => 'Partial', + BackupScheduleRun::STATUS_FAILED => 'Failed', + BackupScheduleRun::STATUS_CANCELED => 'Canceled', + BackupScheduleRun::STATUS_SKIPPED => 'Skipped', + default => $state ? Str::headline($state) : '—', + }) + ->color(fn (?string $state): string => match ($state) { + BackupScheduleRun::STATUS_SUCCESS => 'success', + BackupScheduleRun::STATUS_PARTIAL => 'warning', + BackupScheduleRun::STATUS_RUNNING => 'primary', + BackupScheduleRun::STATUS_SKIPPED => 'gray', + BackupScheduleRun::STATUS_FAILED, + BackupScheduleRun::STATUS_CANCELED => 'danger', + default => 'gray', + }), + + TextColumn::make('last_run_at') + ->label('Last run') + ->dateTime() + ->sortable(), + + TextColumn::make('next_run_at') + ->label('Next run') + ->getStateUsing(function (BackupSchedule $record): ?string { + $nextRun = $record->next_run_at; + + if (! $nextRun) { + return null; + } + + $timezone = $record->timezone ?: 'UTC'; + + try { + return $nextRun->setTimezone($timezone)->format('M j, Y H:i:s'); + } catch (\Throwable) { + return $nextRun->format('M j, Y H:i:s'); + } + }) + ->sortable(), + ]) + ->filters([ + SelectFilter::make('enabled_state') + ->label('Enabled') + ->options([ + 'enabled' => 'Enabled', + 'disabled' => 'Disabled', + ]) + ->query(function (Builder $query, array $data): void { + $value = $data['value'] ?? null; + + if (blank($value)) { + return; + } + + if ($value === 'enabled') { + $query->where('is_enabled', true); + + return; + } + + if ($value === 'disabled') { + $query->where('is_enabled', false); + } + }), + ]) + ->actions([ + ActionGroup::make([ + Action::make('runNow') + ->label('Run now') + ->icon('heroicon-o-play') + ->color('success') + ->visible(fn (): bool => static::currentTenantRole()?->canRunBackupSchedules() ?? false) + ->action(function (BackupSchedule $record): void { + abort_unless(static::currentTenantRole()?->canRunBackupSchedules() ?? false, 403); + + $tenant = Tenant::current(); + $user = auth()->user(); + $userId = auth()->id(); + $userModel = $user instanceof User ? $user : ($userId ? User::query()->find($userId) : null); + + $scheduledFor = CarbonImmutable::now('UTC')->startOfMinute(); + $run = null; + + for ($i = 0; $i < 5; $i++) { + try { + $run = BackupScheduleRun::create([ + 'backup_schedule_id' => $record->id, + 'tenant_id' => $tenant->getKey(), + 'user_id' => $userId, + 'scheduled_for' => $scheduledFor->toDateTimeString(), + 'status' => BackupScheduleRun::STATUS_RUNNING, + 'summary' => null, + ]); + break; + } catch (UniqueConstraintViolationException) { + $scheduledFor = $scheduledFor->addMinute(); + } + } + + if (! $run instanceof BackupScheduleRun) { + Notification::make() + ->title('Run already queued') + ->body('Please wait a moment and try again.') + ->warning() + ->send(); + + return; + } + + app(AuditLogger::class)->log( + tenant: $tenant, + action: 'backup_schedule.run_dispatched_manual', + resourceType: 'backup_schedule_run', + resourceId: (string) $run->id, + status: 'success', + context: [ + 'metadata' => [ + 'backup_schedule_id' => $record->id, + 'backup_schedule_run_id' => $run->id, + 'scheduled_for' => $scheduledFor->toDateTimeString(), + 'trigger' => 'run_now', + ], + ], + ); + + $bulkRunId = null; + + if ($userModel instanceof User) { + $bulkRunId = app(BulkOperationService::class) + ->createRun( + tenant: $tenant, + user: $userModel, + resource: 'backup_schedule', + action: 'run', + itemIds: [(string) $record->id], + totalItems: 1, + ) + ->id; + } + + Bus::dispatch(new RunBackupScheduleJob($run->id, $bulkRunId)); + + $notification = Notification::make() + ->title('Run dispatched') + ->body('The backup run has been queued.') + ->success(); + + if ($userModel instanceof User) { + $userModel->notifyNow($notification->toDatabase()); + DatabaseNotificationsSent::dispatch($userModel); + } + + $notification->send(); + }), + Action::make('retry') + ->label('Retry') + ->icon('heroicon-o-arrow-path') + ->color('warning') + ->visible(fn (): bool => static::currentTenantRole()?->canRunBackupSchedules() ?? false) + ->action(function (BackupSchedule $record): void { + abort_unless(static::currentTenantRole()?->canRunBackupSchedules() ?? false, 403); + + $tenant = Tenant::current(); + $user = auth()->user(); + $userId = auth()->id(); + $userModel = $user instanceof User ? $user : ($userId ? User::query()->find($userId) : null); + + $scheduledFor = CarbonImmutable::now('UTC')->startOfMinute(); + $run = null; + + for ($i = 0; $i < 5; $i++) { + try { + $run = BackupScheduleRun::create([ + 'backup_schedule_id' => $record->id, + 'tenant_id' => $tenant->getKey(), + 'user_id' => $userId, + 'scheduled_for' => $scheduledFor->toDateTimeString(), + 'status' => BackupScheduleRun::STATUS_RUNNING, + 'summary' => null, + ]); + break; + } catch (UniqueConstraintViolationException) { + $scheduledFor = $scheduledFor->addMinute(); + } + } + + if (! $run instanceof BackupScheduleRun) { + Notification::make() + ->title('Retry already queued') + ->body('Please wait a moment and try again.') + ->warning() + ->send(); + + return; + } + + app(AuditLogger::class)->log( + tenant: $tenant, + action: 'backup_schedule.run_dispatched_manual', + resourceType: 'backup_schedule_run', + resourceId: (string) $run->id, + status: 'success', + context: [ + 'metadata' => [ + 'backup_schedule_id' => $record->id, + 'backup_schedule_run_id' => $run->id, + 'scheduled_for' => $scheduledFor->toDateTimeString(), + 'trigger' => 'retry', + ], + ], + ); + + $bulkRunId = null; + + if ($userModel instanceof User) { + $bulkRunId = app(BulkOperationService::class) + ->createRun( + tenant: $tenant, + user: $userModel, + resource: 'backup_schedule', + action: 'retry', + itemIds: [(string) $record->id], + totalItems: 1, + ) + ->id; + } + + Bus::dispatch(new RunBackupScheduleJob($run->id, $bulkRunId)); + + $notification = Notification::make() + ->title('Retry dispatched') + ->body('A new backup run has been queued.') + ->success(); + + if ($userModel instanceof User) { + $userModel->notifyNow($notification->toDatabase()); + DatabaseNotificationsSent::dispatch($userModel); + } + + $notification->send(); + }), + EditAction::make() + ->visible(fn (): bool => static::currentTenantRole()?->canManageBackupSchedules() ?? false), + DeleteAction::make() + ->visible(fn (): bool => static::currentTenantRole()?->canManageBackupSchedules() ?? false), + ])->icon('heroicon-o-ellipsis-vertical'), + ]) + ->bulkActions([ + BulkActionGroup::make([ + BulkAction::make('bulk_run_now') + ->label('Run now') + ->icon('heroicon-o-play') + ->color('success') + ->visible(fn (): bool => static::currentTenantRole()?->canRunBackupSchedules() ?? false) + ->action(function (Collection $records): void { + abort_unless(static::currentTenantRole()?->canRunBackupSchedules() ?? false, 403); + + if ($records->isEmpty()) { + return; + } + + $tenant = Tenant::current(); + $userId = auth()->id(); + $user = $userId ? User::query()->find($userId) : null; + + $bulkRun = null; + if ($user) { + $bulkRun = app(\App\Services\BulkOperationService::class)->createRun( + tenant: $tenant, + user: $user, + resource: 'backup_schedule', + action: 'run', + itemIds: $records->pluck('id')->map(fn (mixed $id): int => (int) $id)->values()->all(), + totalItems: $records->count(), + ); + } + + $createdRunIds = []; + + /** @var BackupSchedule $record */ + foreach ($records as $record) { + $scheduledFor = CarbonImmutable::now('UTC')->startOfMinute(); + $run = null; + + for ($i = 0; $i < 5; $i++) { + try { + $run = BackupScheduleRun::create([ + 'backup_schedule_id' => $record->id, + 'tenant_id' => $tenant->getKey(), + 'user_id' => $userId, + 'scheduled_for' => $scheduledFor->toDateTimeString(), + 'status' => BackupScheduleRun::STATUS_RUNNING, + 'summary' => null, + ]); + break; + } catch (UniqueConstraintViolationException) { + $scheduledFor = $scheduledFor->addMinute(); + } + } + + if (! $run instanceof BackupScheduleRun) { + continue; + } + + $createdRunIds[] = (int) $run->id; + + app(AuditLogger::class)->log( + tenant: $tenant, + action: 'backup_schedule.run_dispatched_manual', + resourceType: 'backup_schedule_run', + resourceId: (string) $run->id, + status: 'success', + context: [ + 'metadata' => [ + 'backup_schedule_id' => $record->id, + 'backup_schedule_run_id' => $run->id, + 'scheduled_for' => $scheduledFor->toDateTimeString(), + 'trigger' => 'bulk_run_now', + 'bulk_run_id' => $bulkRun?->id, + ], + ], + ); + + Bus::dispatch(new RunBackupScheduleJob($run->id, $bulkRun?->id)); + } + + $notification = Notification::make() + ->title('Runs dispatched') + ->body(sprintf('Queued %d run(s).', count($createdRunIds))); + + if (count($createdRunIds) === 0) { + $notification->warning(); + } else { + $notification->success(); + } + + if ($user instanceof User) { + $user->notifyNow($notification->toDatabase()); + DatabaseNotificationsSent::dispatch($user); + } + + $notification->send(); + }), + BulkAction::make('bulk_retry') + ->label('Retry') + ->icon('heroicon-o-arrow-path') + ->color('warning') + ->visible(fn (): bool => static::currentTenantRole()?->canRunBackupSchedules() ?? false) + ->action(function (Collection $records): void { + abort_unless(static::currentTenantRole()?->canRunBackupSchedules() ?? false, 403); + + if ($records->isEmpty()) { + return; + } + + $tenant = Tenant::current(); + $userId = auth()->id(); + $user = $userId ? User::query()->find($userId) : null; + + $bulkRun = null; + if ($user) { + $bulkRun = app(\App\Services\BulkOperationService::class)->createRun( + tenant: $tenant, + user: $user, + resource: 'backup_schedule', + action: 'retry', + itemIds: $records->pluck('id')->map(fn (mixed $id): int => (int) $id)->values()->all(), + totalItems: $records->count(), + ); + } + + $createdRunIds = []; + + /** @var BackupSchedule $record */ + foreach ($records as $record) { + $scheduledFor = CarbonImmutable::now('UTC')->startOfMinute(); + $run = null; + + for ($i = 0; $i < 5; $i++) { + try { + $run = BackupScheduleRun::create([ + 'backup_schedule_id' => $record->id, + 'tenant_id' => $tenant->getKey(), + 'user_id' => $userId, + 'scheduled_for' => $scheduledFor->toDateTimeString(), + 'status' => BackupScheduleRun::STATUS_RUNNING, + 'summary' => null, + ]); + break; + } catch (UniqueConstraintViolationException) { + $scheduledFor = $scheduledFor->addMinute(); + } + } + + if (! $run instanceof BackupScheduleRun) { + continue; + } + + $createdRunIds[] = (int) $run->id; + + app(AuditLogger::class)->log( + tenant: $tenant, + action: 'backup_schedule.run_dispatched_manual', + resourceType: 'backup_schedule_run', + resourceId: (string) $run->id, + status: 'success', + context: [ + 'metadata' => [ + 'backup_schedule_id' => $record->id, + 'backup_schedule_run_id' => $run->id, + 'scheduled_for' => $scheduledFor->toDateTimeString(), + 'trigger' => 'bulk_retry', + 'bulk_run_id' => $bulkRun?->id, + ], + ], + ); + + Bus::dispatch(new RunBackupScheduleJob($run->id, $bulkRun?->id)); + } + + $notification = Notification::make() + ->title('Retries dispatched') + ->body(sprintf('Queued %d run(s).', count($createdRunIds))); + + if (count($createdRunIds) === 0) { + $notification->warning(); + } else { + $notification->success(); + } + + if ($user instanceof User) { + $user->notifyNow($notification->toDatabase()); + DatabaseNotificationsSent::dispatch($user); + } + + $notification->send(); + }), + DeleteBulkAction::make('bulk_delete') + ->visible(fn (): bool => static::currentTenantRole()?->canManageBackupSchedules() ?? false), + ]), + ]); + } + + public static function getEloquentQuery(): Builder + { + $tenantId = Tenant::current()->getKey(); + + return parent::getEloquentQuery() + ->where('tenant_id', $tenantId) + ->orderByDesc('is_enabled') + ->orderBy('next_run_at'); + } + + public static function getRelations(): array + { + return [ + BackupScheduleRunsRelationManager::class, + ]; + } + + public static function getPages(): array + { + return [ + 'index' => Pages\ListBackupSchedules::route('/'), + 'create' => Pages\CreateBackupSchedule::route('/create'), + 'edit' => Pages\EditBackupSchedule::route('/{record}/edit'), + ]; + } + + public static function policyTypesFullLabel(BackupSchedule $record): string + { + $labels = static::policyTypesLabels($record); + + return $labels === [] ? 'None' : implode(', ', $labels); + } + + public static function policyTypesPreviewLabel(BackupSchedule $record): string + { + $labels = static::policyTypesLabels($record); + + if ($labels === []) { + return 'None'; + } + + $preview = array_slice($labels, 0, 2); + $remaining = count($labels) - count($preview); + + $label = implode(', ', $preview); + + if ($remaining > 0) { + $label .= sprintf(' +%d more', $remaining); + } + + return $label; + } + + /** + * @return array + */ + private static function policyTypesLabels(BackupSchedule $record): array + { + $state = $record->policy_types; + + if (is_string($state)) { + $decoded = json_decode($state, true); + + if (is_array($decoded)) { + $state = $decoded; + } + } + + if ($state instanceof \Illuminate\Contracts\Support\Arrayable) { + $state = $state->toArray(); + } + + if (blank($state) || (! is_array($state))) { + return []; + } + + $types = array_is_list($state) + ? $state + : array_keys(array_filter($state)); + + $types = array_values(array_filter($types, fn (mixed $type): bool => is_string($type) && $type !== '')); + + if ($types === []) { + return []; + } + + $labelMap = collect(config('tenantpilot.supported_policy_types', [])) + ->mapWithKeys(fn (array $policy): array => [ + (string) ($policy['type'] ?? '') => (string) ($policy['label'] ?? Str::headline((string) ($policy['type'] ?? ''))), + ]) + ->filter(fn (string $label, string $type): bool => $type !== '') + ->all(); + + return array_map( + fn (string $type): string => $labelMap[$type] ?? Str::headline($type), + $types, + ); + } + + public static function ensurePolicyTypes(array $data): array + { + $types = array_values((array) ($data['policy_types'] ?? [])); + + try { + app(PolicyTypeResolver::class)->ensureSupported($types); + } catch (InvalidPolicyTypeException $exception) { + throw ValidationException::withMessages([ + 'policy_types' => [sprintf('Unknown policy types: %s.', implode(', ', $exception->unknownPolicyTypes))], + ]); + } + + $data['policy_types'] = $types; + + return $data; + } + + public static function assignTenant(array $data): array + { + $data['tenant_id'] = Tenant::current()->getKey(); + + return $data; + } + + public static function hydrateNextRun(array $data): array + { + if (! empty($data['time_of_day'])) { + $data['time_of_day'] = static::normalizeTimeOfDay($data['time_of_day']); + } + + $schedule = new BackupSchedule; + $schedule->forceFill([ + 'frequency' => $data['frequency'] ?? 'daily', + 'time_of_day' => $data['time_of_day'] ?? '00:00:00', + 'timezone' => $data['timezone'] ?? 'UTC', + 'days_of_week' => (array) ($data['days_of_week'] ?? []), + ]); + + $nextRun = app(ScheduleTimeService::class)->nextRunFor($schedule); + + $data['next_run_at'] = $nextRun?->toDateTimeString(); + + return $data; + } + + public static function normalizeTimeOfDay(string $time): string + { + if (preg_match('/^\d{2}:\d{2}$/', $time)) { + return $time.':00'; + } + + return $time; + } + + protected static function timezoneOptions(): array + { + $zones = DateTimeZone::listIdentifiers(); + + sort($zones); + + return array_combine($zones, $zones); + } + + protected static function policyTypeOptions(): array + { + return static::policyTypeLabelMap(); + } + + protected static function policyTypeLabels(array $types): array + { + $map = static::policyTypeLabelMap(); + + return array_map(fn (string $type): string => $map[$type] ?? Str::headline($type), $types); + } + + protected static function policyTypeLabelMap(): array + { + return collect(config('tenantpilot.supported_policy_types', [])) + ->mapWithKeys(fn (array $policy) => [ + $policy['type'] => $policy['label'] ?? Str::headline($policy['type']), + ]) + ->all(); + } + + protected static function dayOfWeekOptions(): array + { + return [ + 1 => 'Monday', + 2 => 'Tuesday', + 3 => 'Wednesday', + 4 => 'Thursday', + 5 => 'Friday', + 6 => 'Saturday', + 7 => 'Sunday', + ]; + } +} diff --git a/app/Filament/Resources/BackupScheduleResource/Pages/CreateBackupSchedule.php b/app/Filament/Resources/BackupScheduleResource/Pages/CreateBackupSchedule.php new file mode 100644 index 0000000..d398e46 --- /dev/null +++ b/app/Filament/Resources/BackupScheduleResource/Pages/CreateBackupSchedule.php @@ -0,0 +1,19 @@ +modifyQueryUsing(fn (Builder $query) => $query->where('tenant_id', Tenant::current()->getKey())->with('backupSet')) + ->defaultSort('scheduled_for', 'desc') + ->columns([ + Tables\Columns\TextColumn::make('scheduled_for') + ->label('Scheduled for') + ->dateTime(), + Tables\Columns\TextColumn::make('status') + ->badge() + ->color(fn (?string $state): string => match ($state) { + BackupScheduleRun::STATUS_SUCCESS => 'success', + BackupScheduleRun::STATUS_PARTIAL => 'warning', + BackupScheduleRun::STATUS_RUNNING => 'primary', + BackupScheduleRun::STATUS_SKIPPED => 'gray', + BackupScheduleRun::STATUS_FAILED, + BackupScheduleRun::STATUS_CANCELED => 'danger', + default => 'gray', + }), + Tables\Columns\TextColumn::make('duration') + ->label('Duration') + ->getStateUsing(function (BackupScheduleRun $record): string { + if (! $record->started_at || ! $record->finished_at) { + return '—'; + } + + $seconds = max(0, $record->started_at->diffInSeconds($record->finished_at)); + + if ($seconds < 60) { + return $seconds.'s'; + } + + $minutes = intdiv($seconds, 60); + $rem = $seconds % 60; + + return sprintf('%dm %ds', $minutes, $rem); + }), + Tables\Columns\TextColumn::make('counts') + ->label('Counts') + ->getStateUsing(function (BackupScheduleRun $record): string { + $summary = is_array($record->summary) ? $record->summary : []; + + $total = (int) ($summary['policies_total'] ?? 0); + $backedUp = (int) ($summary['policies_backed_up'] ?? 0); + $errors = (int) ($summary['errors_count'] ?? 0); + + if ($total === 0 && $backedUp === 0 && $errors === 0) { + return '—'; + } + + return sprintf('%d/%d (%d errors)', $backedUp, $total, $errors); + }), + Tables\Columns\TextColumn::make('error_code') + ->label('Error') + ->badge() + ->default('—'), + Tables\Columns\TextColumn::make('error_message') + ->label('Message') + ->default('—') + ->limit(80) + ->wrap(), + Tables\Columns\TextColumn::make('backup_set_id') + ->label('Backup set') + ->default('—') + ->url(function (BackupScheduleRun $record): ?string { + if (! $record->backup_set_id) { + return null; + } + + return BackupSetResource::getUrl('view', ['record' => $record->backup_set_id], tenant: Tenant::current()); + }) + ->openUrlInNewTab(true), + ]) + ->filters([]) + ->headerActions([]) + ->actions([ + Actions\Action::make('view') + ->label('View') + ->icon('heroicon-o-eye') + ->modalHeading('View backup schedule run') + ->modalSubmitAction(false) + ->modalCancelActionLabel('Close') + ->modalContent(function (BackupScheduleRun $record): View { + return view('filament.modals.backup-schedule-run-view', [ + 'run' => $record, + ]); + }), + ]) + ->bulkActions([]); + } +} diff --git a/app/Filament/Resources/BackupSetResource/RelationManagers/BackupItemsRelationManager.php b/app/Filament/Resources/BackupSetResource/RelationManagers/BackupItemsRelationManager.php index eddf393..e5e8035 100644 --- a/app/Filament/Resources/BackupSetResource/RelationManagers/BackupItemsRelationManager.php +++ b/app/Filament/Resources/BackupSetResource/RelationManagers/BackupItemsRelationManager.php @@ -4,17 +4,15 @@ use App\Filament\Resources\PolicyResource; use App\Models\BackupItem; -use App\Models\Policy; -use App\Models\Tenant; use App\Services\Intune\AuditLogger; -use App\Services\Intune\BackupService; use Filament\Actions; -use Filament\Forms; use Filament\Notifications\Notification; use Filament\Resources\RelationManagers\RelationManager; use Filament\Tables; use Filament\Tables\Table; +use Illuminate\Contracts\View\View; use Illuminate\Database\Eloquent\Builder; +use Illuminate\Database\Eloquent\Collection; class BackupItemsRelationManager extends RelationManager { @@ -99,113 +97,110 @@ public function table(Table $table): Table Actions\Action::make('addPolicies') ->label('Add Policies') ->icon('heroicon-o-plus') - ->form([ - Forms\Components\Select::make('policy_ids') - ->label('Policies') - ->multiple() - ->required() - ->searchable() - ->options(function (RelationManager $livewire) { - $backupSet = $livewire->getOwnerRecord(); - $tenantId = $backupSet?->tenant_id ?? Tenant::current()->getKey(); - - $existing = $backupSet - ? $backupSet->items()->pluck('policy_id')->filter()->all() - : []; - - return Policy::query() - ->where('tenant_id', $tenantId) - ->whereNull('ignored_at') - ->where('last_synced_at', '>', now()->subDays(7)) // Hide deleted policies (Feature 005 workaround) - ->when($existing, fn (Builder $query) => $query->whereNotIn('id', $existing)) - ->orderBy('display_name') - ->pluck('display_name', 'id'); - }), - Forms\Components\Checkbox::make('include_assignments') - ->label('Include assignments') - ->default(true) - ->helperText('Captures assignment include/exclude targeting and filters.'), - Forms\Components\Checkbox::make('include_scope_tags') - ->label('Include scope tags') - ->default(true) - ->helperText('Captures policy scope tag IDs.'), - Forms\Components\Checkbox::make('include_foundations') - ->label('Include foundations') - ->default(true) - ->helperText('Captures assignment filters, scope tags, and notification templates.'), - ]) - ->action(function (array $data, BackupService $service) { - if (empty($data['policy_ids'])) { - Notification::make() - ->title('No policies selected') - ->warning() - ->send(); - - return; - } - + ->modalHeading('Add Policies') + ->modalSubmitAction(false) + ->modalCancelActionLabel('Close') + ->modalContent(function (): View { $backupSet = $this->getOwnerRecord(); - $tenant = $backupSet?->tenant ?? Tenant::current(); - $service->addPoliciesToSet( - tenant: $tenant, - backupSet: $backupSet, - policyIds: $data['policy_ids'], - actorEmail: auth()->user()?->email, - actorName: auth()->user()?->name, - includeAssignments: $data['include_assignments'] ?? false, - includeScopeTags: $data['include_scope_tags'] ?? false, - includeFoundations: $data['include_foundations'] ?? false, - ); - - $notificationTitle = ($data['include_foundations'] ?? false) - ? 'Backup items added' - : 'Policies added to backup'; - - Notification::make() - ->title($notificationTitle) - ->success() - ->send(); + return view('filament.modals.backup-set-policy-picker', [ + 'backupSetId' => $backupSet->getKey(), + ]); }), ]) ->actions([ - Actions\ViewAction::make() - ->label('View policy') - ->url(fn ($record) => $record->policy_id ? PolicyResource::getUrl('view', ['record' => $record->policy_id]) : null) - ->hidden(fn ($record) => ! $record->policy_id) - ->openUrlInNewTab(true), - Actions\Action::make('remove') - ->label('Remove') - ->color('danger') - ->icon('heroicon-o-x-mark') - ->requiresConfirmation() - ->action(function (BackupItem $record, AuditLogger $auditLogger) { - $record->delete(); + Actions\ActionGroup::make([ + Actions\ViewAction::make() + ->label('View policy') + ->url(function (BackupItem $record): ?string { + if (! $record->policy_id) { + return null; + } - if ($record->backupSet) { - $record->backupSet->update([ - 'item_count' => $record->backupSet->items()->count(), - ]); - } + $tenant = $this->getOwnerRecord()->tenant ?? \App\Models\Tenant::current(); - if ($record->tenant) { - $auditLogger->log( - tenant: $record->tenant, - action: 'backup.item_removed', - resourceType: 'backup_set', - resourceId: (string) $record->backup_set_id, - status: 'success', - context: ['metadata' => ['policy_id' => $record->policy_id]] - ); - } + return PolicyResource::getUrl('view', ['record' => $record->policy_id], tenant: $tenant); + }) + ->hidden(fn (BackupItem $record) => ! $record->policy_id) + ->openUrlInNewTab(true), + Actions\Action::make('remove') + ->label('Remove') + ->color('danger') + ->icon('heroicon-o-x-mark') + ->requiresConfirmation() + ->action(function (BackupItem $record, AuditLogger $auditLogger) { + $record->delete(); - Notification::make() - ->title('Policy removed from backup') - ->success() - ->send(); - }), + if ($record->backupSet) { + $record->backupSet->update([ + 'item_count' => $record->backupSet->items()->count(), + ]); + } + + if ($record->tenant) { + $auditLogger->log( + tenant: $record->tenant, + action: 'backup.item_removed', + resourceType: 'backup_set', + resourceId: (string) $record->backup_set_id, + status: 'success', + context: ['metadata' => ['policy_id' => $record->policy_id]] + ); + } + + Notification::make() + ->title('Policy removed from backup') + ->success() + ->send(); + }), + ])->icon('heroicon-o-ellipsis-vertical'), ]) - ->bulkActions([]); + ->bulkActions([ + Actions\BulkActionGroup::make([ + Actions\BulkAction::make('bulk_remove') + ->label('Remove selected') + ->icon('heroicon-o-x-mark') + ->color('danger') + ->requiresConfirmation() + ->action(function (Collection $records, AuditLogger $auditLogger) { + if ($records->isEmpty()) { + return; + } + + $backupSet = $this->getOwnerRecord(); + + $records->each(fn (BackupItem $record) => $record->delete()); + + $backupSet->update([ + 'item_count' => $backupSet->items()->count(), + ]); + + $tenant = $records->first()?->tenant; + + if ($tenant) { + $auditLogger->log( + tenant: $tenant, + action: 'backup.items_removed', + resourceType: 'backup_set', + resourceId: (string) $backupSet->id, + status: 'success', + context: [ + 'metadata' => [ + 'removed_count' => $records->count(), + 'policy_ids' => $records->pluck('policy_id')->filter()->values()->all(), + 'policy_identifiers' => $records->pluck('policy_identifier')->filter()->values()->all(), + ], + ] + ); + } + + Notification::make() + ->title('Policies removed from backup') + ->success() + ->send(); + }), + ]), + ]); } /** diff --git a/app/Filament/Resources/PolicyResource.php b/app/Filament/Resources/PolicyResource.php index bea7788..371b8cc 100644 --- a/app/Filament/Resources/PolicyResource.php +++ b/app/Filament/Resources/PolicyResource.php @@ -58,6 +58,26 @@ public static function infolist(Schema $schema): Schema TextEntry::make('external_id')->label('External ID'), TextEntry::make('last_synced_at')->dateTime()->label('Last synced'), TextEntry::make('created_at')->since(), + TextEntry::make('latest_snapshot_mode') + ->label('Snapshot') + ->badge() + ->color(fn (Policy $record): string => (static::latestVersionMetadata($record)['source'] ?? null) === 'metadata_only' ? 'warning' : 'success') + ->state(fn (Policy $record): string => (static::latestVersionMetadata($record)['source'] ?? null) === 'metadata_only' ? 'metadata only' : 'full') + ->helperText(function (Policy $record): ?string { + $meta = static::latestVersionMetadata($record); + + if (($meta['source'] ?? null) !== 'metadata_only') { + return null; + } + + $status = $meta['original_status'] ?? null; + + return sprintf( + 'Graph returned %s for this policy type. Only local metadata was saved; settings and restore are unavailable until Graph works again.', + $status ?? 'an error' + ); + }) + ->visible(fn (Policy $record) => $record->versions()->exists()), ]) ->columns(2) ->columnSpanFull(), @@ -597,6 +617,20 @@ private static function latestSnapshot(Policy $record): array return []; } + private static function latestVersionMetadata(Policy $record): array + { + $metadata = $record->relationLoaded('versions') + ? $record->versions->first()?->metadata + : $record->versions()->orderByDesc('captured_at')->value('metadata'); + + if (is_string($metadata)) { + $decoded = json_decode($metadata, true); + $metadata = $decoded ?? []; + } + + return is_array($metadata) ? $metadata : []; + } + /** * @return array */ @@ -623,6 +657,7 @@ private static function normalizedPolicyState(Policy $record): array $normalized['context'] = 'policy'; $normalized['record_id'] = (string) $record->getKey(); + $normalized['policy_type'] = $record->policy_type; $request->attributes->set($cacheKey, $normalized); @@ -763,7 +798,7 @@ private static function settingsTabState(Policy $record): array $rows = $normalized['settings_table']['rows'] ?? []; $hasSettingsTable = is_array($rows) && $rows !== []; - if ($record->policy_type === 'settingsCatalogPolicy' && $hasSettingsTable) { + if (in_array($record->policy_type, ['settingsCatalogPolicy', 'endpointSecurityPolicy', 'securityBaselinePolicy'], true) && $hasSettingsTable) { $split = static::splitGeneralBlock($normalized); return $split['normalized']; diff --git a/app/Filament/Resources/PolicyResource/Pages/ListPolicies.php b/app/Filament/Resources/PolicyResource/Pages/ListPolicies.php index e3743d2..222510c 100644 --- a/app/Filament/Resources/PolicyResource/Pages/ListPolicies.php +++ b/app/Filament/Resources/PolicyResource/Pages/ListPolicies.php @@ -28,11 +28,35 @@ protected function getHeaderActions(): array /** @var PolicySyncService $service */ $service = app(PolicySyncService::class); - $synced = $service->syncPolicies($tenant); + $result = $service->syncPoliciesWithReport($tenant); + $syncedCount = count($result['synced'] ?? []); + $failureCount = count($result['failures'] ?? []); + + $body = $syncedCount.' policies synced'; + + if ($failureCount > 0) { + $first = $result['failures'][0] ?? []; + $firstType = $first['policy_type'] ?? 'unknown'; + $firstStatus = $first['status'] ?? null; + + $firstErrorMessage = null; + $firstErrors = $first['errors'] ?? null; + if (is_array($firstErrors) && isset($firstErrors[0]) && is_array($firstErrors[0])) { + $firstErrorMessage = $firstErrors[0]['message'] ?? null; + } + + $suffix = $firstStatus ? "first: {$firstType} {$firstStatus}" : "first: {$firstType}"; + + if (is_string($firstErrorMessage) && $firstErrorMessage !== '') { + $suffix .= ' - '.trim($firstErrorMessage); + } + + $body .= " ({$failureCount} failed; {$suffix})"; + } Notification::make() ->title('Policy sync completed') - ->body(count($synced).' policies synced') + ->body($body) ->success() ->sendToDatabase(auth()->user()) ->send(); diff --git a/app/Filament/Resources/PolicyResource/Pages/ViewPolicy.php b/app/Filament/Resources/PolicyResource/Pages/ViewPolicy.php index 17c7b1b..1bfd70a 100644 --- a/app/Filament/Resources/PolicyResource/Pages/ViewPolicy.php +++ b/app/Filament/Resources/PolicyResource/Pages/ViewPolicy.php @@ -49,7 +49,7 @@ protected function getActions(): array return; } - app(VersionService::class)->captureFromGraph( + $version = app(VersionService::class)->captureFromGraph( tenant: $tenant, policy: $policy, createdBy: auth()->user()?->email ?? null, @@ -57,10 +57,23 @@ protected function getActions(): array includeScopeTags: $data['include_scope_tags'] ?? false, ); - Notification::make() - ->title('Snapshot captured successfully.') - ->success() - ->send(); + if (($version->metadata['source'] ?? null) === 'metadata_only') { + $status = $version->metadata['original_status'] ?? null; + + Notification::make() + ->title('Snapshot captured (metadata only)') + ->body(sprintf( + 'Microsoft Graph returned %s for this policy type, so only local metadata was saved. Full restore is not possible until Graph works again.', + $status ?? 'an error' + )) + ->warning() + ->send(); + } else { + Notification::make() + ->title('Snapshot captured successfully.') + ->success() + ->send(); + } $this->redirect($this->getResource()::getUrl('view', ['record' => $policy->getKey()])); } catch (\Throwable $e) { diff --git a/app/Filament/Resources/PolicyResource/RelationManagers/VersionsRelationManager.php b/app/Filament/Resources/PolicyResource/RelationManagers/VersionsRelationManager.php index 56f42cc..5a340ab 100644 --- a/app/Filament/Resources/PolicyResource/RelationManagers/VersionsRelationManager.php +++ b/app/Filament/Resources/PolicyResource/RelationManagers/VersionsRelationManager.php @@ -34,6 +34,8 @@ public function table(Table $table): Table ->label('Restore to Intune') ->icon('heroicon-o-arrow-path-rounded-square') ->color('danger') + ->disabled(fn (PolicyVersion $record): bool => ($record->metadata['source'] ?? null) === 'metadata_only') + ->tooltip('Disabled for metadata-only snapshots (Graph did not provide policy settings).') ->requiresConfirmation() ->modalHeading(fn (PolicyVersion $record): string => "Restore version {$record->version_number} to Intune?") ->modalSubheading('Creates a restore run using this policy version snapshot.') diff --git a/app/Filament/Resources/PolicyVersionResource.php b/app/Filament/Resources/PolicyVersionResource.php index 4bab649..9d6259a 100644 --- a/app/Filament/Resources/PolicyVersionResource.php +++ b/app/Filament/Resources/PolicyVersionResource.php @@ -74,7 +74,7 @@ public static function infolist(Schema $schema): Schema return $normalized; }) - ->visible(fn (PolicyVersion $record) => ($record->policy_type ?? '') === 'settingsCatalogPolicy'), + ->visible(fn (PolicyVersion $record) => in_array($record->policy_type, ['settingsCatalogPolicy', 'endpointSecurityPolicy', 'securityBaselinePolicy'], true)), Infolists\Components\ViewEntry::make('normalized_settings_standard') ->view('filament.infolists.entries.policy-settings-standard') @@ -87,10 +87,11 @@ public static function infolist(Schema $schema): Schema $normalized['context'] = 'version'; $normalized['record_id'] = (string) $record->getKey(); + $normalized['policy_type'] = $record->policy_type; return $normalized; }) - ->visible(fn (PolicyVersion $record) => ($record->policy_type ?? '') !== 'settingsCatalogPolicy'), + ->visible(fn (PolicyVersion $record) => ! in_array($record->policy_type, ['settingsCatalogPolicy', 'endpointSecurityPolicy', 'securityBaselinePolicy'], true)), ]), Tab::make('Raw JSON') ->id('raw-json') @@ -114,7 +115,10 @@ public static function infolist(Schema $schema): Schema : []; $to = $normalizer->flattenForDiff($record->snapshot ?? [], $record->policy_type ?? '', $record->platform); - return $diff->compare($from, $to); + $result = $diff->compare($from, $to); + $result['policy_type'] = $record->policy_type; + + return $result; }), Infolists\Components\ViewEntry::make('diff_json') ->label('Raw diff (advanced)') @@ -182,14 +186,14 @@ public static function table(Table $table): Table ->falseLabel('Archived'), ]) ->actions([ - Actions\ViewAction::make() - ->url(fn (PolicyVersion $record) => static::getUrl('view', ['record' => $record])) - ->openUrlInNewTab(false), + Actions\ViewAction::make(), Actions\ActionGroup::make([ Actions\Action::make('restore_via_wizard') ->label('Restore via Wizard') ->icon('heroicon-o-arrow-path-rounded-square') ->color('primary') + ->disabled(fn (PolicyVersion $record): bool => ($record->metadata['source'] ?? null) === 'metadata_only') + ->tooltip('Disabled for metadata-only snapshots (Graph did not provide policy settings).') ->requiresConfirmation() ->modalHeading(fn (PolicyVersion $record): string => "Restore version {$record->version_number} via wizard?") ->modalSubheading('Creates a 1-item backup set from this snapshot and opens the restore run wizard prefilled.') diff --git a/app/Filament/Resources/TenantResource.php b/app/Filament/Resources/TenantResource.php index dd61427..f32bd90 100644 --- a/app/Filament/Resources/TenantResource.php +++ b/app/Filament/Resources/TenantResource.php @@ -4,13 +4,18 @@ use App\Filament\Resources\TenantResource\Pages; use App\Http\Controllers\RbacDelegatedAuthController; +use App\Jobs\BulkTenantSyncJob; +use App\Jobs\SyncPoliciesJob; use App\Models\Tenant; +use App\Models\User; +use App\Services\BulkOperationService; use App\Services\Graph\GraphClientInterface; use App\Services\Intune\AuditLogger; use App\Services\Intune\RbacHealthService; use App\Services\Intune\RbacOnboardingService; use App\Services\Intune\TenantConfigService; use App\Services\Intune\TenantPermissionService; +use App\Support\TenantRole; use BackedEnum; use Filament\Actions; use Filament\Actions\ActionGroup; @@ -23,6 +28,8 @@ use Filament\Schemas\Schema; use Filament\Tables; use Filament\Tables\Table; +use Illuminate\Database\Eloquent\Builder; +use Illuminate\Support\Collection; use Illuminate\Support\Facades\Cache; use Illuminate\Support\Facades\Log; use Illuminate\Support\Str; @@ -33,6 +40,8 @@ class TenantResource extends Resource { protected static ?string $model = Tenant::class; + protected static bool $isScopedToTenant = false; + protected static string|BackedEnum|null $navigationIcon = 'heroicon-o-building-office-2'; protected static string|UnitEnum|null $navigationGroup = 'Settings'; @@ -44,6 +53,15 @@ public static function form(Schema $schema): Schema Forms\Components\TextInput::make('name') ->required() ->maxLength(255), + Forms\Components\Select::make('environment') + ->options([ + 'prod' => 'PROD', + 'dev' => 'DEV', + 'staging' => 'STAGING', + 'other' => 'Other', + ]) + ->default('other') + ->required(), Forms\Components\TextInput::make('tenant_id') ->label('Tenant ID (GUID)') ->required() @@ -69,10 +87,28 @@ public static function form(Schema $schema): Schema ]); } + public static function getEloquentQuery(): Builder + { + $user = auth()->user(); + + if (! $user instanceof User) { + return parent::getEloquentQuery()->whereRaw('1 = 0'); + } + + $tenantIds = $user->tenants() + ->withTrashed() + ->pluck('tenants.id'); + + return parent::getEloquentQuery() + ->withTrashed() + ->whereIn('id', $tenantIds) + ->withCount('policies') + ->withMax('policies as last_policy_sync_at', 'last_synced_at'); + } + public static function table(Table $table): Table { return $table - ->modifyQueryUsing(fn (\Illuminate\Database\Eloquent\Builder $query) => $query->withTrashed()) ->columns([ Tables\Columns\TextColumn::make('name') ->searchable(), @@ -80,6 +116,23 @@ public static function table(Table $table): Table ->label('Tenant ID') ->copyable() ->searchable(), + Tables\Columns\TextColumn::make('environment') + ->badge() + ->color(fn (?string $state) => match ($state) { + 'prod' => 'danger', + 'dev' => 'warning', + 'staging' => 'info', + default => 'gray', + }) + ->sortable(), + Tables\Columns\TextColumn::make('policies_count') + ->label('Policies') + ->numeric() + ->sortable(), + Tables\Columns\TextColumn::make('last_policy_sync_at') + ->label('Last Sync') + ->since() + ->sortable(), Tables\Columns\TextColumn::make('domain') ->copyable() ->toggleable(), @@ -102,6 +155,13 @@ public static function table(Table $table): Table ->trueLabel('All') ->falseLabel('Archived') ->default(true), + Tables\Filters\SelectFilter::make('environment') + ->options([ + 'prod' => 'PROD', + 'dev' => 'DEV', + 'staging' => 'STAGING', + 'other' => 'Other', + ]), Tables\Filters\SelectFilter::make('app_status') ->options([ 'ok' => 'OK', @@ -113,6 +173,51 @@ public static function table(Table $table): Table ->actions([ Actions\ViewAction::make(), ActionGroup::make([ + Actions\Action::make('syncTenant') + ->label('Sync') + ->icon('heroicon-o-arrow-path') + ->color('warning') + ->requiresConfirmation() + ->visible(function (Tenant $record): bool { + if (! $record->isActive()) { + return false; + } + + $user = auth()->user(); + + if (! $user instanceof User) { + return false; + } + + return $user->canSyncTenant($record); + }) + ->action(function (Tenant $record, AuditLogger $auditLogger): void { + SyncPoliciesJob::dispatch($record->getKey()); + + $auditLogger->log( + tenant: $record, + action: 'tenant.sync_dispatched', + resourceType: 'tenant', + resourceId: (string) $record->id, + status: 'success', + context: ['metadata' => ['tenant_id' => $record->tenant_id]], + ); + + Notification::make() + ->title('Sync started') + ->body("Sync dispatched for {$record->name}.") + ->icon('heroicon-o-arrow-path') + ->iconColor('warning') + ->success() + ->sendToDatabase(auth()->user()) + ->send(); + }), + Actions\Action::make('openTenant') + ->label('Open') + ->icon('heroicon-o-arrow-right') + ->color('primary') + ->url(fn (Tenant $record) => \App\Filament\Resources\PolicyResource::getUrl('index', tenant: $record)) + ->visible(fn (Tenant $record) => $record->isActive()), Actions\EditAction::make(), Actions\RestoreAction::make() ->label('Restore') @@ -157,6 +262,12 @@ public static function table(Table $table): Table ->url(fn (Tenant $record) => static::adminConsentUrl($record)) ->visible(fn (Tenant $record) => static::adminConsentUrl($record) !== null) ->openUrlInNewTab(), + Actions\Action::make('open_in_entra') + ->label('Open in Entra') + ->icon('heroicon-o-arrow-top-right-on-square') + ->url(fn (Tenant $record) => static::entraUrl($record)) + ->visible(fn (Tenant $record) => static::entraUrl($record) !== null) + ->openUrlInNewTab(), Actions\Action::make('verify') ->label('Verify configuration') ->icon('heroicon-o-check-badge') @@ -236,7 +347,106 @@ public static function table(Table $table): Table }), ])->icon('heroicon-o-ellipsis-vertical'), ]) - ->bulkActions([]) + ->bulkActions([ + Actions\BulkAction::make('syncSelected') + ->label('Sync selected') + ->icon('heroicon-o-arrow-path') + ->color('warning') + ->requiresConfirmation() + ->visible(function (): bool { + $user = auth()->user(); + + if (! $user instanceof User) { + return false; + } + + return $user->tenants() + ->whereIn('role', [ + TenantRole::Owner->value, + TenantRole::Manager->value, + TenantRole::Operator->value, + ]) + ->exists(); + }) + ->authorize(function (): bool { + $user = auth()->user(); + + if (! $user instanceof User) { + return false; + } + + return $user->tenants() + ->whereIn('role', [ + TenantRole::Owner->value, + TenantRole::Manager->value, + TenantRole::Operator->value, + ]) + ->exists(); + }) + ->action(function (Collection $records, AuditLogger $auditLogger): void { + $user = auth()->user(); + + if (! $user instanceof User) { + return; + } + + $eligible = $records + ->filter(fn ($record) => $record instanceof Tenant && $record->isActive()) + ->filter(fn (Tenant $tenant) => $user->canSyncTenant($tenant)); + + if ($eligible->isEmpty()) { + Notification::make() + ->title('Bulk sync skipped') + ->body('No eligible tenants selected.') + ->icon('heroicon-o-information-circle') + ->info() + ->sendToDatabase($user) + ->send(); + + return; + } + + $tenantContext = Tenant::current() ?? $eligible->first(); + + if (! $tenantContext) { + return; + } + + $ids = $eligible->pluck('id')->toArray(); + $count = $eligible->count(); + + $service = app(BulkOperationService::class); + $run = $service->createRun($tenantContext, $user, 'tenant', 'sync', $ids, $count); + + foreach ($eligible as $tenant) { + SyncPoliciesJob::dispatch($tenant->getKey()); + + $auditLogger->log( + tenant: $tenant, + action: 'tenant.sync_dispatched', + resourceType: 'tenant', + resourceId: (string) $tenant->id, + status: 'success', + context: ['metadata' => ['tenant_id' => $tenant->tenant_id]], + ); + } + + $count = $eligible->count(); + + Notification::make() + ->title('Bulk sync started') + ->body("Syncing {$count} tenant(s) in the background. Check the progress bar in the bottom right corner.") + ->icon('heroicon-o-arrow-path') + ->iconColor('warning') + ->success() + ->duration(8000) + ->sendToDatabase($user) + ->send(); + + BulkTenantSyncJob::dispatch($run->id); + }) + ->deselectRecordsAfterCompletion(), + ]) ->headerActions([]); } @@ -434,7 +644,10 @@ public static function rbacAction(): Actions\Action ->label('Open RBAC login') ->url(route('admin.rbac.start', [ 'tenant' => $record->graphTenantId(), - 'return' => route('filament.admin.resources.tenants.view', $record), + 'return' => route('filament.admin.resources.tenants.view', [ + 'tenant' => $record->external_id, + 'record' => $record, + ]), ])), ]) ->warning() @@ -573,7 +786,10 @@ private static function loginToSearchRolesAction(?Tenant $tenant): ?Actions\Acti ->label('Login to load roles') ->url(route('admin.rbac.start', [ 'tenant' => $tenant->graphTenantId(), - 'return' => route('filament.admin.resources.tenants.view', $tenant), + 'return' => route('filament.admin.resources.tenants.view', [ + 'tenant' => $tenant->external_id, + 'record' => $tenant, + ]), ])); } @@ -755,7 +971,10 @@ private static function loginToSearchGroupsAction(?Tenant $tenant): ?Actions\Act ->label('Login to search groups') ->url(route('admin.rbac.start', [ 'tenant' => $tenant->graphTenantId(), - 'return' => route('filament.admin.resources.tenants.view', $tenant), + 'return' => route('filament.admin.resources.tenants.view', [ + 'tenant' => $tenant->external_id, + 'record' => $tenant, + ]), ])); } diff --git a/app/Filament/Resources/TenantResource/Pages/CreateTenant.php b/app/Filament/Resources/TenantResource/Pages/CreateTenant.php index 2a0c9ed..6e592c6 100644 --- a/app/Filament/Resources/TenantResource/Pages/CreateTenant.php +++ b/app/Filament/Resources/TenantResource/Pages/CreateTenant.php @@ -3,9 +3,24 @@ namespace App\Filament\Resources\TenantResource\Pages; use App\Filament\Resources\TenantResource; +use App\Models\User; +use App\Support\TenantRole; use Filament\Resources\Pages\CreateRecord; class CreateTenant extends CreateRecord { protected static string $resource = TenantResource::class; + + protected function afterCreate(): void + { + $user = auth()->user(); + + if (! $user instanceof User) { + return; + } + + $user->tenants()->syncWithoutDetaching([ + $this->record->getKey() => ['role' => TenantRole::Owner->value], + ]); + } } diff --git a/app/Filament/Resources/TenantResource/Pages/ViewTenant.php b/app/Filament/Resources/TenantResource/Pages/ViewTenant.php index cad7dac..d65f9d9 100644 --- a/app/Filament/Resources/TenantResource/Pages/ViewTenant.php +++ b/app/Filament/Resources/TenantResource/Pages/ViewTenant.php @@ -9,6 +9,7 @@ use App\Services\Intune\TenantConfigService; use App\Services\Intune\TenantPermissionService; use Filament\Actions; +use Filament\Notifications\Notification; use Filament\Resources\Pages\ViewRecord; class ViewTenant extends ViewRecord @@ -18,34 +19,63 @@ class ViewTenant extends ViewRecord protected function getHeaderActions(): array { return [ - Actions\EditAction::make(), - Actions\Action::make('admin_consent') - ->label('Admin consent') - ->icon('heroicon-o-clipboard-document') - ->url(fn (Tenant $record) => TenantResource::adminConsentUrl($record)) - ->visible(fn (Tenant $record) => TenantResource::adminConsentUrl($record) !== null) - ->openUrlInNewTab(), - Actions\Action::make('open_in_entra') - ->label('Open in Entra') - ->icon('heroicon-o-arrow-top-right-on-square') - ->url(fn (Tenant $record) => TenantResource::entraUrl($record)) - ->visible(fn (Tenant $record) => TenantResource::entraUrl($record) !== null) - ->openUrlInNewTab(), - Actions\Action::make('verify') - ->label('Verify configuration') - ->icon('heroicon-o-check-badge') - ->color('primary') - ->requiresConfirmation() - ->action(function ( - Tenant $record, - TenantConfigService $configService, - TenantPermissionService $permissionService, - RbacHealthService $rbacHealthService, - AuditLogger $auditLogger - ) { - TenantResource::verifyTenant($record, $configService, $permissionService, $rbacHealthService, $auditLogger); - }), - TenantResource::rbacAction(), + Actions\ActionGroup::make([ + Actions\EditAction::make(), + Actions\Action::make('admin_consent') + ->label('Admin consent') + ->icon('heroicon-o-clipboard-document') + ->url(fn (Tenant $record) => TenantResource::adminConsentUrl($record)) + ->visible(fn (Tenant $record) => TenantResource::adminConsentUrl($record) !== null) + ->openUrlInNewTab(), + Actions\Action::make('open_in_entra') + ->label('Open in Entra') + ->icon('heroicon-o-arrow-top-right-on-square') + ->url(fn (Tenant $record) => TenantResource::entraUrl($record)) + ->visible(fn (Tenant $record) => TenantResource::entraUrl($record) !== null) + ->openUrlInNewTab(), + Actions\Action::make('verify') + ->label('Verify configuration') + ->icon('heroicon-o-check-badge') + ->color('primary') + ->requiresConfirmation() + ->action(function ( + Tenant $record, + TenantConfigService $configService, + TenantPermissionService $permissionService, + RbacHealthService $rbacHealthService, + AuditLogger $auditLogger + ) { + TenantResource::verifyTenant($record, $configService, $permissionService, $rbacHealthService, $auditLogger); + }), + TenantResource::rbacAction(), + Actions\Action::make('archive') + ->label('Deactivate') + ->color('danger') + ->icon('heroicon-o-archive-box-x-mark') + ->requiresConfirmation() + ->visible(fn (Tenant $record) => ! $record->trashed()) + ->action(function (Tenant $record, AuditLogger $auditLogger) { + $record->delete(); + + $auditLogger->log( + tenant: $record, + action: 'tenant.archived', + resourceType: 'tenant', + resourceId: (string) $record->id, + status: 'success', + context: ['metadata' => ['tenant_id' => $record->tenant_id]] + ); + + Notification::make() + ->title('Tenant deactivated') + ->body('The tenant has been archived and hidden from lists.') + ->success() + ->send(); + }), + ]) + ->label('Actions') + ->icon('heroicon-o-ellipsis-vertical') + ->color('gray'), ]; } } diff --git a/app/Jobs/ApplyBackupScheduleRetentionJob.php b/app/Jobs/ApplyBackupScheduleRetentionJob.php new file mode 100644 index 0000000..09f98bf --- /dev/null +++ b/app/Jobs/ApplyBackupScheduleRetentionJob.php @@ -0,0 +1,100 @@ +with('tenant') + ->find($this->backupScheduleId); + + if (! $schedule || ! $schedule->tenant) { + return; + } + + $keepLast = (int) ($schedule->retention_keep_last ?? 30); + + if ($keepLast < 1) { + $keepLast = 1; + } + + /** @var Collection $keepBackupSetIds */ + $keepBackupSetIds = BackupScheduleRun::query() + ->where('backup_schedule_id', $schedule->id) + ->whereNotNull('backup_set_id') + ->orderByDesc('scheduled_for') + ->limit($keepLast) + ->pluck('backup_set_id') + ->filter() + ->values(); + + /** @var Collection $deleteBackupSetIds */ + $deleteBackupSetIds = BackupScheduleRun::query() + ->where('backup_schedule_id', $schedule->id) + ->whereNotNull('backup_set_id') + ->when($keepBackupSetIds->isNotEmpty(), fn ($query) => $query->whereNotIn('backup_set_id', $keepBackupSetIds->all())) + ->pluck('backup_set_id') + ->filter() + ->unique() + ->values(); + + if ($deleteBackupSetIds->isEmpty()) { + $auditLogger->log( + tenant: $schedule->tenant, + action: 'backup_schedule.retention_applied', + resourceType: 'backup_schedule', + resourceId: (string) $schedule->id, + status: 'success', + context: [ + 'metadata' => [ + 'keep_last' => $keepLast, + 'deleted_backup_sets' => 0, + ], + ], + ); + + return; + } + + $deletedCount = 0; + + BackupSet::query() + ->where('tenant_id', $schedule->tenant_id) + ->whereIn('id', $deleteBackupSetIds->all()) + ->whereNull('deleted_at') + ->chunkById(200, function (Collection $sets) use (&$deletedCount): void { + foreach ($sets as $set) { + $set->delete(); + $deletedCount++; + } + }); + + $auditLogger->log( + tenant: $schedule->tenant, + action: 'backup_schedule.retention_applied', + resourceType: 'backup_schedule', + resourceId: (string) $schedule->id, + status: 'success', + context: [ + 'metadata' => [ + 'keep_last' => $keepLast, + 'deleted_backup_sets' => $deletedCount, + ], + ], + ); + } +} diff --git a/app/Jobs/BulkTenantSyncJob.php b/app/Jobs/BulkTenantSyncJob.php new file mode 100644 index 0000000..50fd31e --- /dev/null +++ b/app/Jobs/BulkTenantSyncJob.php @@ -0,0 +1,152 @@ +find($this->bulkRunId); + + if (! $run || $run->status !== 'pending') { + return; + } + + $service->start($run); + + try { + $chunkSize = max(1, (int) config('tenantpilot.bulk_operations.chunk_size', 10)); + $itemCount = 0; + + $supported = config('tenantpilot.supported_policy_types'); + + $totalItems = $run->total_items ?: count($run->item_ids ?? []); + $failureThreshold = (int) floor($totalItems / 2); + + foreach (($run->item_ids ?? []) as $tenantId) { + $itemCount++; + + try { + $tenant = Tenant::query()->whereKey($tenantId)->first(); + + if (! $tenant) { + $service->recordFailure($run, (string) $tenantId, 'Tenant not found'); + + if ($run->failed > $failureThreshold) { + $service->abort($run, 'Circuit breaker: more than 50% of items failed.'); + + if ($run->user) { + Notification::make() + ->title('Bulk Sync Aborted') + ->body('Circuit breaker triggered: too many failures (>50%).') + ->icon('heroicon-o-exclamation-triangle') + ->danger() + ->sendToDatabase($run->user) + ->send(); + } + + return; + } + + continue; + } + + if (! $tenant->isActive()) { + $service->recordSkippedWithReason($run, (string) $tenantId, 'Tenant is not active'); + + continue; + } + + if (! $run->user || ! $run->user->canSyncTenant($tenant)) { + $service->recordSkippedWithReason($run, (string) $tenantId, 'Not authorized to sync tenant'); + + continue; + } + + $syncService->syncPolicies($tenant, $supported); + + $service->recordSuccess($run); + } catch (Throwable $e) { + $service->recordFailure($run, (string) $tenantId, $e->getMessage()); + + if ($run->failed > $failureThreshold) { + $service->abort($run, 'Circuit breaker: more than 50% of items failed.'); + + if ($run->user) { + Notification::make() + ->title('Bulk Sync Aborted') + ->body('Circuit breaker triggered: too many failures (>50%).') + ->icon('heroicon-o-exclamation-triangle') + ->danger() + ->sendToDatabase($run->user) + ->send(); + } + + return; + } + } + + if ($itemCount % $chunkSize === 0) { + $run->refresh(); + } + } + + $service->complete($run); + + if ($run->user) { + $message = "Synced {$run->succeeded} tenant(s)"; + + if ($run->skipped > 0) { + $message .= " ({$run->skipped} skipped)"; + } + + if ($run->failed > 0) { + $message .= " ({$run->failed} failed)"; + } + + $message .= '.'; + + Notification::make() + ->title('Bulk Sync Completed') + ->body($message) + ->icon('heroicon-o-check-circle') + ->success() + ->sendToDatabase($run->user) + ->send(); + } + } catch (Throwable $e) { + $service->fail($run, $e->getMessage()); + + $run->refresh(); + $run->load('user'); + + if ($run->user) { + Notification::make() + ->title('Bulk Sync Failed') + ->body($e->getMessage()) + ->icon('heroicon-o-x-circle') + ->danger() + ->sendToDatabase($run->user) + ->send(); + } + + throw $e; + } + } +} diff --git a/app/Jobs/RunBackupScheduleJob.php b/app/Jobs/RunBackupScheduleJob.php new file mode 100644 index 0000000..23c802b --- /dev/null +++ b/app/Jobs/RunBackupScheduleJob.php @@ -0,0 +1,399 @@ +with(['schedule', 'tenant', 'user']) + ->find($this->backupScheduleRunId); + + if (! $run) { + return; + } + + $bulkRun = $this->bulkRunId + ? BulkOperationRun::query()->with(['tenant', 'user'])->find($this->bulkRunId) + : null; + + if ( + $bulkRun + && ($bulkRun->tenant_id !== $run->tenant_id || $bulkRun->user_id !== $run->user_id) + ) { + $bulkRun = null; + } + + if ($bulkRun && $bulkRun->status === 'pending') { + $bulkOperationService->start($bulkRun); + } + + $schedule = $run->schedule; + + if (! $schedule instanceof BackupSchedule) { + $run->update([ + 'status' => BackupScheduleRun::STATUS_FAILED, + 'error_code' => RunErrorMapper::ERROR_UNKNOWN, + 'error_message' => 'Schedule not found.', + 'finished_at' => CarbonImmutable::now('UTC'), + ]); + + return; + } + + $tenant = $run->tenant; + + if (! $tenant) { + $run->update([ + 'status' => BackupScheduleRun::STATUS_FAILED, + 'error_code' => RunErrorMapper::ERROR_UNKNOWN, + 'error_message' => 'Tenant not found.', + 'finished_at' => CarbonImmutable::now('UTC'), + ]); + + return; + } + + $lock = Cache::lock("backup_schedule:{$schedule->id}", 900); + + if (! $lock->get()) { + $this->finishRun( + run: $run, + schedule: $schedule, + status: BackupScheduleRun::STATUS_SKIPPED, + errorCode: 'CONCURRENT_RUN', + errorMessage: 'Another run is already in progress for this schedule.', + summary: ['reason' => 'concurrent_run'], + scheduleTimeService: $scheduleTimeService, + bulkRunId: $this->bulkRunId, + ); + + return; + } + + try { + $nowUtc = CarbonImmutable::now('UTC'); + + $run->forceFill([ + 'started_at' => $run->started_at ?? $nowUtc, + 'status' => BackupScheduleRun::STATUS_RUNNING, + ])->save(); + + $this->notifyRunStarted($run, $schedule); + + $auditLogger->log( + tenant: $tenant, + action: 'backup_schedule.run_started', + context: [ + 'metadata' => [ + 'backup_schedule_id' => $schedule->id, + 'backup_schedule_run_id' => $run->id, + 'scheduled_for' => $run->scheduled_for?->toDateTimeString(), + ], + ], + resourceType: 'backup_schedule_run', + resourceId: (string) $run->id, + status: 'success' + ); + + $runtime = $policyTypeResolver->resolveRuntime((array) ($schedule->policy_types ?? [])); + $validTypes = $runtime['valid']; + $unknownTypes = $runtime['unknown']; + + if (empty($validTypes)) { + $this->finishRun( + run: $run, + schedule: $schedule, + status: BackupScheduleRun::STATUS_SKIPPED, + errorCode: 'UNKNOWN_POLICY_TYPE', + errorMessage: 'All configured policy types are unknown.', + summary: [ + 'unknown_policy_types' => $unknownTypes, + ], + scheduleTimeService: $scheduleTimeService, + bulkRunId: $this->bulkRunId, + ); + + return; + } + + $supported = array_values(array_filter( + config('tenantpilot.supported_policy_types', []), + fn (array $typeConfig): bool => in_array($typeConfig['type'] ?? null, $validTypes, true), + )); + + $syncReport = $policySyncService->syncPoliciesWithReport($tenant, $supported); + + $policyIds = $syncReport['synced'] ?? []; + $syncFailures = $syncReport['failures'] ?? []; + + $backupSet = $backupService->createBackupSet( + tenant: $tenant, + policyIds: $policyIds, + actorEmail: null, + actorName: null, + name: 'Scheduled backup: '.$schedule->name, + includeAssignments: false, + includeScopeTags: false, + includeFoundations: (bool) ($schedule->include_foundations ?? false), + ); + + $status = match ($backupSet->status) { + 'completed' => BackupScheduleRun::STATUS_SUCCESS, + 'partial' => BackupScheduleRun::STATUS_PARTIAL, + 'failed' => BackupScheduleRun::STATUS_FAILED, + default => BackupScheduleRun::STATUS_SUCCESS, + }; + + $errorCode = null; + $errorMessage = null; + + $summary = [ + 'policies_total' => count($policyIds), + 'policies_backed_up' => (int) ($backupSet->item_count ?? 0), + 'sync_failures' => $syncFailures, + ]; + + if (! empty($unknownTypes)) { + $status = BackupScheduleRun::STATUS_PARTIAL; + $errorCode = 'UNKNOWN_POLICY_TYPE'; + $errorMessage = 'Some configured policy types are unknown and were skipped.'; + $summary['unknown_policy_types'] = $unknownTypes; + } + + $this->finishRun( + run: $run, + schedule: $schedule, + status: $status, + errorCode: $errorCode, + errorMessage: $errorMessage, + summary: $summary, + scheduleTimeService: $scheduleTimeService, + backupSetId: (string) $backupSet->id, + bulkRunId: $this->bulkRunId, + ); + + $auditLogger->log( + tenant: $tenant, + action: 'backup_schedule.run_finished', + context: [ + 'metadata' => [ + 'backup_schedule_id' => $schedule->id, + 'backup_schedule_run_id' => $run->id, + 'status' => $status, + 'error_code' => $errorCode, + ], + ], + resourceType: 'backup_schedule_run', + resourceId: (string) $run->id, + status: in_array($status, [BackupScheduleRun::STATUS_SUCCESS], true) ? 'success' : 'partial' + ); + } catch (\Throwable $throwable) { + $attempt = (int) method_exists($this, 'attempts') ? $this->attempts() : 1; + $mapped = $errorMapper->map($throwable, $attempt, $this->tries); + + if ($mapped['shouldRetry']) { + $this->release($mapped['delay']); + + return; + } + + $this->finishRun( + run: $run, + schedule: $schedule, + status: BackupScheduleRun::STATUS_FAILED, + errorCode: $mapped['error_code'], + errorMessage: $mapped['error_message'], + summary: [ + 'exception' => get_class($throwable), + 'attempt' => $attempt, + ], + scheduleTimeService: $scheduleTimeService, + bulkRunId: $this->bulkRunId, + ); + + $auditLogger->log( + tenant: $tenant, + action: 'backup_schedule.run_failed', + context: [ + 'metadata' => [ + 'backup_schedule_id' => $schedule->id, + 'backup_schedule_run_id' => $run->id, + 'error_code' => $mapped['error_code'], + ], + ], + resourceType: 'backup_schedule_run', + resourceId: (string) $run->id, + status: 'failed' + ); + } finally { + optional($lock)->release(); + } + } + + private function notifyRunStarted(BackupScheduleRun $run, BackupSchedule $schedule): void + { + $user = $run->user; + + if (! $user) { + return; + } + + $notification = Notification::make() + ->title('Backup started') + ->body(sprintf('Schedule "%s" has started.', $schedule->name)) + ->info(); + + $user->notifyNow($notification->toDatabase()); + DatabaseNotificationsSent::dispatch($user); + } + + private function notifyRunFinished(BackupScheduleRun $run, BackupSchedule $schedule): void + { + $user = $run->user; + + if (! $user) { + return; + } + + $title = match ($run->status) { + BackupScheduleRun::STATUS_SUCCESS => 'Backup completed', + BackupScheduleRun::STATUS_PARTIAL => 'Backup completed (partial)', + BackupScheduleRun::STATUS_SKIPPED => 'Backup skipped', + default => 'Backup failed', + }; + + $notification = Notification::make() + ->title($title) + ->body(sprintf('Schedule "%s" finished with status: %s.', $schedule->name, $run->status)); + + if (filled($run->error_message)) { + $notification->body($notification->getBody()."\n".$run->error_message); + } + + match ($run->status) { + BackupScheduleRun::STATUS_SUCCESS => $notification->success(), + BackupScheduleRun::STATUS_PARTIAL, BackupScheduleRun::STATUS_SKIPPED => $notification->warning(), + default => $notification->danger(), + }; + + $user->notifyNow($notification->toDatabase()); + DatabaseNotificationsSent::dispatch($user); + } + + private function finishRun( + BackupScheduleRun $run, + BackupSchedule $schedule, + string $status, + ?string $errorCode, + ?string $errorMessage, + array $summary, + ScheduleTimeService $scheduleTimeService, + ?string $backupSetId = null, + ?int $bulkRunId = null, + ): void { + $nowUtc = CarbonImmutable::now('UTC'); + + $run->forceFill([ + 'status' => $status, + 'error_code' => $errorCode, + 'error_message' => $errorMessage, + 'summary' => Arr::wrap($summary), + 'finished_at' => $nowUtc, + 'backup_set_id' => $backupSetId, + ])->save(); + + $schedule->forceFill([ + 'last_run_at' => $nowUtc, + 'last_run_status' => $status, + 'next_run_at' => $scheduleTimeService->nextRunFor($schedule, $nowUtc), + ])->saveQuietly(); + + $this->notifyRunFinished($run, $schedule); + + if ($bulkRunId) { + $bulkRun = BulkOperationRun::query()->with(['tenant', 'user'])->find($bulkRunId); + + if ( + $bulkRun + && ($bulkRun->tenant_id === $run->tenant_id) + && ($bulkRun->user_id === $run->user_id) + && in_array($bulkRun->status, ['pending', 'running'], true) + ) { + $service = app(BulkOperationService::class); + + $itemId = (string) $run->backup_schedule_id; + + match ($status) { + BackupScheduleRun::STATUS_SUCCESS => $service->recordSuccess($bulkRun), + BackupScheduleRun::STATUS_SKIPPED => $service->recordSkippedWithReason( + $bulkRun, + $itemId, + $errorMessage ?: 'Skipped', + ), + BackupScheduleRun::STATUS_PARTIAL => $service->recordFailure( + $bulkRun, + $itemId, + $errorMessage ?: 'Completed partially', + ), + default => $service->recordFailure( + $bulkRun, + $itemId, + $errorMessage ?: ($errorCode ?: 'Failed'), + ), + }; + + $bulkRun->refresh(); + if ( + in_array($bulkRun->status, ['pending', 'running'], true) + && $bulkRun->processed_items >= $bulkRun->total_items + ) { + $service->complete($bulkRun); + } + } + } + + if ($backupSetId && in_array($status, [BackupScheduleRun::STATUS_SUCCESS, BackupScheduleRun::STATUS_PARTIAL], true)) { + Bus::dispatch(new ApplyBackupScheduleRetentionJob($schedule->id)); + } + } +} diff --git a/app/Livewire/BackupSetPolicyPickerTable.php b/app/Livewire/BackupSetPolicyPickerTable.php new file mode 100644 index 0000000..fdf340c --- /dev/null +++ b/app/Livewire/BackupSetPolicyPickerTable.php @@ -0,0 +1,277 @@ +backupSetId = $backupSetId; + } + + public static function externalIdShort(?string $externalId): string + { + $value = (string) ($externalId ?? ''); + + $normalized = preg_replace('/[^A-Za-z0-9]/', '', $value) ?? ''; + + if ($normalized === '') { + return '—'; + } + + return substr($normalized, -8); + } + + public function table(Table $table): Table + { + $backupSet = BackupSet::query()->find($this->backupSetId); + $tenantId = $backupSet?->tenant_id ?? Tenant::current()->getKey(); + $existingPolicyIds = $backupSet + ? $backupSet->items()->pluck('policy_id')->filter()->all() + : []; + + return $table + ->queryStringIdentifier('backupSetPolicyPicker'.Str::studly((string) $this->backupSetId)) + ->query( + Policy::query() + ->where('tenant_id', $tenantId) + ->when($existingPolicyIds !== [], fn (Builder $query) => $query->whereNotIn('id', $existingPolicyIds)) + ) + ->deferLoading(! app()->runningUnitTests()) + ->paginated([25, 50, 100]) + ->defaultPaginationPageOption(25) + ->searchable() + ->striped() + ->columns([ + TextColumn::make('display_name') + ->label('Name') + ->searchable() + ->sortable() + ->wrap(), + TextColumn::make('policy_type') + ->label('Type') + ->badge() + ->formatStateUsing(fn (?string $state): string => (string) (static::typeMeta($state)['label'] ?? $state ?? '—')), + TextColumn::make('platform') + ->label('Platform') + ->badge() + ->default('—') + ->sortable(), + TextColumn::make('external_id') + ->label('External ID') + ->formatStateUsing(fn (?string $state): string => static::externalIdShort($state)) + ->tooltip(fn (?string $state): ?string => filled($state) ? $state : null) + ->extraAttributes(['class' => 'font-mono text-xs']) + ->toggleable(), + TextColumn::make('versions_count') + ->label('Versions') + ->state(fn (Policy $record): int => (int) ($record->versions_count ?? 0)) + ->badge() + ->sortable(), + TextColumn::make('last_synced_at') + ->label('Last synced') + ->dateTime() + ->since() + ->sortable() + ->toggleable(), + TextColumn::make('ignored_at') + ->label('Ignored') + ->badge() + ->color(fn (?string $state): string => filled($state) ? 'warning' : 'gray') + ->formatStateUsing(fn (?string $state): string => filled($state) ? 'yes' : 'no') + ->toggleable(isToggledHiddenByDefault: true), + ]) + ->modifyQueryUsing(fn (Builder $query) => $query->withCount('versions')) + ->filters([ + SelectFilter::make('policy_type') + ->label('Policy type') + ->options(static::policyTypeOptions()), + SelectFilter::make('platform') + ->label('Platform') + ->options(fn (): array => Policy::query() + ->where('tenant_id', $tenantId) + ->whereNotNull('platform') + ->distinct() + ->orderBy('platform') + ->pluck('platform', 'platform') + ->all()), + SelectFilter::make('synced_within') + ->label('Last synced') + ->options([ + '7' => 'Within 7 days', + '30' => 'Within 30 days', + '90' => 'Within 90 days', + 'any' => 'Any time', + ]) + ->default('7') + ->query(function (Builder $query, array $data): Builder { + $value = (string) ($data['value'] ?? '7'); + + if ($value === 'any') { + return $query; + } + + $days = is_numeric($value) ? (int) $value : 7; + + return $query->where('last_synced_at', '>', now()->subDays(max(1, $days))); + }), + TernaryFilter::make('ignored') + ->label('Ignored') + ->nullable() + ->queries( + true: fn (Builder $query) => $query->whereNotNull('ignored_at'), + false: fn (Builder $query) => $query->whereNull('ignored_at'), + ) + ->default(false), + SelectFilter::make('has_versions') + ->label('Has versions') + ->options([ + '1' => 'Has versions', + '0' => 'No versions', + ]) + ->query(function (Builder $query, array $data): Builder { + $value = $data['value'] ?? null; + + if ($value === null || $value === '') { + return $query; + } + + return match ((string) $value) { + '1' => $query->whereHas('versions'), + '0' => $query->whereDoesntHave('versions'), + default => $query, + }; + }), + ]) + ->bulkActions([ + BulkAction::make('add_selected_to_backup_set') + ->label('Add selected') + ->icon('heroicon-m-plus') + ->action(function (Collection $records, BackupService $service): void { + $backupSet = BackupSet::query()->findOrFail($this->backupSetId); + $tenant = $backupSet->tenant ?? Tenant::current(); + + $beforeFailures = (array) (($backupSet->metadata ?? [])['failures'] ?? []); + $beforeFailureCount = count($beforeFailures); + + $policyIds = $records->pluck('id')->all(); + + if ($policyIds === []) { + Notification::make() + ->title('No policies selected') + ->warning() + ->send(); + + return; + } + + $service->addPoliciesToSet( + tenant: $tenant, + backupSet: $backupSet, + policyIds: $policyIds, + actorEmail: auth()->user()?->email, + actorName: auth()->user()?->name, + includeAssignments: $this->include_assignments, + includeScopeTags: $this->include_scope_tags, + includeFoundations: $this->include_foundations, + ); + + $notificationTitle = $this->include_foundations + ? 'Backup items added' + : 'Policies added to backup'; + + $backupSet->refresh(); + + $afterFailures = (array) (($backupSet->metadata ?? [])['failures'] ?? []); + $afterFailureCount = count($afterFailures); + + if ($afterFailureCount > $beforeFailureCount) { + Notification::make() + ->title($notificationTitle.' with failures') + ->body('Some policies could not be captured from Microsoft Graph. Check the backup set failures list for details.') + ->warning() + ->send(); + } else { + Notification::make() + ->title($notificationTitle) + ->success() + ->send(); + } + + $this->resetTable(); + }), + ]); + } + + public function render(): View + { + return view('livewire.backup-set-policy-picker-table'); + } + + /** + * @return array{label:?string,category:?string,restore:?string,risk:?string}|array + */ + private static function typeMeta(?string $type): array + { + if ($type === null) { + return []; + } + + $types = array_merge( + config('tenantpilot.supported_policy_types', []), + config('tenantpilot.foundation_types', []) + ); + + return collect($types) + ->firstWhere('type', $type) ?? []; + } + + /** + * @return array + */ + private static function policyTypeOptions(): array + { + $types = array_merge( + config('tenantpilot.supported_policy_types', []), + config('tenantpilot.foundation_types', []) + ); + + return collect($types) + ->mapWithKeys(function (array $meta): array { + $type = (string) ($meta['type'] ?? ''); + + if ($type === '') { + return []; + } + + $label = (string) ($meta['label'] ?? $type); + + return [$type => $label]; + }) + ->all(); + } +} diff --git a/app/Livewire/BulkOperationProgress.php b/app/Livewire/BulkOperationProgress.php index 975a619..a7ef31d 100644 --- a/app/Livewire/BulkOperationProgress.php +++ b/app/Livewire/BulkOperationProgress.php @@ -2,8 +2,10 @@ namespace App\Livewire; +use App\Models\BackupScheduleRun; use App\Models\BulkOperationRun; use App\Models\Tenant; +use Illuminate\Support\Arr; use Livewire\Attributes\Computed; use Livewire\Component; @@ -13,9 +15,12 @@ class BulkOperationProgress extends Component public int $pollSeconds = 3; + public int $recentFinishedSeconds = 12; + public function mount() { $this->pollSeconds = max(1, min(10, (int) config('tenantpilot.bulk_operations.poll_interval_seconds', 3))); + $this->recentFinishedSeconds = max(3, min(60, (int) config('tenantpilot.bulk_operations.recent_finished_seconds', 12))); $this->loadRuns(); } @@ -35,12 +40,102 @@ public function loadRuns() return; } + $recentThreshold = now()->subSeconds($this->recentFinishedSeconds); + $this->runs = BulkOperationRun::query() ->where('tenant_id', $tenant->id) ->where('user_id', auth()->id()) - ->whereIn('status', ['pending', 'running']) + ->where(function ($query) use ($recentThreshold): void { + $query->whereIn('status', ['pending', 'running']) + ->orWhere(function ($query) use ($recentThreshold): void { + $query->whereIn('status', ['completed', 'completed_with_errors', 'failed', 'aborted']) + ->where('updated_at', '>=', $recentThreshold); + }); + }) ->orderByDesc('created_at') ->get(); + + $this->reconcileBackupScheduleRuns($tenant->id); + } + + private function reconcileBackupScheduleRuns(int $tenantId): void + { + $userId = auth()->id(); + + if (! $userId) { + return; + } + + $staleThreshold = now()->subSeconds(60); + + foreach ($this->runs as $bulkRun) { + if ($bulkRun->resource !== 'backup_schedule') { + continue; + } + + if (! in_array($bulkRun->status, ['pending', 'running'], true)) { + continue; + } + + if (! $bulkRun->created_at || $bulkRun->created_at->gt($staleThreshold)) { + continue; + } + + $scheduleId = (int) Arr::first($bulkRun->item_ids ?? []); + + if ($scheduleId <= 0) { + continue; + } + + $scheduleRun = BackupScheduleRun::query() + ->where('tenant_id', $tenantId) + ->where('user_id', $userId) + ->where('backup_schedule_id', $scheduleId) + ->where('created_at', '>=', $bulkRun->created_at) + ->orderByDesc('id') + ->first(); + + if (! $scheduleRun) { + continue; + } + + if ($scheduleRun->finished_at) { + $processed = 1; + $succeeded = 0; + $failed = 0; + $skipped = 0; + $status = 'completed'; + + switch ($scheduleRun->status) { + case BackupScheduleRun::STATUS_SUCCESS: + $succeeded = 1; + break; + + case BackupScheduleRun::STATUS_SKIPPED: + $skipped = 1; + break; + + default: + $failed = 1; + $status = 'completed_with_errors'; + break; + } + + $bulkRun->forceFill([ + 'status' => $status, + 'processed_items' => $processed, + 'succeeded' => $succeeded, + 'failed' => $failed, + 'skipped' => $skipped, + ])->save(); + + continue; + } + + if ($scheduleRun->started_at && $bulkRun->status === 'pending') { + $bulkRun->forceFill(['status' => 'running'])->save(); + } + } } public function render(): \Illuminate\Contracts\View\View diff --git a/app/Models/BackupSchedule.php b/app/Models/BackupSchedule.php new file mode 100644 index 0000000..66e4e21 --- /dev/null +++ b/app/Models/BackupSchedule.php @@ -0,0 +1,34 @@ + 'boolean', + 'include_foundations' => 'boolean', + 'days_of_week' => 'array', + 'policy_types' => 'array', + 'last_run_at' => 'datetime', + 'next_run_at' => 'datetime', + ]; + + public function tenant(): BelongsTo + { + return $this->belongsTo(Tenant::class); + } + + public function runs(): HasMany + { + return $this->hasMany(BackupScheduleRun::class); + } +} diff --git a/app/Models/BackupScheduleRun.php b/app/Models/BackupScheduleRun.php new file mode 100644 index 0000000..4091b20 --- /dev/null +++ b/app/Models/BackupScheduleRun.php @@ -0,0 +1,53 @@ + 'datetime', + 'started_at' => 'datetime', + 'finished_at' => 'datetime', + 'summary' => 'array', + ]; + + public function schedule(): BelongsTo + { + return $this->belongsTo(BackupSchedule::class, 'backup_schedule_id'); + } + + public function tenant(): BelongsTo + { + return $this->belongsTo(Tenant::class); + } + + public function user(): BelongsTo + { + return $this->belongsTo(User::class); + } + + public function backupSet(): BelongsTo + { + return $this->belongsTo(BackupSet::class); + } +} diff --git a/app/Models/Tenant.php b/app/Models/Tenant.php index bc0a134..1294555 100644 --- a/app/Models/Tenant.php +++ b/app/Models/Tenant.php @@ -2,16 +2,19 @@ namespace App\Models; +use Filament\Facades\Filament; +use Filament\Models\Contracts\HasName; use Illuminate\Database\Eloquent\Builder; use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Model; +use Illuminate\Database\Eloquent\Relations\BelongsToMany; use Illuminate\Database\Eloquent\Relations\HasMany; use Illuminate\Database\Eloquent\SoftDeletes; use Illuminate\Support\Facades\DB; use Illuminate\Support\Str; use RuntimeException; -class Tenant extends Model +class Tenant extends Model implements HasName { use HasFactory; use SoftDeletes; @@ -104,13 +107,23 @@ public function makeCurrent(): void DB::transaction(function () { static::activeQuery()->update(['is_current' => false]); - $this->forceFill(['is_current' => true])->save(); + static::query() + ->whereKey($this->getKey()) + ->update(['is_current' => true]); }); + + $this->forceFill(['is_current' => true]); } public static function current(): self { - $envTenantId = env('INTUNE_TENANT_ID') ?: null; + $filamentTenant = Filament::getTenant(); + + if ($filamentTenant instanceof self) { + return $filamentTenant; + } + + $envTenantId = getenv('INTUNE_TENANT_ID') ?: null; if ($envTenantId) { $tenant = static::activeQuery() @@ -138,6 +151,20 @@ public static function current(): self return $tenant; } + public function getFilamentName(): string + { + $environment = strtoupper((string) ($this->environment ?? 'other')); + + return "{$this->name} ({$environment})"; + } + + public function users(): BelongsToMany + { + return $this->belongsToMany(User::class) + ->withPivot('role') + ->withTimestamps(); + } + public function policies(): HasMany { return $this->hasMany(Policy::class); @@ -148,6 +175,16 @@ public function backupSets(): HasMany return $this->hasMany(BackupSet::class); } + public function backupSchedules(): HasMany + { + return $this->hasMany(BackupSchedule::class); + } + + public function backupScheduleRuns(): HasMany + { + return $this->hasMany(BackupScheduleRun::class); + } + public function policyVersions(): HasMany { return $this->hasMany(PolicyVersion::class); diff --git a/app/Models/User.php b/app/Models/User.php index ddf23da..8c08d23 100644 --- a/app/Models/User.php +++ b/app/Models/User.php @@ -2,13 +2,21 @@ namespace App\Models; +use App\Support\TenantRole; use Filament\Models\Contracts\FilamentUser; +use Filament\Models\Contracts\HasDefaultTenant; +use Filament\Models\Contracts\HasTenants; use Filament\Panel; use Illuminate\Database\Eloquent\Factories\HasFactory; +use Illuminate\Database\Eloquent\Model; +use Illuminate\Database\Eloquent\Relations\BelongsToMany; +use Illuminate\Database\Eloquent\Relations\HasMany; use Illuminate\Foundation\Auth\User as Authenticatable; use Illuminate\Notifications\Notifiable; +use Illuminate\Support\Collection; +use Illuminate\Support\Facades\Schema; -class User extends Authenticatable implements FilamentUser +class User extends Authenticatable implements FilamentUser, HasDefaultTenant, HasTenants { /** @use HasFactory<\Database\Factories\UserFactory> */ use HasFactory, Notifiable; @@ -51,4 +59,113 @@ public function canAccessPanel(Panel $panel): bool { return true; } + + public function tenants(): BelongsToMany + { + return $this->belongsToMany(Tenant::class) + ->withPivot('role') + ->withTimestamps(); + } + + public function tenantPreferences(): HasMany + { + return $this->hasMany(UserTenantPreference::class); + } + + private function tenantPivotTableExists(): bool + { + static $exists; + + return $exists ??= Schema::hasTable('tenant_user'); + } + + private function tenantPreferencesTableExists(): bool + { + static $exists; + + return $exists ??= Schema::hasTable('user_tenant_preferences'); + } + + public function tenantRole(Tenant $tenant): ?TenantRole + { + if (! $this->tenantPivotTableExists()) { + return null; + } + + $role = $this->tenants() + ->whereKey($tenant->getKey()) + ->value('role'); + + if (! is_string($role)) { + return null; + } + + return TenantRole::tryFrom($role); + } + + public function canSyncTenant(Tenant $tenant): bool + { + $role = $this->tenantRole($tenant); + + return $role?->canSync() ?? false; + } + + public function canAccessTenant(Model $tenant): bool + { + if (! $tenant instanceof Tenant) { + return false; + } + + if (! $this->tenantPivotTableExists()) { + return false; + } + + return $this->tenants() + ->whereKey($tenant->getKey()) + ->exists(); + } + + public function getTenants(Panel $panel): array|Collection + { + if (! $this->tenantPivotTableExists()) { + return collect(); + } + + return $this->tenants() + ->where('status', 'active') + ->orderBy('name') + ->get(); + } + + public function getDefaultTenant(Panel $panel): ?Model + { + if (! $this->tenantPivotTableExists()) { + return null; + } + + $tenantId = null; + + if ($this->tenantPreferencesTableExists()) { + $tenantId = $this->tenantPreferences() + ->whereNotNull('last_used_at') + ->orderByDesc('last_used_at') + ->value('tenant_id'); + } + + if ($tenantId !== null) { + $tenant = $this->tenants() + ->where('status', 'active') + ->whereKey($tenantId) + ->first(); + + if ($tenant !== null) { + return $tenant; + } + } + + return $this->tenants() + ->where('status', 'active') + ->orderBy('name') + ->first(); + } } diff --git a/app/Models/UserTenantPreference.php b/app/Models/UserTenantPreference.php new file mode 100644 index 0000000..dab7b6c --- /dev/null +++ b/app/Models/UserTenantPreference.php @@ -0,0 +1,26 @@ + 'boolean', + 'last_used_at' => 'datetime', + ]; + + public function user(): BelongsTo + { + return $this->belongsTo(User::class); + } + + public function tenant(): BelongsTo + { + return $this->belongsTo(Tenant::class); + } +} diff --git a/app/Notifications/BackupScheduleRunDispatchedNotification.php b/app/Notifications/BackupScheduleRunDispatchedNotification.php new file mode 100644 index 0000000..9690097 --- /dev/null +++ b/app/Notifications/BackupScheduleRunDispatchedNotification.php @@ -0,0 +1,55 @@ +, + * backup_schedule_run_ids?:array + * } $metadata + */ + public function __construct(public array $metadata) {} + + /** + * @return array + */ + public function via(object $notifiable): array + { + return ['database']; + } + + /** + * @return array + */ + public function toDatabase(object $notifiable): array + { + $trigger = (string) ($this->metadata['trigger'] ?? 'run_now'); + + $title = match ($trigger) { + 'retry' => 'Retry dispatched', + 'bulk_retry' => 'Retries dispatched', + 'bulk_run_now' => 'Runs dispatched', + default => 'Run dispatched', + }; + + $body = match ($trigger) { + 'bulk_retry', 'bulk_run_now' => 'Backup runs have been queued.', + default => 'A backup run has been queued.', + }; + + return [ + 'title' => $title, + 'body' => $body, + 'metadata' => $this->metadata, + ]; + } +} diff --git a/app/Policies/BackupSchedulePolicy.php b/app/Policies/BackupSchedulePolicy.php new file mode 100644 index 0000000..4fb4d78 --- /dev/null +++ b/app/Policies/BackupSchedulePolicy.php @@ -0,0 +1,46 @@ +tenantRole($tenant); + } + + public function viewAny(User $user): bool + { + return $this->resolveRole($user) !== null; + } + + public function view(User $user, BackupSchedule $schedule): bool + { + return $this->resolveRole($user) !== null; + } + + public function create(User $user): bool + { + return $this->resolveRole($user)?->canManageBackupSchedules() ?? false; + } + + public function update(User $user, BackupSchedule $schedule): bool + { + return $this->resolveRole($user)?->canManageBackupSchedules() ?? false; + } + + public function delete(User $user, BackupSchedule $schedule): bool + { + return $this->resolveRole($user)?->canManageBackupSchedules() ?? false; + } +} diff --git a/app/Providers/AppServiceProvider.php b/app/Providers/AppServiceProvider.php index 517a762..248c52c 100644 --- a/app/Providers/AppServiceProvider.php +++ b/app/Providers/AppServiceProvider.php @@ -2,14 +2,31 @@ namespace App\Providers; +use App\Models\BackupSchedule; +use App\Models\Tenant; +use App\Models\User; +use App\Models\UserTenantPreference; +use App\Policies\BackupSchedulePolicy; use App\Services\Graph\GraphClientInterface; use App\Services\Graph\MicrosoftGraphClient; use App\Services\Graph\NullGraphClient; use App\Services\Intune\AppProtectionPolicyNormalizer; use App\Services\Intune\CompliancePolicyNormalizer; use App\Services\Intune\DeviceConfigurationPolicyNormalizer; +use App\Services\Intune\EnrollmentAutopilotPolicyNormalizer; use App\Services\Intune\GroupPolicyConfigurationNormalizer; +use App\Services\Intune\ManagedDeviceAppConfigurationNormalizer; +use App\Services\Intune\ScriptsPolicyNormalizer; use App\Services\Intune\SettingsCatalogPolicyNormalizer; +use App\Services\Intune\TermsAndConditionsNormalizer; +use App\Services\Intune\WindowsDriverUpdateProfileNormalizer; +use App\Services\Intune\WindowsFeatureUpdateProfileNormalizer; +use App\Services\Intune\WindowsQualityUpdateProfileNormalizer; +use App\Services\Intune\WindowsUpdateRingNormalizer; +use Filament\Events\TenantSet; +use Illuminate\Support\Facades\Event; +use Illuminate\Support\Facades\Gate; +use Illuminate\Support\Facades\Schema; use Illuminate\Support\ServiceProvider; class AppServiceProvider extends ServiceProvider @@ -38,8 +55,16 @@ public function register(): void AppProtectionPolicyNormalizer::class, CompliancePolicyNormalizer::class, DeviceConfigurationPolicyNormalizer::class, + EnrollmentAutopilotPolicyNormalizer::class, GroupPolicyConfigurationNormalizer::class, + ManagedDeviceAppConfigurationNormalizer::class, + ScriptsPolicyNormalizer::class, SettingsCatalogPolicyNormalizer::class, + TermsAndConditionsNormalizer::class, + WindowsDriverUpdateProfileNormalizer::class, + WindowsFeatureUpdateProfileNormalizer::class, + WindowsQualityUpdateProfileNormalizer::class, + WindowsUpdateRingNormalizer::class, ], 'policy-type-normalizers' ); @@ -50,6 +75,37 @@ public function register(): void */ public function boot(): void { - // + Event::listen(TenantSet::class, function (TenantSet $event): void { + static $hasPreferencesTable; + + $hasPreferencesTable ??= Schema::hasTable('user_tenant_preferences'); + + if (! $hasPreferencesTable) { + return; + } + + $tenant = $event->getTenant(); + $user = $event->getUser(); + + if (! $tenant instanceof Tenant) { + return; + } + + if (! $user instanceof User) { + return; + } + + UserTenantPreference::query()->updateOrCreate( + [ + 'user_id' => $user->getKey(), + 'tenant_id' => $tenant->getKey(), + ], + [ + 'last_used_at' => now(), + ], + ); + }); + + Gate::policy(BackupSchedule::class, BackupSchedulePolicy::class); } } diff --git a/app/Providers/Filament/AdminPanelProvider.php b/app/Providers/Filament/AdminPanelProvider.php index 9827abc..351d2bd 100644 --- a/app/Providers/Filament/AdminPanelProvider.php +++ b/app/Providers/Filament/AdminPanelProvider.php @@ -2,6 +2,8 @@ namespace App\Providers\Filament; +use App\Filament\Pages\Tenancy\RegisterTenant; +use App\Models\Tenant; use Filament\Http\Middleware\Authenticate; use Filament\Http\Middleware\AuthenticateSession; use Filament\Http\Middleware\DisableBladeIconComponents; @@ -29,6 +31,10 @@ public function panel(Panel $panel): Panel ->id('admin') ->path('admin') ->login() + ->tenant(Tenant::class, slugAttribute: 'external_id') + ->tenantRoutePrefix('t') + ->searchableTenantMenu() + ->tenantRegistration(RegisterTenant::class) ->colors([ 'primary' => Color::Amber, ]) diff --git a/app/Rules/SupportedPolicyTypesRule.php b/app/Rules/SupportedPolicyTypesRule.php new file mode 100644 index 0000000..ca73105 --- /dev/null +++ b/app/Rules/SupportedPolicyTypesRule.php @@ -0,0 +1,22 @@ +ensureSupported($types); + } catch (InvalidPolicyTypeException $exception) { + $fail(sprintf('Unknown policy types: %s.', implode(', ', $exception->unknownPolicyTypes))); + } + } +} diff --git a/app/Services/BackupScheduling/BackupScheduleDispatcher.php b/app/Services/BackupScheduling/BackupScheduleDispatcher.php new file mode 100644 index 0000000..be63a73 --- /dev/null +++ b/app/Services/BackupScheduling/BackupScheduleDispatcher.php @@ -0,0 +1,143 @@ +where('is_enabled', true) + ->whereHas('tenant', fn ($query) => $query->where('status', 'active')) + ->with('tenant'); + + if (is_array($tenantIdentifiers) && ! empty($tenantIdentifiers)) { + $schedulesQuery->whereIn('tenant_id', $this->resolveTenantIds($tenantIdentifiers)); + } + + $createdRuns = 0; + $skippedRuns = 0; + $scannedSchedules = 0; + + foreach ($schedulesQuery->cursor() as $schedule) { + $scannedSchedules++; + + $slot = $this->scheduleTimeService->nextRunFor($schedule, $nowUtc->subMinute()); + + if ($slot === null) { + $schedule->forceFill(['next_run_at' => null])->saveQuietly(); + + continue; + } + + if ($slot->greaterThan($nowUtc)) { + if (! $schedule->next_run_at || ! $schedule->next_run_at->equalTo($slot)) { + $schedule->forceFill(['next_run_at' => $slot])->saveQuietly(); + } + + continue; + } + + $run = null; + + try { + $run = BackupScheduleRun::create([ + 'backup_schedule_id' => $schedule->id, + 'tenant_id' => $schedule->tenant_id, + 'scheduled_for' => $slot->toDateTimeString(), + 'status' => BackupScheduleRun::STATUS_RUNNING, + 'summary' => null, + ]); + } catch (UniqueConstraintViolationException) { + // Idempotency: unique (backup_schedule_id, scheduled_for) + $skippedRuns++; + + Log::debug('Backup schedule run already dispatched for slot.', [ + 'schedule_id' => $schedule->id, + 'slot' => $slot->toDateTimeString(), + ]); + + $schedule->forceFill([ + 'next_run_at' => $this->scheduleTimeService->nextRunFor($schedule, $nowUtc), + ])->saveQuietly(); + + continue; + } + + $createdRuns++; + + $this->auditLogger->log( + tenant: $schedule->tenant, + action: 'backup_schedule.run_dispatched', + context: [ + 'metadata' => [ + 'backup_schedule_id' => $schedule->id, + 'backup_schedule_run_id' => $run->id, + 'scheduled_for' => $slot->toDateTimeString(), + ], + ], + resourceType: 'backup_schedule_run', + resourceId: (string) $run->id, + status: 'success' + ); + + $schedule->forceFill([ + 'next_run_at' => $this->scheduleTimeService->nextRunFor($schedule, $nowUtc), + ])->saveQuietly(); + + Bus::dispatch(new RunBackupScheduleJob($run->id)); + } + + return [ + 'created_runs' => $createdRuns, + 'skipped_runs' => $skippedRuns, + 'scanned_schedules' => $scannedSchedules, + ]; + } + + /** + * @param array $tenantIdentifiers + * @return array + */ + private function resolveTenantIds(array $tenantIdentifiers): array + { + $tenantIds = []; + + foreach ($tenantIdentifiers as $identifier) { + $tenant = Tenant::query() + ->where('status', 'active') + ->forTenant($identifier) + ->first(); + + if ($tenant) { + $tenantIds[] = $tenant->id; + } + } + + return array_values(array_unique($tenantIds)); + } +} diff --git a/app/Services/BackupScheduling/PolicyTypeResolver.php b/app/Services/BackupScheduling/PolicyTypeResolver.php new file mode 100644 index 0000000..6c11c98 --- /dev/null +++ b/app/Services/BackupScheduling/PolicyTypeResolver.php @@ -0,0 +1,55 @@ +findUnknown($types); + + if (! empty($unknown)) { + throw new InvalidPolicyTypeException($unknown); + } + } + + public function filterRuntime(array $types): array + { + $valid = $this->filter($types); + + return array_values($valid); + } + + public function resolveRuntime(array $types): array + { + $valid = $this->filter($types); + $unknown = $this->findUnknown($types); + + return [ + 'valid' => array_values($valid), + 'unknown' => array_values($unknown), + ]; + } + + protected function filter(array $types): array + { + $supported = $this->supportedPolicyTypes(); + + return array_values(array_intersect($types, $supported)); + } + + protected function findUnknown(array $types): array + { + $supported = $this->supportedPolicyTypes(); + + return array_values(array_diff($types, $supported)); + } +} diff --git a/app/Services/BackupScheduling/RunErrorMapper.php b/app/Services/BackupScheduling/RunErrorMapper.php new file mode 100644 index 0000000..613c023 --- /dev/null +++ b/app/Services/BackupScheduling/RunErrorMapper.php @@ -0,0 +1,86 @@ +status; + + if ($status === 401) { + return $this->final(self::ERROR_TOKEN_EXPIRED, $throwable->getMessage()); + } + + if ($status === 403) { + return $this->final(self::ERROR_PERMISSION_MISSING, $throwable->getMessage()); + } + + if ($status === 429) { + return $this->retry(self::ERROR_GRAPH_THROTTLE, $throwable->getMessage(), $attempt, $maxAttempts); + } + + if ($status === 503) { + return $this->retry(self::ERROR_GRAPH_UNAVAILABLE, $throwable->getMessage(), $attempt, $maxAttempts); + } + + return $this->retry(self::ERROR_UNKNOWN, $throwable->getMessage(), $attempt, $maxAttempts); + } + + return $this->retry(self::ERROR_UNKNOWN, $throwable->getMessage(), $attempt, $maxAttempts); + } + + /** + * @return array{shouldRetry: bool, delay: int, error_code: string, error_message: string, final_status: string} + */ + private function retry(string $code, string $message, int $attempt, int $maxAttempts): array + { + if ($attempt >= $maxAttempts) { + return $this->final($code, $message); + } + + $delays = [60, 300, 900]; + $delay = $delays[min($attempt - 1, count($delays) - 1)]; + + return [ + 'shouldRetry' => true, + 'delay' => $delay, + 'error_code' => $code, + 'error_message' => $message, + 'final_status' => 'failed', + ]; + } + + /** + * @return array{shouldRetry: bool, delay: int, error_code: string, error_message: string, final_status: string} + */ + private function final(string $code, string $message): array + { + return [ + 'shouldRetry' => false, + 'delay' => 0, + 'error_code' => $code, + 'error_message' => $message, + 'final_status' => 'failed', + ]; + } +} diff --git a/app/Services/BackupScheduling/ScheduleTimeService.php b/app/Services/BackupScheduling/ScheduleTimeService.php new file mode 100644 index 0000000..331af54 --- /dev/null +++ b/app/Services/BackupScheduling/ScheduleTimeService.php @@ -0,0 +1,88 @@ +timezone; + $cursor = $after?->copy()->timezone($timezone) ?? CarbonImmutable::now($timezone); + + if ($schedule->frequency === 'weekly') { + return $this->nextWeeklyRun($schedule, $cursor); + } + + return $this->nextDailyRun($schedule, $cursor); + } + + protected function nextDailyRun(BackupSchedule $schedule, CarbonImmutable $cursor): ?CarbonImmutable + { + $time = $schedule->time_of_day; + $attempts = 0; + + if ($cursor->format('H:i:s') >= $time) { + $cursor = $cursor->addDay(); + } + + while ($attempts++ < 14) { + $candidate = $this->buildLocalSlot($schedule, $cursor); + + if ($candidate) { + return $candidate; + } + + $cursor = $cursor->addDay(); + } + + return null; + } + + protected function nextWeeklyRun(BackupSchedule $schedule, CarbonImmutable $cursor): ?CarbonImmutable + { + $allowed = $schedule->days_of_week ?? []; + $allowed = array_filter($allowed, fn ($day) => is_numeric($day) && $day >= 1 && $day <= 7); + $allowed = array_values($allowed); + + if (empty($allowed)) { + return null; + } + + $attempts = 0; + + while ($attempts++ < 21) { + $dayOfWeek = $cursor->dayOfWeekIso; + + if (in_array($dayOfWeek, $allowed, true)) { + $candidate = $this->buildLocalSlot($schedule, $cursor); + + $cursorUtc = $cursor->copy()->timezone('UTC'); + + if ($candidate && $candidate->greaterThan($cursorUtc)) { + return $candidate; + } + } + + $cursor = $cursor->addDay()->startOfDay(); + } + + return null; + } + + protected function buildLocalSlot(BackupSchedule $schedule, CarbonImmutable $date): ?CarbonImmutable + { + $timezone = $schedule->timezone; + $time = $schedule->time_of_day; + $datePart = $date->format('Y-m-d'); + $candidate = CarbonImmutable::createFromFormat('Y-m-d H:i:s', "{$datePart} {$time}", $timezone); + + if (! $candidate || $candidate->format('H:i:s') !== $time) { + return null; + } + + return $candidate->startOfMinute()->timezone('UTC'); + } +} diff --git a/app/Services/BulkOperationService.php b/app/Services/BulkOperationService.php index 76b3af7..8f3f158 100644 --- a/app/Services/BulkOperationService.php +++ b/app/Services/BulkOperationService.php @@ -109,8 +109,24 @@ public function recordSkippedWithReason(BulkOperationRun $run, string $itemId, s public function complete(BulkOperationRun $run): void { + $run->refresh(); + + if (! in_array($run->status, ['pending', 'running'], true)) { + return; + } + $status = $run->failed > 0 ? 'completed_with_errors' : 'completed'; - $run->update(['status' => $status]); + + $updated = BulkOperationRun::query() + ->whereKey($run->id) + ->whereIn('status', ['pending', 'running']) + ->update(['status' => $status]); + + if ($updated === 0) { + return; + } + + $run->refresh(); $failureEntries = collect($run->failures ?? []); $failedReasons = $failureEntries diff --git a/app/Services/Graph/AssignmentFetcher.php b/app/Services/Graph/AssignmentFetcher.php index 6bd2139..29aa0b5 100644 --- a/app/Services/Graph/AssignmentFetcher.php +++ b/app/Services/Graph/AssignmentFetcher.php @@ -39,7 +39,7 @@ public function fetch( $primaryException = null; $assignments = []; - $primarySucceeded = false; + $lastSuccessfulAssignments = null; // Try primary endpoint(s) $listPathTemplates = []; @@ -65,7 +65,12 @@ public function fetch( $context, $throwOnFailure ); - $primarySucceeded = true; + + if ($assignments === null) { + continue; + } + + $lastSuccessfulAssignments = $assignments; if (! empty($assignments)) { Log::debug('Fetched assignments via primary endpoint', [ @@ -77,20 +82,25 @@ public function fetch( return $assignments; } + + if ($policyType !== 'appProtectionPolicy') { + // Empty is a valid outcome (policy not assigned). Do not attempt fallback. + return []; + } } catch (GraphException $e) { $primaryException = $primaryException ?? $e; } } - if ($primarySucceeded && $policyType === 'appProtectionPolicy') { + if ($lastSuccessfulAssignments !== null && $policyType === 'appProtectionPolicy') { Log::debug('Assignments fetched via primary endpoint(s)', [ 'tenant_id' => $tenantId, 'policy_type' => $policyType, 'policy_id' => $policyId, - 'count' => count($assignments), + 'count' => count($lastSuccessfulAssignments), ]); - return $assignments; + return $lastSuccessfulAssignments; } // Try fallback with $expand @@ -215,15 +225,15 @@ private function fetchPrimary( array $options, array $context, bool $throwOnFailure - ): array { + ): ?array { if (! is_string($listPathTemplate) || $listPathTemplate === '') { - return []; + return null; } $path = $this->resolvePath($listPathTemplate, $policyId); if ($path === null) { - return []; + return null; } $response = $this->graphClient->request('GET', $path, $options); @@ -239,7 +249,7 @@ private function fetchPrimary( ); } - return []; + return null; } return $response->data['value'] ?? []; diff --git a/app/Services/Graph/GraphContractRegistry.php b/app/Services/Graph/GraphContractRegistry.php index 9d07ff4..22f85c9 100644 --- a/app/Services/Graph/GraphContractRegistry.php +++ b/app/Services/Graph/GraphContractRegistry.php @@ -32,6 +32,16 @@ public function sanitizeQuery(string $policyType, array $query): array : array_map('trim', explode(',', (string) $original)); $filtered = array_values(array_intersect($select, $allowedSelect)); + $withoutAnnotations = array_values(array_filter( + $filtered, + static fn ($field) => is_string($field) && ! str_contains($field, '@') + )); + + if (count($withoutAnnotations) !== count($filtered)) { + $warnings[] = 'Removed OData annotation fields from $select (unsupported by Graph).'; + $filtered = $withoutAnnotations; + } + if (count($filtered) !== count($select)) { $warnings[] = 'Trimmed unsupported $select fields for capability safety.'; } diff --git a/app/Services/Graph/MicrosoftGraphClient.php b/app/Services/Graph/MicrosoftGraphClient.php index 897e237..094fe16 100644 --- a/app/Services/Graph/MicrosoftGraphClient.php +++ b/app/Services/Graph/MicrosoftGraphClient.php @@ -14,6 +14,8 @@ class MicrosoftGraphClient implements GraphClientInterface { private const DEFAULT_SCOPE = 'https://graph.microsoft.com/.default'; + private const MAX_LIST_PAGES = 50; + private string $baseUrl; private string $tokenUrlTemplate; @@ -51,12 +53,21 @@ public function __construct( public function listPolicies(string $policyType, array $options = []): GraphResponse { $endpoint = $this->endpointFor($policyType); - $query = array_filter([ + $contract = $this->contracts->get($policyType); + $allowedSelect = is_array($contract['allowed_select'] ?? null) ? $contract['allowed_select'] : []; + $defaultSelect = $options['select'] ?? ($allowedSelect !== [] ? implode(',', $allowedSelect) : null); + + $queryInput = array_filter([ '$top' => $options['top'] ?? null, '$filter' => $options['filter'] ?? null, + '$select' => $defaultSelect, 'platform' => $options['platform'] ?? null, ], fn ($value) => $value !== null && $value !== ''); + $sanitized = $this->contracts->sanitizeQuery($policyType, $queryInput); + $query = $sanitized['query']; + $warnings = $sanitized['warnings']; + $context = $this->resolveContext($options); $clientRequestId = $options['client_request_id'] ?? (string) Str::uuid(); $fullPath = $this->buildFullPath($endpoint, $query); @@ -79,19 +90,178 @@ public function listPolicies(string $policyType, array $options = []): GraphResp $response = $this->send('GET', $endpoint, $sendOptions, $context); - return $this->toGraphResponse( - action: 'list_policies', - response: $response, - transform: fn (array $json) => $json['value'] ?? (is_array($json) ? $json : []), - meta: [ - 'tenant' => $context['tenant'] ?? null, - 'path' => $endpoint, - 'full_path' => $fullPath, + if ($response->failed()) { + $graphResponse = $this->toGraphResponse( + action: 'list_policies', + response: $response, + transform: fn (array $json) => $json['value'] ?? (is_array($json) ? $json : []), + meta: [ + 'tenant' => $context['tenant'] ?? null, + 'path' => $endpoint, + 'full_path' => $fullPath, + 'method' => 'GET', + 'query' => $query ?: null, + 'client_request_id' => $clientRequestId, + ], + warnings: $warnings, + ); + + if (! $this->shouldApplySelectFallback($graphResponse, $query)) { + return $graphResponse; + } + + $fallbackQuery = array_filter($query, fn ($value, $key) => $key !== '$select', ARRAY_FILTER_USE_BOTH); + $fallbackPath = $this->buildFullPath($endpoint, $fallbackQuery); + $fallbackSendOptions = ['query' => $fallbackQuery, 'client_request_id' => $clientRequestId]; + + if (isset($options['access_token'])) { + $fallbackSendOptions['access_token'] = $options['access_token']; + } + + $this->logger->logRequest('list_policies_fallback', [ + 'endpoint' => $endpoint, + 'full_path' => $fallbackPath, 'method' => 'GET', - 'query' => $query ?: null, + 'policy_type' => $policyType, + 'tenant' => $context['tenant'], + 'query' => $fallbackQuery ?: null, 'client_request_id' => $clientRequestId, - ] + ]); + + $fallbackResponse = $this->send('GET', $endpoint, $fallbackSendOptions, $context); + + if ($fallbackResponse->failed()) { + return $this->toGraphResponse( + action: 'list_policies', + response: $fallbackResponse, + transform: fn (array $json) => $json['value'] ?? (is_array($json) ? $json : []), + meta: [ + 'tenant' => $context['tenant'] ?? null, + 'path' => $endpoint, + 'full_path' => $fallbackPath, + 'method' => 'GET', + 'query' => $fallbackQuery ?: null, + 'client_request_id' => $clientRequestId, + ], + warnings: array_values(array_unique(array_merge( + $warnings, + ['Capability fallback applied: removed $select for compatibility.'] + ))), + ); + } + + $response = $fallbackResponse; + $query = $fallbackQuery; + $fullPath = $fallbackPath; + $warnings = array_values(array_unique(array_merge( + $warnings, + ['Capability fallback applied: removed $select for compatibility.'] + ))); + } + + $json = $response->json() ?? []; + $policies = $json['value'] ?? (is_array($json) ? $json : []); + $nextLink = $json['@odata.nextLink'] ?? null; + $pages = 1; + + while (is_string($nextLink) && $nextLink !== '') { + if ($pages >= self::MAX_LIST_PAGES) { + $graphResponse = new GraphResponse( + success: false, + data: [], + status: 500, + errors: [[ + 'message' => 'Graph pagination exceeded maximum page limit.', + 'max_pages' => self::MAX_LIST_PAGES, + ]], + warnings: $warnings, + meta: [ + 'tenant' => $context['tenant'] ?? null, + 'path' => $endpoint, + 'full_path' => $fullPath, + 'method' => 'GET', + 'query' => $query ?: null, + 'client_request_id' => $clientRequestId, + 'pages_fetched' => $pages, + ], + ); + + $this->logger->logResponse('list_policies', $graphResponse, $graphResponse->meta); + + return $graphResponse; + } + + $pageOptions = ['client_request_id' => $clientRequestId]; + + if (isset($options['access_token'])) { + $pageOptions['access_token'] = $options['access_token']; + } + + $pageResponse = $this->send('GET', $nextLink, $pageOptions, $context); + + if ($pageResponse->failed()) { + $graphResponse = $this->toGraphResponse( + action: 'list_policies', + response: $pageResponse, + transform: fn (array $json) => $json['value'] ?? (is_array($json) ? $json : []), + meta: [ + 'tenant' => $context['tenant'] ?? null, + 'path' => $endpoint, + 'full_path' => $fullPath, + 'method' => 'GET', + 'query' => $query ?: null, + 'client_request_id' => $clientRequestId, + 'pages_fetched' => $pages, + ], + warnings: array_values(array_unique(array_merge( + $warnings, + ['Pagination failed while listing policies.'] + ))), + ); + + return $graphResponse; + } + + $pageJson = $pageResponse->json() ?? []; + $pageValue = $pageJson['value'] ?? []; + + if (is_array($pageValue) && $pageValue !== []) { + $policies = array_merge($policies, $pageValue); + } + + $nextLink = $pageJson['@odata.nextLink'] ?? null; + $pages++; + } + + $meta = $this->responseMeta($response, [ + 'tenant' => $context['tenant'] ?? null, + 'path' => $endpoint, + 'full_path' => $fullPath, + 'method' => 'GET', + 'query' => $query ?: null, + 'client_request_id' => $clientRequestId, + ]); + + $meta['pages_fetched'] = $pages; + $meta['item_count'] = count($policies); + + if ($pages > 1) { + $warnings = array_values(array_unique(array_merge($warnings, [ + sprintf('Pagination applied: fetched %d pages.', $pages), + ]))); + } + + $graphResponse = new GraphResponse( + success: true, + data: $policies, + status: $response->status(), + warnings: $warnings, + meta: $meta, ); + + $this->logger->logResponse('list_policies', $graphResponse, $meta); + + return $graphResponse; } public function getPolicy(string $policyType, string $policyId, array $options = []): GraphResponse @@ -182,6 +352,37 @@ public function getPolicy(string $policyType, string $policyId, array $options = return $graphResponse; } + private function shouldApplySelectFallback(GraphResponse $graphResponse, array $query): bool + { + if (! $graphResponse->failed()) { + return false; + } + + if (($graphResponse->status ?? null) !== 400) { + return false; + } + + if (! array_key_exists('$select', $query)) { + return false; + } + + $errorMessage = $graphResponse->meta['error_message'] ?? null; + + if (! is_string($errorMessage) || $errorMessage === '') { + return false; + } + + if (stripos($errorMessage, 'Parsing OData Select and Expand failed') !== false) { + return true; + } + + if (stripos($errorMessage, 'Could not find a property named') !== false) { + return true; + } + + return false; + } + public function getOrganization(array $options = []): GraphResponse { $context = $this->resolveContext($options); @@ -575,8 +776,22 @@ private function normalizeScopes(array|string|null $scope): array private function endpointFor(string $policyType): string { - $supported = config('tenantpilot.supported_policy_types', []); - foreach ($supported as $type) { + $contractResource = $this->contracts->resourcePath($policyType); + if (is_string($contractResource) && $contractResource !== '') { + return $contractResource; + } + + $builtinEndpoint = $this->builtinEndpointFor($policyType); + if ($builtinEndpoint !== null) { + return $builtinEndpoint; + } + + $types = array_merge( + config('tenantpilot.supported_policy_types', []), + config('tenantpilot.foundation_types', []), + ); + + foreach ($types as $type) { if (($type['type'] ?? null) === $policyType && ! empty($type['endpoint'])) { return $type['endpoint']; } @@ -585,6 +800,16 @@ private function endpointFor(string $policyType): string return 'deviceManagement/'.$policyType; } + private function builtinEndpointFor(string $policyType): ?string + { + return match ($policyType) { + 'settingsCatalogPolicy', + 'endpointSecurityPolicy', + 'securityBaselinePolicy' => 'deviceManagement/configurationPolicies', + default => null, + }; + } + private function getAccessToken(array $context): string { $tenant = $context['tenant'] ?? $this->tenantId; diff --git a/app/Services/Intune/BackupService.php b/app/Services/Intune/BackupService.php index 22abe4c..255e2e1 100644 --- a/app/Services/Intune/BackupService.php +++ b/app/Services/Intune/BackupService.php @@ -5,6 +5,7 @@ use App\Models\BackupItem; use App\Models\BackupSet; use App\Models\Policy; +use App\Models\PolicyVersion; use App\Models\Tenant; use App\Services\AssignmentBackupService; use Carbon\CarbonImmutable; @@ -289,13 +290,46 @@ private function snapshotPolicy( $captured = $captureResult['captured']; $payload = $captured['payload']; $metadata = $captured['metadata'] ?? []; - $metadataWarnings = $captured['warnings'] ?? []; - // Validate snapshot - $validation = $this->snapshotValidator->validate(is_array($payload) ? $payload : []); + return [ + $this->createBackupItemFromVersion( + tenant: $tenant, + backupSet: $backupSet, + policy: $policy, + version: $version, + payload: is_array($payload) ? $payload : [], + assignments: $captured['assignments'] ?? null, + scopeTags: $captured['scope_tags'] ?? null, + metadata: is_array($metadata) ? $metadata : [], + warnings: $captured['warnings'] ?? [], + ), + null, + ]; + } + + /** + * @param array $payload + * @param array $metadata + * @param array $warnings + * @param array{ids:array,names:array}|null $scopeTags + */ + private function createBackupItemFromVersion( + Tenant $tenant, + BackupSet $backupSet, + Policy $policy, + PolicyVersion $version, + array $payload, + ?array $assignments, + ?array $scopeTags, + array $metadata, + array $warnings = [], + ): BackupItem { + $metadataWarnings = $warnings; + + $validation = $this->snapshotValidator->validate($payload); $metadataWarnings = array_merge($metadataWarnings, $validation['warnings']); - $odataWarning = BackupItem::odataTypeWarning(is_array($payload) ? $payload : [], $policy->policy_type, $policy->platform); + $odataWarning = BackupItem::odataTypeWarning($payload, $policy->policy_type, $policy->platform); if ($odataWarning) { $metadataWarnings[] = $odataWarning; @@ -305,29 +339,23 @@ private function snapshotPolicy( $metadata['warnings'] = array_values(array_unique($metadataWarnings)); } - $capturedScopeTags = $captured['scope_tags'] ?? null; - if (is_array($capturedScopeTags)) { - $metadata['scope_tag_ids'] = $capturedScopeTags['ids'] ?? null; - $metadata['scope_tag_names'] = $capturedScopeTags['names'] ?? null; + if (is_array($scopeTags)) { + $metadata['scope_tag_ids'] = $scopeTags['ids'] ?? null; + $metadata['scope_tag_names'] = $scopeTags['names'] ?? null; } - // Create BackupItem as a copy/reference of the PolicyVersion - $backupItem = BackupItem::create([ + return BackupItem::create([ 'tenant_id' => $tenant->id, 'backup_set_id' => $backupSet->id, 'policy_id' => $policy->id, - 'policy_version_id' => $version->id, // Link to version + 'policy_version_id' => $version->id, 'policy_identifier' => $policy->external_id, 'policy_type' => $policy->policy_type, 'platform' => $policy->platform, 'payload' => $payload, 'metadata' => $metadata, - // Copy assignments from version (already captured) - // Note: scope_tags are only stored in PolicyVersion - 'assignments' => $captured['assignments'] ?? null, + 'assignments' => $assignments, ]); - - return [$backupItem, null]; } /** diff --git a/app/Services/Intune/ConfigurationPolicyTemplateResolver.php b/app/Services/Intune/ConfigurationPolicyTemplateResolver.php new file mode 100644 index 0000000..3432c59 --- /dev/null +++ b/app/Services/Intune/ConfigurationPolicyTemplateResolver.php @@ -0,0 +1,388 @@ +> + */ + private array $templateCache = []; + + /** + * @var array,reason:?string}>> + */ + private array $familyCache = []; + + /** + * @var array,reason:?string}>> + */ + private array $templateDefinitionCache = []; + + public function __construct( + private readonly GraphClientInterface $graphClient, + ) {} + + /** + * @param array $templateReference + * @param array $graphOptions + * @return array{success:bool,template_id:?string,template_reference:?array,reason:?string,warnings:array} + */ + public function resolveTemplateReference(Tenant $tenant, array $templateReference, array $graphOptions = []): array + { + $warnings = []; + + $templateId = $this->extractString($templateReference, ['templateId', 'TemplateId']); + $templateFamily = $this->extractString($templateReference, ['templateFamily', 'TemplateFamily']); + $templateDisplayName = $this->extractString($templateReference, ['templateDisplayName', 'TemplateDisplayName']); + $templateDisplayVersion = $this->extractString($templateReference, ['templateDisplayVersion', 'TemplateDisplayVersion']); + + if ($templateId !== null) { + $templateOutcome = $this->getTemplate($tenant, $templateId, $graphOptions); + + if ($templateOutcome['success']) { + return [ + 'success' => true, + 'template_id' => $templateId, + 'template_reference' => $templateReference, + 'reason' => null, + 'warnings' => $warnings, + ]; + } + + if ($templateFamily === null) { + return [ + 'success' => false, + 'template_id' => null, + 'template_reference' => null, + 'reason' => $templateOutcome['reason'] ?? "Template '{$templateId}' is not available in the tenant.", + 'warnings' => $warnings, + ]; + } + } + + if ($templateFamily === null) { + return [ + 'success' => false, + 'template_id' => null, + 'template_reference' => null, + 'reason' => 'Template reference is missing templateFamily and cannot be resolved.', + 'warnings' => $warnings, + ]; + } + + $listOutcome = $this->listTemplatesByFamily($tenant, $templateFamily, $graphOptions); + + if (! $listOutcome['success']) { + return [ + 'success' => false, + 'template_id' => null, + 'template_reference' => null, + 'reason' => $listOutcome['reason'] ?? "Unable to list templates for family '{$templateFamily}'.", + 'warnings' => $warnings, + ]; + } + + $candidates = $this->chooseTemplateCandidate( + templates: $listOutcome['templates'], + templateDisplayName: $templateDisplayName, + templateDisplayVersion: $templateDisplayVersion, + ); + + if (count($candidates) !== 1) { + $reason = count($candidates) === 0 + ? "No templates found for family '{$templateFamily}'." + : "Multiple templates found for family '{$templateFamily}' (cannot resolve automatically)."; + + return [ + 'success' => false, + 'template_id' => null, + 'template_reference' => null, + 'reason' => $reason, + 'warnings' => $warnings, + ]; + } + + $candidate = $candidates[0]; + $resolvedId = is_array($candidate) ? ($candidate['id'] ?? null) : null; + + if (! is_string($resolvedId) || $resolvedId === '') { + return [ + 'success' => false, + 'template_id' => null, + 'template_reference' => null, + 'reason' => "Template candidate for family '{$templateFamily}' is missing an id.", + 'warnings' => $warnings, + ]; + } + + if ($templateId !== null && $templateId !== $resolvedId) { + $warnings[] = sprintf("TemplateId '%s' not found; mapped to '%s' via templateFamily.", $templateId, $resolvedId); + } + + $templateReference['templateId'] = $resolvedId; + + if (! isset($templateReference['templateDisplayName']) && isset($candidate['displayName'])) { + $templateReference['templateDisplayName'] = $candidate['displayName']; + } + + if (! isset($templateReference['templateDisplayVersion']) && isset($candidate['displayVersion'])) { + $templateReference['templateDisplayVersion'] = $candidate['displayVersion']; + } + + return [ + 'success' => true, + 'template_id' => $resolvedId, + 'template_reference' => $templateReference, + 'reason' => null, + 'warnings' => $warnings, + ]; + } + + /** + * @param array $graphOptions + * @return array{success:bool,template:?array,reason:?string} + */ + public function getTemplate(Tenant $tenant, string $templateId, array $graphOptions = []): array + { + $tenantKey = $this->tenantKey($tenant, $graphOptions); + + if (isset($this->templateCache[$tenantKey][$templateId])) { + return $this->templateCache[$tenantKey][$templateId]; + } + + $context = array_merge($tenant->graphOptions(), Arr::except($graphOptions, ['platform'])); + $path = sprintf('/deviceManagement/configurationPolicyTemplates/%s', urlencode($templateId)); + $response = $this->graphClient->request('GET', $path, $context); + + if ($response->failed()) { + return $this->templateCache[$tenantKey][$templateId] = [ + 'success' => false, + 'template' => null, + 'reason' => $response->meta['error_message'] ?? 'Template lookup failed.', + ]; + } + + return $this->templateCache[$tenantKey][$templateId] = [ + 'success' => true, + 'template' => $response->data, + 'reason' => null, + ]; + } + + /** + * @param array $graphOptions + * @return array{success:bool,templates:array,reason:?string} + */ + public function listTemplatesByFamily(Tenant $tenant, string $templateFamily, array $graphOptions = []): array + { + $tenantKey = $this->tenantKey($tenant, $graphOptions); + $cacheKey = strtolower($templateFamily); + + if (isset($this->familyCache[$tenantKey][$cacheKey])) { + return $this->familyCache[$tenantKey][$cacheKey]; + } + + $escapedFamily = str_replace("'", "''", $templateFamily); + + $context = array_merge($tenant->graphOptions(), Arr::except($graphOptions, ['platform']), [ + 'query' => [ + '$filter' => "templateFamily eq '{$escapedFamily}'", + '$top' => 999, + ], + ]); + + $response = $this->graphClient->request('GET', '/deviceManagement/configurationPolicyTemplates', $context); + + if ($response->failed()) { + return $this->familyCache[$tenantKey][$cacheKey] = [ + 'success' => false, + 'templates' => [], + 'reason' => $response->meta['error_message'] ?? 'Template list failed.', + ]; + } + + $value = $response->data['value'] ?? []; + $templates = is_array($value) ? array_values(array_filter($value, static fn ($item) => is_array($item))) : []; + + return $this->familyCache[$tenantKey][$cacheKey] = [ + 'success' => true, + 'templates' => $templates, + 'reason' => null, + ]; + } + + /** + * @param array $graphOptions + * @return array{success:bool,definition_ids:array,reason:?string} + */ + public function fetchTemplateSettingDefinitionIds(Tenant $tenant, string $templateId, array $graphOptions = []): array + { + $tenantKey = $this->tenantKey($tenant, $graphOptions); + + if (isset($this->templateDefinitionCache[$tenantKey][$templateId])) { + return $this->templateDefinitionCache[$tenantKey][$templateId]; + } + + $context = array_merge($tenant->graphOptions(), Arr::except($graphOptions, ['platform']), [ + 'query' => [ + '$expand' => 'settingDefinitions', + '$top' => 999, + ], + ]); + + $path = sprintf('/deviceManagement/configurationPolicyTemplates/%s/settingTemplates', urlencode($templateId)); + $response = $this->graphClient->request('GET', $path, $context); + + if ($response->failed()) { + return $this->templateDefinitionCache[$tenantKey][$templateId] = [ + 'success' => false, + 'definition_ids' => [], + 'reason' => $response->meta['error_message'] ?? 'Template definitions lookup failed.', + ]; + } + + $value = $response->data['value'] ?? []; + $templates = is_array($value) ? $value : []; + $definitionIds = []; + + foreach ($templates as $settingTemplate) { + if (! is_array($settingTemplate)) { + continue; + } + + $definitions = $settingTemplate['settingDefinitions'] ?? null; + + if (! is_array($definitions)) { + continue; + } + + foreach ($definitions as $definition) { + if (! is_array($definition)) { + continue; + } + + $id = $definition['id'] ?? null; + + if (is_string($id) && $id !== '') { + $definitionIds[] = $id; + } + } + } + + $definitionIds = array_values(array_unique($definitionIds)); + + return $this->templateDefinitionCache[$tenantKey][$templateId] = [ + 'success' => true, + 'definition_ids' => $definitionIds, + 'reason' => null, + ]; + } + + /** + * @param array $settings + * @return array + */ + public function extractSettingDefinitionIds(array $settings): array + { + $ids = []; + + $walk = function (mixed $node) use (&$walk, &$ids): void { + if (! is_array($node)) { + return; + } + + foreach ($node as $key => $value) { + if (is_string($key) && strtolower($key) === 'settingdefinitionid' && is_string($value) && $value !== '') { + $ids[] = $value; + } + + $walk($value); + } + }; + + $walk($settings); + + return array_values(array_unique($ids)); + } + + /** + * @param array $templates + * @return array + */ + private function chooseTemplateCandidate(array $templates, ?string $templateDisplayName, ?string $templateDisplayVersion): array + { + $candidates = $templates; + + $active = array_values(array_filter($candidates, static function (array $template): bool { + $state = $template['lifecycleState'] ?? null; + + return is_string($state) && strtolower($state) === 'active'; + })); + + if ($active !== []) { + $candidates = $active; + } + + if ($templateDisplayVersion !== null) { + $byVersion = array_values(array_filter($candidates, static function (array $template) use ($templateDisplayVersion): bool { + $version = $template['displayVersion'] ?? null; + + return is_string($version) && $version === $templateDisplayVersion; + })); + + if ($byVersion !== []) { + $candidates = $byVersion; + } + } + + if ($templateDisplayName !== null) { + $byName = array_values(array_filter($candidates, static function (array $template) use ($templateDisplayName): bool { + $name = $template['displayName'] ?? null; + + return is_string($name) && $name === $templateDisplayName; + })); + + if ($byName !== []) { + $candidates = $byName; + } + } + + return $candidates; + } + + /** + * @param array $payload + * @param array $keys + */ + private function extractString(array $payload, array $keys): ?string + { + $normalized = array_map('strtolower', $keys); + + foreach ($payload as $key => $value) { + if (! is_string($key) || ! in_array(strtolower($key), $normalized, true)) { + continue; + } + + if (is_string($value) && trim($value) !== '') { + return $value; + } + } + + return null; + } + + /** + * @param array $graphOptions + */ + private function tenantKey(Tenant $tenant, array $graphOptions): string + { + $tenantId = $graphOptions['tenant'] ?? $tenant->graphTenantId() ?? (string) $tenant->getKey(); + + return (string) $tenantId; + } +} diff --git a/app/Services/Intune/DefaultPolicyNormalizer.php b/app/Services/Intune/DefaultPolicyNormalizer.php index f890a10..067092c 100644 --- a/app/Services/Intune/DefaultPolicyNormalizer.php +++ b/app/Services/Intune/DefaultPolicyNormalizer.php @@ -35,6 +35,8 @@ public function normalize(?array $snapshot, string $policyType, ?string $platfor $resultWarnings = []; $status = 'success'; $settingsTable = null; + $usesSettingsCatalogTable = in_array($policyType, ['settingsCatalogPolicy', 'endpointSecurityPolicy', 'securityBaselinePolicy'], true); + $fallbackCategoryName = $this->extractConfigurationPolicyFallbackCategoryName($snapshot); $validation = $this->validator->validate($snapshot); $resultWarnings = array_merge($resultWarnings, $validation['warnings']); @@ -60,23 +62,30 @@ public function normalize(?array $snapshot, string $policyType, ?string $platfor } if (isset($snapshot['settings']) && is_array($snapshot['settings'])) { - if ($policyType === 'settingsCatalogPolicy') { - $normalized = $this->buildSettingsCatalogSettingsTable($snapshot['settings']); + if ($usesSettingsCatalogTable) { + $normalized = $this->buildSettingsCatalogSettingsTable( + $snapshot['settings'], + fallbackCategoryName: $fallbackCategoryName + ); $settingsTable = $normalized['table']; $resultWarnings = array_merge($resultWarnings, $normalized['warnings']); } else { $settings[] = $this->normalizeSettingsCatalog($snapshot['settings']); } } elseif (isset($snapshot['settingsDelta']) && is_array($snapshot['settingsDelta'])) { - if ($policyType === 'settingsCatalogPolicy') { - $normalized = $this->buildSettingsCatalogSettingsTable($snapshot['settingsDelta'], 'Settings delta'); + if ($usesSettingsCatalogTable) { + $normalized = $this->buildSettingsCatalogSettingsTable( + $snapshot['settingsDelta'], + 'Settings delta', + $fallbackCategoryName + ); $settingsTable = $normalized['table']; $resultWarnings = array_merge($resultWarnings, $normalized['warnings']); } else { $settings[] = $this->normalizeSettingsCatalog($snapshot['settingsDelta'], 'Settings delta'); } - } elseif ($policyType === 'settingsCatalogPolicy') { - $resultWarnings[] = 'Settings not hydrated for this Settings Catalog policy.'; + } elseif ($usesSettingsCatalogTable) { + $resultWarnings[] = 'Settings not hydrated for this Configuration Policy.'; } $settings[] = $this->normalizeStandard($snapshot); @@ -231,13 +240,41 @@ private function normalizeSettingsCatalog(array $settings, string $title = 'Sett ]; } + private function extractConfigurationPolicyFallbackCategoryName(array $snapshot): ?string + { + $templateReference = $snapshot['templateReference'] ?? null; + + if (is_string($templateReference)) { + $decoded = json_decode($templateReference, true); + $templateReference = is_array($decoded) ? $decoded : null; + } + + if (! is_array($templateReference)) { + return null; + } + + $displayName = $templateReference['templateDisplayName'] ?? null; + + if (is_string($displayName) && $displayName !== '') { + return $displayName; + } + + $family = $templateReference['templateFamily'] ?? null; + + if (is_string($family) && $family !== '') { + return Str::headline($family); + } + + return null; + } + /** * @param array $settings * @return array{table: array, warnings: array} */ - private function buildSettingsCatalogSettingsTable(array $settings, string $title = 'Settings'): array + private function buildSettingsCatalogSettingsTable(array $settings, string $title = 'Settings', ?string $fallbackCategoryName = null): array { - $flattened = $this->flattenSettingsCatalogSettingInstances($settings); + $flattened = $this->flattenSettingsCatalogSettingInstances($settings, $fallbackCategoryName); return [ 'table' => [ @@ -252,7 +289,7 @@ private function buildSettingsCatalogSettingsTable(array $settings, string $titl * @param array $settings * @return array{rows: array>, warnings: array} */ - private function flattenSettingsCatalogSettingInstances(array $settings): array + private function flattenSettingsCatalogSettingInstances(array $settings, ?string $fallbackCategoryName = null): array { $rows = []; $warnings = []; @@ -292,7 +329,8 @@ private function flattenSettingsCatalogSettingInstances(array $settings): array &$warnedRowLimit, $definitions, $categories, - $defaultCategoryName + $defaultCategoryName, + $fallbackCategoryName, ): void { if ($rowCount >= self::SETTINGS_CATALOG_MAX_ROWS) { if (! $warnedRowLimit) { @@ -364,6 +402,16 @@ private function flattenSettingsCatalogSettingInstances(array $settings): array $categoryName = $defaultCategoryName; } + if ( + $categoryName === '-' + && is_string($fallbackCategoryName) + && $fallbackCategoryName !== '' + && is_array($definition) + && ($definition['isFallback'] ?? false) + ) { + $categoryName = $fallbackCategoryName; + } + // Convert technical type to user-friendly data type $dataType = $this->getUserFriendlyDataType($rawInstanceType, $value); @@ -516,11 +564,41 @@ private function extractSettingsCatalogValue(array $setting, ?array $instance): $type = $instance['@odata.type'] ?? null; $type = is_string($type) ? $type : ''; + if (Str::contains($type, 'ChoiceSettingCollectionInstance', ignoreCase: true)) { + $collection = $instance['choiceSettingCollectionValue'] ?? null; + + if (! is_array($collection) || $collection === []) { + return []; + } + + $values = []; + + foreach ($collection as $item) { + if (! is_array($item)) { + continue; + } + + $value = $item['value'] ?? null; + + if (is_string($value) && $value !== '') { + $values[] = $value; + } + } + + return array_values(array_unique($values)); + } + if (Str::contains($type, 'SimpleSettingInstance', ignoreCase: true)) { $simple = $instance['simpleSettingValue'] ?? null; if (is_array($simple)) { - return $simple['value'] ?? $simple; + $simpleValue = $simple['value'] ?? $simple; + + if (is_array($simpleValue) && array_key_exists('value', $simpleValue)) { + return $simpleValue['value']; + } + + return $simpleValue; } return $simple; @@ -530,7 +608,13 @@ private function extractSettingsCatalogValue(array $setting, ?array $instance): $choice = $instance['choiceSettingValue'] ?? null; if (is_array($choice)) { - return $choice['value'] ?? $choice; + $choiceValue = $choice['value'] ?? $choice; + + if (is_array($choiceValue) && array_key_exists('value', $choiceValue)) { + return $choiceValue['value']; + } + + return $choiceValue; } return $choice; @@ -748,11 +832,17 @@ private function formatSettingsCatalogValue(mixed $value): string if (is_string($value)) { // Remove {tenantid} placeholder $value = str_replace(['{tenantid}', '_tenantid_'], ['', '_'], $value); + $value = preg_replace('/\{[^}]+\}/', '', $value); $value = preg_replace('/_+/', '_', $value); // Extract choice label from choice values (last meaningful part) // Example: "device_vendor_msft_...lowercaseletters_0" -> "Lowercase Letters: 0" - if (str_contains($value, 'device_vendor_msft') || str_contains($value, 'user_vendor_msft') || str_contains($value, '#microsoft.graph')) { + if ( + str_contains($value, 'device_vendor_msft') + || str_contains($value, 'user_vendor_msft') + || str_contains($value, 'vendor_msft') + || str_contains($value, '#microsoft.graph') + ) { $parts = explode('_', $value); $lastPart = end($parts); @@ -761,6 +851,29 @@ private function formatSettingsCatalogValue(mixed $value): string return strtolower($lastPart) === 'true' ? 'Enabled' : 'Disabled'; } + $commonLastPartMapping = [ + 'in' => 'Inbound', + 'out' => 'Outbound', + 'allow' => 'Allow', + 'block' => 'Block', + 'tcp' => 'TCP', + 'udp' => 'UDP', + 'icmpv4' => 'ICMPv4', + 'icmpv6' => 'ICMPv6', + 'any' => 'Any', + 'notconfigured' => 'Not configured', + 'lan' => 'LAN', + 'wireless' => 'Wireless', + 'remoteaccess' => 'Remote access', + 'domain' => 'Domain', + 'private' => 'Private', + 'public' => 'Public', + ]; + + if (is_string($lastPart) && isset($commonLastPartMapping[strtolower($lastPart)])) { + return $commonLastPartMapping[strtolower($lastPart)]; + } + // If last part is just a number, take second-to-last too if (is_numeric($lastPart) && count($parts) > 1) { $secondLast = $parts[count($parts) - 2]; @@ -792,6 +905,33 @@ private function formatSettingsCatalogValue(mixed $value): string } if (is_array($value)) { + if ($value === []) { + return '-'; + } + + if (array_is_list($value)) { + $parts = []; + + foreach ($value as $item) { + if ($item === null) { + continue; + } + + if (! is_bool($item) && ! is_int($item) && ! is_float($item) && ! is_string($item)) { + $parts = []; + break; + } + + $parts[] = $this->formatSettingsCatalogValue($item); + } + + $parts = array_values(array_unique(array_filter($parts, static fn (string $part): bool => $part !== '' && $part !== '-'))); + + if ($parts !== []) { + return implode(', ', $parts); + } + } + return json_encode($value); } diff --git a/app/Services/Intune/EnrollmentAutopilotPolicyNormalizer.php b/app/Services/Intune/EnrollmentAutopilotPolicyNormalizer.php new file mode 100644 index 0000000..ab73397 --- /dev/null +++ b/app/Services/Intune/EnrollmentAutopilotPolicyNormalizer.php @@ -0,0 +1,609 @@ +>, settings_table?: array, warnings: array} + */ + public function normalize(?array $snapshot, string $policyType, ?string $platform = null): array + { + $snapshot = is_array($snapshot) ? $snapshot : []; + + $displayName = Arr::get($snapshot, 'displayName') ?? Arr::get($snapshot, 'name'); + $description = Arr::get($snapshot, 'description'); + + $warnings = []; + + if ($policyType === 'enrollmentRestriction') { + $warnings[] = 'Restore is preview-only for Enrollment Restrictions.'; + } + + if ($policyType === 'deviceEnrollmentLimitConfiguration') { + $warnings[] = 'Restore is preview-only for Enrollment Limits.'; + } + + if ($policyType === 'deviceEnrollmentPlatformRestrictionsConfiguration') { + $warnings[] = 'Restore is preview-only for Platform Restrictions.'; + } + + if ($policyType === 'deviceEnrollmentNotificationConfiguration') { + $warnings[] = 'Restore is preview-only for Enrollment Notifications.'; + } + + $generalEntries = [ + ['key' => 'Type', 'value' => $policyType], + ]; + + if (is_string($displayName) && $displayName !== '') { + $generalEntries[] = ['key' => 'Display name', 'value' => $displayName]; + } + + if (is_string($description) && $description !== '') { + $generalEntries[] = ['key' => 'Description', 'value' => $description]; + } + + $odataType = Arr::get($snapshot, '@odata.type'); + if (is_string($odataType) && $odataType !== '') { + $generalEntries[] = ['key' => '@odata.type', 'value' => $odataType]; + } + + $roleScopeTagIds = Arr::get($snapshot, 'roleScopeTagIds'); + if (is_array($roleScopeTagIds) && $roleScopeTagIds !== []) { + $generalEntries[] = ['key' => 'Scope tag IDs', 'value' => array_values($roleScopeTagIds)]; + } + + $settings = [ + [ + 'type' => 'keyValue', + 'title' => 'General', + 'entries' => $generalEntries, + ], + ]; + + $typeBlock = match ($policyType) { + 'windowsAutopilotDeploymentProfile' => $this->buildAutopilotBlock($snapshot), + 'windowsEnrollmentStatusPage' => $this->buildEnrollmentStatusPageBlock($snapshot), + 'enrollmentRestriction' => $this->buildEnrollmentRestrictionBlock($snapshot), + 'deviceEnrollmentLimitConfiguration' => $this->buildEnrollmentLimitBlock($snapshot), + 'deviceEnrollmentPlatformRestrictionsConfiguration' => $this->buildEnrollmentPlatformRestrictionsBlock($snapshot), + 'deviceEnrollmentNotificationConfiguration' => $this->buildEnrollmentNotificationBlock($snapshot), + default => null, + }; + + if ($typeBlock !== null) { + $settings[] = $typeBlock; + } + + $settings = array_values(array_filter($settings)); + + return [ + 'status' => 'ok', + 'settings' => $settings, + 'warnings' => $warnings, + ]; + } + + /** + * @return array{type: string, title: string, entries: array}|null + */ + private function buildAutopilotBlock(array $snapshot): ?array + { + $entries = []; + + foreach ([ + 'deviceNameTemplate' => 'Device name template', + 'language' => 'Language', + 'locale' => 'Locale', + 'deploymentMode' => 'Deployment mode', + 'deviceType' => 'Device type', + 'enableWhiteGlove' => 'Pre-provisioning (White Glove)', + 'hybridAzureADJoinSkipConnectivityCheck' => 'Skip Hybrid AAD connectivity check', + ] as $key => $label) { + $value = Arr::get($snapshot, $key); + + if (is_string($value) && $value !== '') { + $entries[] = ['key' => $label, 'value' => $value]; + } elseif (is_bool($value)) { + $entries[] = ['key' => $label, 'value' => $value ? 'Enabled' : 'Disabled']; + } + } + + $oobe = Arr::get($snapshot, 'outOfBoxExperienceSettings'); + if (is_array($oobe) && $oobe !== []) { + $oobe = Arr::except($oobe, ['@odata.type']); + + foreach ($this->expandOutOfBoxExperienceEntries($oobe) as $entry) { + $entries[] = $entry; + } + } + + $assignments = Arr::get($snapshot, 'assignments'); + if (is_array($assignments) && $assignments !== []) { + $entries[] = ['key' => 'Assignments (snapshot)', 'value' => '[present]']; + } + + if ($entries === []) { + return null; + } + + return [ + 'type' => 'keyValue', + 'title' => 'Autopilot profile', + 'entries' => $entries, + ]; + } + + /** + * @return array + */ + private function expandOutOfBoxExperienceEntries(array $oobe): array + { + $knownKeys = [ + 'hideEULA' => 'Hide EULA', + 'userType' => 'User type', + 'hideEscapeLink' => 'Hide escape link', + 'deviceUsageType' => 'Device usage type', + 'hidePrivacySettings' => 'Hide privacy settings', + 'skipKeyboardSelectionPage' => 'Skip keyboard selection page', + 'skipExpressSettings' => 'Skip express settings', + ]; + + $entries = []; + + foreach ($knownKeys as $key => $label) { + if (! array_key_exists($key, $oobe)) { + continue; + } + + $value = $oobe[$key]; + + if (is_bool($value)) { + $entries[] = ['key' => "OOBE: {$label}", 'value' => $value ? 'Enabled' : 'Disabled']; + } elseif (is_string($value) && $value !== '') { + $entries[] = ['key' => "OOBE: {$label}", 'value' => $value]; + } elseif (is_int($value) || is_float($value)) { + $entries[] = ['key' => "OOBE: {$label}", 'value' => $value]; + } + + unset($oobe[$key]); + } + + foreach ($oobe as $key => $value) { + $label = Str::headline((string) $key); + + if (is_bool($value)) { + $entries[] = ['key' => "OOBE: {$label}", 'value' => $value ? 'Enabled' : 'Disabled']; + } elseif (is_string($value) && $value !== '') { + $entries[] = ['key' => "OOBE: {$label}", 'value' => $value]; + } elseif (is_int($value) || is_float($value)) { + $entries[] = ['key' => "OOBE: {$label}", 'value' => $value]; + } elseif (is_array($value) && $value !== []) { + $entries[] = ['key' => "OOBE: {$label}", 'value' => $value]; + } + } + + return $entries; + } + + /** + * @return array{type: string, title: string, entries: array}|null + */ + private function buildEnrollmentStatusPageBlock(array $snapshot): ?array + { + $entries = []; + + foreach ([ + 'priority' => 'Priority', + 'showInstallationProgress' => 'Show installation progress', + 'blockDeviceSetupRetryByUser' => 'Block retry by user', + 'allowDeviceResetOnInstallFailure' => 'Allow device reset on install failure', + 'installProgressTimeoutInMinutes' => 'Install progress timeout (minutes)', + 'allowLogCollectionOnInstallFailure' => 'Allow log collection on failure', + ] as $key => $label) { + $value = Arr::get($snapshot, $key); + + if (is_int($value) || is_float($value)) { + $entries[] = ['key' => $label, 'value' => $value]; + } elseif (is_string($value) && $value !== '') { + $entries[] = ['key' => $label, 'value' => $value]; + } elseif (is_bool($value)) { + $entries[] = ['key' => $label, 'value' => $value ? 'Enabled' : 'Disabled']; + } + } + + $selected = Arr::get($snapshot, 'selectedMobileAppIds'); + if (is_array($selected) && $selected !== []) { + $entries[] = ['key' => 'Selected mobile app IDs', 'value' => array_values($selected)]; + } + + $assigned = Arr::get($snapshot, 'assignments'); + if (is_array($assigned) && $assigned !== []) { + $entries[] = ['key' => 'Assignments (snapshot)', 'value' => '[present]']; + } + + if ($entries === []) { + return null; + } + + return [ + 'type' => 'keyValue', + 'title' => 'Enrollment Status Page (ESP)', + 'entries' => $entries, + ]; + } + + /** + * @return array{type: string, title: string, entries: array}|null + */ + private function buildEnrollmentRestrictionBlock(array $snapshot): ?array + { + $entries = []; + + foreach ([ + 'priority' => 'Priority', + 'version' => 'Version', + 'deviceEnrollmentConfigurationType' => 'Configuration type', + ] as $key => $label) { + $value = Arr::get($snapshot, $key); + + if (is_int($value) || is_float($value)) { + $entries[] = ['key' => $label, 'value' => $value]; + } elseif (is_string($value) && $value !== '') { + $entries[] = ['key' => $label, 'value' => $value]; + } + } + + $platformRestrictions = Arr::get($snapshot, 'platformRestrictions'); + $platformRestriction = Arr::get($snapshot, 'platformRestriction'); + + $platformPayload = is_array($platformRestrictions) && $platformRestrictions !== [] + ? $platformRestrictions + : (is_array($platformRestriction) ? $platformRestriction : null); + + if (is_array($platformPayload) && $platformPayload !== []) { + $platformPayload = Arr::except($platformPayload, ['@odata.type']); + + $platformBlocked = Arr::get($platformPayload, 'platformBlocked'); + if (is_bool($platformBlocked)) { + $entries[] = ['key' => 'Platform blocked', 'value' => $platformBlocked ? 'Enabled' : 'Disabled']; + } + + $personalBlocked = Arr::get($platformPayload, 'personalDeviceEnrollmentBlocked'); + if (is_bool($personalBlocked)) { + $entries[] = ['key' => 'Personal device enrollment blocked', 'value' => $personalBlocked ? 'Enabled' : 'Disabled']; + } + + $osMin = Arr::get($platformPayload, 'osMinimumVersion'); + $entries[] = [ + 'key' => 'OS minimum version', + 'value' => (is_string($osMin) && $osMin !== '') ? $osMin : 'None', + ]; + + $osMax = Arr::get($platformPayload, 'osMaximumVersion'); + $entries[] = [ + 'key' => 'OS maximum version', + 'value' => (is_string($osMax) && $osMax !== '') ? $osMax : 'None', + ]; + + $blockedManufacturers = Arr::get($platformPayload, 'blockedManufacturers'); + $entries[] = [ + 'key' => 'Blocked manufacturers', + 'value' => (is_array($blockedManufacturers) && $blockedManufacturers !== []) + ? array_values($blockedManufacturers) + : ['None'], + ]; + + $blockedSkus = Arr::get($platformPayload, 'blockedSkus'); + $entries[] = [ + 'key' => 'Blocked SKUs', + 'value' => (is_array($blockedSkus) && $blockedSkus !== []) + ? array_values($blockedSkus) + : ['None'], + ]; + } + + $assigned = Arr::get($snapshot, 'assignments'); + if (is_array($assigned) && $assigned !== []) { + $entries[] = ['key' => 'Assignments (snapshot)', 'value' => '[present]']; + } + + if ($entries === []) { + return null; + } + + return [ + 'type' => 'keyValue', + 'title' => 'Enrollment restrictions', + 'entries' => $entries, + ]; + } + + /** + * @return array{type: string, title: string, entries: array}|null + */ + private function buildEnrollmentLimitBlock(array $snapshot): ?array + { + $entries = []; + + foreach ([ + 'priority' => 'Priority', + 'version' => 'Version', + 'deviceEnrollmentConfigurationType' => 'Configuration type', + 'limit' => 'Enrollment limit', + 'limitType' => 'Limit type', + ] as $key => $label) { + $value = Arr::get($snapshot, $key); + + if (is_int($value) || is_float($value)) { + $entries[] = ['key' => $label, 'value' => $value]; + } elseif (is_string($value) && $value !== '') { + $entries[] = ['key' => $label, 'value' => $value]; + } elseif (is_bool($value)) { + $entries[] = ['key' => $label, 'value' => $value ? 'Enabled' : 'Disabled']; + } + } + + $assigned = Arr::get($snapshot, 'assignments'); + if (is_array($assigned) && $assigned !== []) { + $entries[] = ['key' => 'Assignments (snapshot)', 'value' => '[present]']; + } + + if ($entries === []) { + return null; + } + + return [ + 'type' => 'keyValue', + 'title' => 'Enrollment limits', + 'entries' => $entries, + ]; + } + + /** + * @return array{type: string, title: string, entries: array}|null + */ + private function buildEnrollmentPlatformRestrictionsBlock(array $snapshot): ?array + { + $entries = []; + + foreach ([ + 'priority' => 'Priority', + 'version' => 'Version', + 'platformType' => 'Platform type', + 'deviceEnrollmentConfigurationType' => 'Configuration type', + ] as $key => $label) { + $value = Arr::get($snapshot, $key); + + if (is_int($value) || is_float($value)) { + $entries[] = ['key' => $label, 'value' => $value]; + } elseif (is_string($value) && $value !== '') { + $entries[] = ['key' => $label, 'value' => $value]; + } + } + + $platformPayload = Arr::get($snapshot, 'platformRestrictions') ?? Arr::get($snapshot, 'platformRestriction'); + if (is_array($platformPayload) && $platformPayload !== []) { + $prefix = (string) (Arr::get($snapshot, 'platformType') ?: 'Platform'); + $this->appendPlatformRestrictionEntries($entries, $prefix, $platformPayload); + } + + $typedRestrictions = [ + 'androidForWorkRestriction' => 'Android work profile', + 'androidRestriction' => 'Android', + 'iosRestriction' => 'iOS/iPadOS', + 'macRestriction' => 'macOS', + 'windowsRestriction' => 'Windows', + ]; + + foreach ($typedRestrictions as $key => $prefix) { + $restriction = Arr::get($snapshot, $key); + + if (! is_array($restriction) || $restriction === []) { + continue; + } + + $this->appendPlatformRestrictionEntries($entries, $prefix, $restriction); + } + + $assigned = Arr::get($snapshot, 'assignments'); + if (is_array($assigned) && $assigned !== []) { + $entries[] = ['key' => 'Assignments (snapshot)', 'value' => '[present]']; + } + + if ($entries === []) { + return null; + } + + return [ + 'type' => 'keyValue', + 'title' => 'Platform restrictions (enrollment)', + 'entries' => $entries, + ]; + } + + /** + * @param array $entries + */ + private function appendPlatformRestrictionEntries(array &$entries, string $prefix, array $payload): void + { + $payload = Arr::except($payload, ['@odata.type']); + + $platformBlocked = Arr::get($payload, 'platformBlocked'); + if (is_bool($platformBlocked)) { + $entries[] = ['key' => "{$prefix}: Platform blocked", 'value' => $platformBlocked ? 'Enabled' : 'Disabled']; + } + + $personalBlocked = Arr::get($payload, 'personalDeviceEnrollmentBlocked'); + if (is_bool($personalBlocked)) { + $entries[] = ['key' => "{$prefix}: Personal device enrollment blocked", 'value' => $personalBlocked ? 'Enabled' : 'Disabled']; + } + + $osMin = Arr::get($payload, 'osMinimumVersion'); + $entries[] = [ + 'key' => "{$prefix}: OS minimum version", + 'value' => (is_string($osMin) && $osMin !== '') ? $osMin : 'None', + ]; + + $osMax = Arr::get($payload, 'osMaximumVersion'); + $entries[] = [ + 'key' => "{$prefix}: OS maximum version", + 'value' => (is_string($osMax) && $osMax !== '') ? $osMax : 'None', + ]; + + $blockedManufacturers = Arr::get($payload, 'blockedManufacturers'); + $entries[] = [ + 'key' => "{$prefix}: Blocked manufacturers", + 'value' => (is_array($blockedManufacturers) && $blockedManufacturers !== []) + ? array_values($blockedManufacturers) + : ['None'], + ]; + + $blockedSkus = Arr::get($payload, 'blockedSkus'); + $entries[] = [ + 'key' => "{$prefix}: Blocked SKUs", + 'value' => (is_array($blockedSkus) && $blockedSkus !== []) + ? array_values($blockedSkus) + : ['None'], + ]; + } + + /** + * @return array{type: string, title: string, entries: array}|null + */ + private function buildEnrollmentNotificationBlock(array $snapshot): ?array + { + $entries = []; + + foreach ([ + 'priority' => 'Priority', + 'version' => 'Version', + 'platformType' => 'Platform type', + 'deviceEnrollmentConfigurationType' => 'Configuration type', + 'brandingOptions' => 'Branding options', + 'templateType' => 'Template type', + 'defaultLocale' => 'Default locale', + 'notificationMessageTemplateId' => 'Notification message template ID', + ] as $key => $label) { + $value = Arr::get($snapshot, $key); + + if (is_int($value) || is_float($value)) { + $entries[] = ['key' => $label, 'value' => $value]; + } elseif (is_string($value) && $value !== '') { + $entries[] = ['key' => $label, 'value' => $value]; + } elseif (is_bool($value)) { + $entries[] = ['key' => $label, 'value' => $value ? 'Enabled' : 'Disabled']; + } + } + + $notificationTemplates = Arr::get($snapshot, 'notificationTemplates'); + if (is_array($notificationTemplates) && $notificationTemplates !== []) { + $entries[] = ['key' => 'Notification templates', 'value' => array_values($notificationTemplates)]; + } + + $templateSnapshots = Arr::get($snapshot, 'notificationTemplateSnapshots'); + if (is_array($templateSnapshots) && $templateSnapshots !== []) { + foreach ($templateSnapshots as $templateSnapshot) { + if (! is_array($templateSnapshot)) { + continue; + } + + $channel = Arr::get($templateSnapshot, 'channel'); + $channelLabel = is_string($channel) && $channel !== '' ? $channel : 'Template'; + + $templateId = Arr::get($templateSnapshot, 'template_id'); + if (is_string($templateId) && $templateId !== '') { + $entries[] = ['key' => "{$channelLabel} template ID", 'value' => $templateId]; + } + + $template = Arr::get($templateSnapshot, 'template'); + if (is_array($template) && $template !== []) { + $displayName = Arr::get($template, 'displayName'); + if (is_string($displayName) && $displayName !== '') { + $entries[] = ['key' => "{$channelLabel} template name", 'value' => $displayName]; + } + + $brandingOptions = Arr::get($template, 'brandingOptions'); + if (is_string($brandingOptions) && $brandingOptions !== '') { + $entries[] = ['key' => "{$channelLabel} branding options", 'value' => $brandingOptions]; + } + + $defaultLocale = Arr::get($template, 'defaultLocale'); + if (is_string($defaultLocale) && $defaultLocale !== '') { + $entries[] = ['key' => "{$channelLabel} default locale", 'value' => $defaultLocale]; + } + } + + $localizedMessages = Arr::get($templateSnapshot, 'localized_notification_messages'); + if (is_array($localizedMessages) && $localizedMessages !== []) { + foreach ($localizedMessages as $localizedMessage) { + if (! is_array($localizedMessage)) { + continue; + } + + $locale = Arr::get($localizedMessage, 'locale'); + $localeLabel = is_string($locale) && $locale !== '' ? $locale : 'locale'; + + $subject = Arr::get($localizedMessage, 'subject'); + if (is_string($subject) && $subject !== '') { + $entries[] = ['key' => "{$channelLabel} ({$localeLabel}) Subject", 'value' => $subject]; + } + + $messageTemplate = Arr::get($localizedMessage, 'messageTemplate'); + if (is_string($messageTemplate) && $messageTemplate !== '') { + $entries[] = ['key' => "{$channelLabel} ({$localeLabel}) Message", 'value' => $messageTemplate]; + } + + $isDefault = Arr::get($localizedMessage, 'isDefault'); + if (is_bool($isDefault)) { + $entries[] = ['key' => "{$channelLabel} ({$localeLabel}) Default", 'value' => $isDefault ? 'Enabled' : 'Disabled']; + } + } + } + } + } + + $assigned = Arr::get($snapshot, 'assignments'); + if (is_array($assigned) && $assigned !== []) { + $entries[] = ['key' => 'Assignments (snapshot)', 'value' => '[present]']; + } + + if ($entries === []) { + return null; + } + + return [ + 'type' => 'keyValue', + 'title' => 'Enrollment notifications', + 'entries' => $entries, + ]; + } + + /** + * @return array + */ + public function flattenForDiff(?array $snapshot, string $policyType, ?string $platform = null): array + { + $normalized = $this->normalize($snapshot ?? [], $policyType, $platform); + + return $this->defaultNormalizer->flattenNormalizedForDiff($normalized); + } +} diff --git a/app/Services/Intune/ManagedDeviceAppConfigurationNormalizer.php b/app/Services/Intune/ManagedDeviceAppConfigurationNormalizer.php new file mode 100644 index 0000000..27be266 --- /dev/null +++ b/app/Services/Intune/ManagedDeviceAppConfigurationNormalizer.php @@ -0,0 +1,143 @@ +>, settings_table?: array, warnings: array} + */ + public function normalize(?array $snapshot, string $policyType, ?string $platform = null): array + { + $snapshot = $snapshot ?? []; + $normalized = $this->defaultNormalizer->normalize($snapshot, $policyType, $platform); + + if ($snapshot === []) { + return $normalized; + } + + $normalized['settings'] = array_values(array_filter( + $normalized['settings'], + static function (array $block): bool { + $title = strtolower((string) ($block['title'] ?? '')); + + return $title !== 'settings' && $title !== 'settings delta'; + } + )); + + $rows = $this->buildSettingsRows($snapshot['settings'] ?? null); + + if ($rows !== []) { + $normalized['settings'][] = [ + 'type' => 'table', + 'title' => 'App configuration settings', + 'rows' => $rows, + ]; + } else { + $normalized['warnings'][] = 'No app configuration settings were returned by Graph. Intune only returns configured keys; items shown as "Not configured" in the portal are typically absent.'; + $normalized['warnings'] = array_values(array_unique(array_filter($normalized['warnings'], static fn ($value) => is_string($value) && $value !== ''))); + } + + return $normalized; + } + + /** + * @return array + */ + public function flattenForDiff(?array $snapshot, string $policyType, ?string $platform = null): array + { + $snapshot = $snapshot ?? []; + $normalized = $this->normalize($snapshot, $policyType, $platform); + + return $this->defaultNormalizer->flattenNormalizedForDiff($normalized); + } + + /** + * @return array> + */ + private function buildSettingsRows(mixed $settings): array + { + if (! is_array($settings) || $settings === []) { + return []; + } + + $rows = []; + + foreach ($settings as $setting) { + if (! is_array($setting)) { + continue; + } + + $key = $setting['appConfigKey'] ?? null; + $rawValue = $setting['appConfigKeyValue'] ?? null; + $type = $setting['appConfigKeyType'] ?? null; + + if (! is_string($key) || $key === '') { + continue; + } + + $value = $this->normalizeValue($rawValue, $type); + + $rows[] = [ + 'path' => $key, + 'label' => $key, + 'value' => is_scalar($value) || $value === null ? $value : json_encode($value, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE), + 'description' => is_string($type) && $type !== '' ? Str::headline($type) : null, + ]; + } + + return $rows; + } + + private function normalizeValue(mixed $value, mixed $type): mixed + { + $type = is_string($type) ? strtolower($type) : ''; + + if (is_bool($value)) { + return $value; + } + + if (is_int($value) || is_float($value)) { + return $value; + } + + if (is_string($value)) { + $trimmed = trim($value); + + if ($type !== '' && str_contains($type, 'boolean')) { + if (in_array(strtolower($trimmed), ['true', 'false'], true)) { + return strtolower($trimmed) === 'true'; + } + + if (in_array(strtolower($trimmed), ['yes', 'no'], true)) { + return strtolower($trimmed) === 'yes'; + } + + if (in_array($trimmed, ['1', '0'], true)) { + return $trimmed === '1'; + } + } + + if ($type !== '' && (str_contains($type, 'integer') || str_contains($type, 'int'))) { + if (is_numeric($trimmed) && (string) (int) $trimmed === $trimmed) { + return (int) $trimmed; + } + } + + return $trimmed; + } + + return $value; + } +} diff --git a/app/Services/Intune/PolicyCaptureOrchestrator.php b/app/Services/Intune/PolicyCaptureOrchestrator.php index c495b73..b7a5f78 100644 --- a/app/Services/Intune/PolicyCaptureOrchestrator.php +++ b/app/Services/Intune/PolicyCaptureOrchestrator.php @@ -47,13 +47,21 @@ public function capture( $snapshot = $this->snapshotService->fetch($tenant, $policy, $createdBy); if (isset($snapshot['failure'])) { - throw new \RuntimeException($snapshot['failure']['reason'] ?? 'Unable to fetch policy snapshot'); + return [ + 'failure' => $snapshot['failure'], + ]; } $payload = $snapshot['payload']; $assignments = null; $scopeTags = null; - $captureMetadata = []; + $captureMetadata = is_array($snapshot['metadata'] ?? null) ? $snapshot['metadata'] : []; + + $snapshotWarnings = is_array($snapshot['warnings'] ?? null) ? $snapshot['warnings'] : []; + if ($snapshotWarnings !== []) { + $existingWarnings = is_array($captureMetadata['warnings'] ?? null) ? $captureMetadata['warnings'] : []; + $captureMetadata['warnings'] = array_values(array_unique(array_merge($existingWarnings, $snapshotWarnings))); + } // 2. Fetch assignments if requested if ($includeAssignments) { @@ -179,9 +187,9 @@ public function capture( // 5. Create new PolicyVersion with all captured data $metadata = array_merge( - ['source' => 'orchestrated_capture'], + ['capture_source' => 'orchestrated_capture'], $metadata, - $captureMetadata + $captureMetadata, ); $version = $this->versionService->captureVersion( diff --git a/app/Services/Intune/PolicySnapshotService.php b/app/Services/Intune/PolicySnapshotService.php index c173b1b..aa3c57e 100644 --- a/app/Services/Intune/PolicySnapshotService.php +++ b/app/Services/Intune/PolicySnapshotService.php @@ -8,6 +8,7 @@ use App\Services\Graph\GraphContractRegistry; use App\Services\Graph\GraphErrorMapper; use App\Services\Graph\GraphLogger; +use App\Services\Graph\GraphResponse; use Illuminate\Support\Arr; use Throwable; @@ -62,6 +63,11 @@ public function fetch(Tenant $tenant, Policy $policy, ?string $actorEmail = null } catch (Throwable $throwable) { $mapped = GraphErrorMapper::fromThrowable($throwable, $context); + // For certain policy types experiencing upstream Graph issues, fall back to metadata-only + if ($this->shouldFallbackToMetadata($policy->policy_type, $mapped->status)) { + return $this->createMetadataOnlySnapshot($policy, $mapped->getMessage(), $mapped->status); + } + return [ 'failure' => [ 'policy_id' => $policy->id, @@ -77,8 +83,19 @@ public function fetch(Tenant $tenant, Policy $policy, ?string $actorEmail = null $metadata = Arr::except($response->data, ['payload']); $metadataWarnings = $metadata['warnings'] ?? []; - if ($policy->policy_type === 'settingsCatalogPolicy') { - [$payload, $metadata] = $this->hydrateSettingsCatalog( + if ($policy->policy_type === 'windowsUpdateRing') { + [$payload, $metadata] = $this->hydrateWindowsUpdateRing( + tenantIdentifier: $tenantIdentifier, + tenant: $tenant, + policyId: $policy->external_id, + payload: is_array($payload) ? $payload : [], + metadata: $metadata, + ); + } + + if (in_array($policy->policy_type, ['settingsCatalogPolicy', 'endpointSecurityPolicy', 'securityBaselinePolicy'], true)) { + [$payload, $metadata] = $this->hydrateConfigurationPolicySettings( + policyType: $policy->policy_type, tenantIdentifier: $tenantIdentifier, tenant: $tenant, policyId: $policy->external_id, @@ -107,8 +124,22 @@ public function fetch(Tenant $tenant, Policy $policy, ?string $actorEmail = null ); } + if ($policy->policy_type === 'deviceEnrollmentNotificationConfiguration') { + [$payload, $metadata] = $this->hydrateEnrollmentNotificationTemplates( + tenantIdentifier: $tenantIdentifier, + tenant: $tenant, + payload: is_array($payload) ? $payload : [], + metadata: $metadata + ); + } + if ($response->failed()) { - $reason = $response->warnings[0] ?? 'Graph request failed'; + $reason = $this->formatGraphFailureReason($response); + + if ($this->shouldFallbackToMetadata($policy->policy_type, $response->status)) { + return $this->createMetadataOnlySnapshot($policy, $reason, $response->status); + } + $failure = [ 'policy_id' => $policy->id, 'reason' => $reason, @@ -152,6 +183,98 @@ public function fetch(Tenant $tenant, Policy $policy, ?string $actorEmail = null ]; } + private function formatGraphFailureReason(GraphResponse $response): string + { + $code = $response->meta['error_code'] + ?? ($response->errors[0]['code'] ?? null) + ?? ($response->data['error']['code'] ?? null); + + $message = $response->meta['error_message'] + ?? ($response->errors[0]['message'] ?? null) + ?? ($response->data['error']['message'] ?? null) + ?? ($response->warnings[0] ?? null); + + $reason = 'Graph request failed'; + + if (is_string($message) && $message !== '') { + $reason = $message; + } + + if (is_string($code) && $code !== '') { + $reason = sprintf('%s: %s', $code, $reason); + } + + $requestId = $response->meta['request_id'] ?? null; + $clientRequestId = $response->meta['client_request_id'] ?? null; + + $suffixParts = []; + + if (is_string($clientRequestId) && $clientRequestId !== '') { + $suffixParts[] = sprintf('client_request_id=%s', $clientRequestId); + } + + if (is_string($requestId) && $requestId !== '') { + $suffixParts[] = sprintf('request_id=%s', $requestId); + } + + if ($suffixParts !== []) { + $reason = sprintf('%s (%s)', $reason, implode(', ', $suffixParts)); + } + + return $reason; + } + + /** + * Hydrate Windows Update Ring payload via derived type cast to capture + * windowsUpdateForBusinessConfiguration-specific properties. + * + * @return array{0:array,1:array} + */ + private function hydrateWindowsUpdateRing(string $tenantIdentifier, Tenant $tenant, string $policyId, array $payload, array $metadata): array + { + $odataType = $payload['@odata.type'] ?? null; + $castSegment = $this->deriveTypeCastSegment($odataType); + + if ($castSegment === null) { + $metadata['properties_hydration'] = 'skipped'; + + return [$payload, $metadata]; + } + + $castPath = sprintf('deviceManagement/deviceConfigurations/%s/%s', urlencode($policyId), $castSegment); + + $response = $this->graphClient->request('GET', $castPath, [ + 'tenant' => $tenantIdentifier, + 'client_id' => $tenant->app_client_id, + 'client_secret' => $tenant->app_client_secret, + ]); + + if ($response->failed() || ! is_array($response->data)) { + $metadata['properties_hydration'] = 'failed'; + + return [$payload, $metadata]; + } + + $metadata['properties_hydration'] = 'complete'; + + return [array_merge($payload, $response->data), $metadata]; + } + + private function deriveTypeCastSegment(mixed $odataType): ?string + { + if (! is_string($odataType) || $odataType === '') { + return null; + } + + if (! str_starts_with($odataType, '#')) { + return null; + } + + $segment = ltrim($odataType, '#'); + + return $segment !== '' ? $segment : null; + } + private function isMetadataOnlyPolicyType(string $policyType): bool { foreach (config('tenantpilot.supported_policy_types', []) as $type) { @@ -202,14 +325,14 @@ private function filterMetadataOnlyPayload(string $policyType, array $payload): } /** - * Hydrate settings catalog policies with configuration settings subresource. + * Hydrate configurationPolicies settings via settings subresource (Settings Catalog / Endpoint Security / Baselines). * * @return array{0:array,1:array} */ - private function hydrateSettingsCatalog(string $tenantIdentifier, Tenant $tenant, string $policyId, array $payload, array $metadata): array + private function hydrateConfigurationPolicySettings(string $policyType, string $tenantIdentifier, Tenant $tenant, string $policyId, array $payload, array $metadata): array { - $strategy = $this->contracts->memberHydrationStrategy('settingsCatalogPolicy'); - $settingsPath = $this->contracts->subresourceSettingsPath('settingsCatalogPolicy', $policyId); + $strategy = $this->contracts->memberHydrationStrategy($policyType); + $settingsPath = $this->contracts->subresourceSettingsPath($policyType, $policyId); if ($strategy !== 'subresource_settings' || ! $settingsPath) { return [$payload, $metadata]; @@ -493,6 +616,126 @@ private function hydrateComplianceActions(string $tenantIdentifier, Tenant $tena return [$payload, $metadata]; } + /** + * Hydrate enrollment notifications with message template details. + * + * @return array{0:array,1:array} + */ + private function hydrateEnrollmentNotificationTemplates(string $tenantIdentifier, Tenant $tenant, array $payload, array $metadata): array + { + $existing = $payload['notificationTemplateSnapshots'] ?? null; + + if (is_array($existing) && $existing !== []) { + $metadata['enrollment_notification_templates_hydration'] = 'embedded'; + + return [$payload, $metadata]; + } + + $templateRefs = $payload['notificationTemplates'] ?? null; + + if (! is_array($templateRefs) || $templateRefs === []) { + $metadata['enrollment_notification_templates_hydration'] = 'none'; + + return [$payload, $metadata]; + } + + $options = [ + 'tenant' => $tenantIdentifier, + 'client_id' => $tenant->app_client_id, + 'client_secret' => $tenant->app_client_secret, + ]; + + $snapshots = []; + $failures = 0; + + foreach ($templateRefs as $templateRef) { + if (! is_string($templateRef) || $templateRef === '') { + continue; + } + + [$channel, $templateId] = $this->parseEnrollmentNotificationTemplateRef($templateRef); + + if ($templateId === null) { + $failures++; + + continue; + } + + $templatePath = sprintf('deviceManagement/notificationMessageTemplates/%s', urlencode($templateId)); + $templateResponse = $this->graphClient->request('GET', $templatePath, $options); + + if ($templateResponse->failed() || ! is_array($templateResponse->data)) { + $failures++; + + continue; + } + + $template = Arr::except($templateResponse->data, ['@odata.context']); + + $messagesPath = sprintf( + 'deviceManagement/notificationMessageTemplates/%s/localizedNotificationMessages', + urlencode($templateId) + ); + $messagesResponse = $this->graphClient->request('GET', $messagesPath, $options); + + $messages = []; + + if ($messagesResponse->failed()) { + $failures++; + } else { + $pageItems = $messagesResponse->data['value'] ?? []; + + if (is_array($pageItems)) { + foreach ($pageItems as $message) { + if (is_array($message)) { + $messages[] = Arr::except($message, ['@odata.context']); + } + } + } + } + + $snapshots[] = [ + 'channel' => $channel, + 'template_id' => $templateId, + 'template' => $template, + 'localized_notification_messages' => $messages, + ]; + } + + if ($snapshots === []) { + $metadata['enrollment_notification_templates_hydration'] = 'failed'; + + return [$payload, $metadata]; + } + + $payload['notificationTemplateSnapshots'] = $snapshots; + + $metadata['enrollment_notification_templates_hydration'] = $failures > 0 ? 'partial' : 'complete'; + + return [$payload, $metadata]; + } + + /** + * @return array{0:?string,1:?string} + */ + private function parseEnrollmentNotificationTemplateRef(string $templateRef): array + { + if (! str_contains($templateRef, '_')) { + return [null, $templateRef]; + } + + [$channel, $templateId] = explode('_', $templateRef, 2); + + $channel = trim($channel); + $templateId = trim($templateId); + + if ($templateId === '') { + return [$channel !== '' ? $channel : null, null]; + } + + return [$channel !== '' ? $channel : null, $templateId]; + } + /** * Extract all settingDefinitionId from settings array, including nested children. */ @@ -531,6 +774,69 @@ private function stripGraphBaseUrl(string $nextLink): string return ltrim(substr($nextLink, strlen($base)), '/'); } - return ltrim($nextLink, '/'); + return $nextLink; + } + + /** + * Determine if we should fall back to metadata-only for this policy type and error. + */ + private function shouldFallbackToMetadata(string $policyType, ?int $status): bool + { + // Only fallback on 5xx server errors + if ($status === null || $status < 500 || $status >= 600) { + return false; + } + + // Enable fallback for policy types experiencing upstream Graph issues + $fallbackTypes = [ + 'mamAppConfiguration', + 'managedDeviceAppConfiguration', + ]; + + return in_array($policyType, $fallbackTypes, true); + } + + /** + * Create a metadata-only snapshot from the Policy model when Graph is unavailable. + * + * @return array{payload:array,metadata:array,warnings:array} + */ + private function createMetadataOnlySnapshot(Policy $policy, string $failureReason, ?int $status): array + { + $odataType = match ($policy->policy_type) { + 'mamAppConfiguration' => '#microsoft.graph.targetedManagedAppConfiguration', + 'managedDeviceAppConfiguration' => '#microsoft.graph.managedDeviceMobileAppConfiguration', + default => '#microsoft.graph.'.$policy->policy_type, + }; + + $payload = [ + 'id' => $policy->external_id, + 'displayName' => $policy->display_name, + '@odata.type' => $odataType, + 'createdDateTime' => $policy->created_at?->toIso8601String(), + 'lastModifiedDateTime' => $policy->updated_at?->toIso8601String(), + ]; + + if ($policy->platform) { + $payload['platform'] = $policy->platform; + } + + $metadata = [ + 'source' => 'metadata_only', + 'original_failure' => $failureReason, + 'original_status' => $status, + 'warnings' => [ + sprintf( + 'Snapshot captured from local metadata only (Graph API returned %s). Restore preview available, full restore not possible.', + $status ?? 'error' + ), + ], + ]; + + return [ + 'payload' => $payload, + 'metadata' => $metadata, + 'warnings' => $metadata['warnings'], + ]; } } diff --git a/app/Services/Intune/PolicySyncService.php b/app/Services/Intune/PolicySyncService.php index 3d4fc06..ec42c31 100644 --- a/app/Services/Intune/PolicySyncService.php +++ b/app/Services/Intune/PolicySyncService.php @@ -3,6 +3,7 @@ namespace App\Services\Intune; use App\Models\Policy; +use App\Models\PolicyVersion; use App\Models\Tenant; use App\Services\Graph\GraphClientInterface; use App\Services\Graph\GraphErrorMapper; @@ -24,6 +25,19 @@ public function __construct( * @return array IDs of policies synced or created */ public function syncPolicies(Tenant $tenant, ?array $supportedTypes = null): array + { + $result = $this->syncPoliciesWithReport($tenant, $supportedTypes); + + return $result['synced']; + } + + /** + * Sync supported policies for a tenant from Microsoft Graph. + * + * @param array|null $supportedTypes + * @return array{synced: array, failures: array} + */ + public function syncPoliciesWithReport(Tenant $tenant, ?array $supportedTypes = null): array { if (! $tenant->isActive()) { throw new \RuntimeException('Tenant is archived or inactive.'); @@ -31,6 +45,7 @@ public function syncPolicies(Tenant $tenant, ?array $supportedTypes = null): arr $types = $supportedTypes ?? config('tenantpilot.supported_policy_types', []); $synced = []; + $failures = []; $tenantIdentifier = $tenant->tenant_id ?? $tenant->external_id; foreach ($types as $typeConfig) { @@ -68,6 +83,13 @@ public function syncPolicies(Tenant $tenant, ?array $supportedTypes = null): arr ]); if ($response->failed()) { + $failures[] = [ + 'policy_type' => $policyType, + 'status' => $response->status, + 'errors' => $response->errors, + 'meta' => $response->meta, + ]; + continue; } @@ -78,6 +100,12 @@ public function syncPolicies(Tenant $tenant, ?array $supportedTypes = null): arr continue; } + $canonicalPolicyType = $this->resolveCanonicalPolicyType($policyType, $policyData); + + if ($canonicalPolicyType !== $policyType) { + continue; + } + if ($policyType === 'appProtectionPolicy') { $odataType = $policyData['@odata.type'] ?? $policyData['@OData.Type'] ?? null; @@ -96,15 +124,17 @@ public function syncPolicies(Tenant $tenant, ?array $supportedTypes = null): arr $displayName = $policyData['displayName'] ?? $policyData['name'] ?? 'Unnamed policy'; $policyPlatform = $platform ?? ($policyData['platform'] ?? null); - $existingWithDifferentType = Policy::query() - ->where('tenant_id', $tenant->id) - ->where('external_id', $externalId) - ->where('policy_type', '!=', $policyType) - ->exists(); + $this->reclassifyEnrollmentConfigurationPoliciesIfNeeded( + tenantId: $tenant->id, + externalId: $externalId, + policyType: $policyType, + ); - if ($existingWithDifferentType) { - continue; - } + $this->reclassifyConfigurationPoliciesIfNeeded( + tenantId: $tenant->id, + externalId: $externalId, + policyType: $policyType, + ); $policy = Policy::updateOrCreate( [ @@ -125,7 +155,282 @@ public function syncPolicies(Tenant $tenant, ?array $supportedTypes = null): arr } } - return $synced; + return [ + 'synced' => $synced, + 'failures' => $failures, + ]; + } + + private function resolveCanonicalPolicyType(string $policyType, array $policyData): string + { + if (in_array($policyType, ['settingsCatalogPolicy', 'endpointSecurityPolicy', 'securityBaselinePolicy'], true)) { + return $this->resolveConfigurationPolicyType($policyData); + } + + $enrollmentConfigurationTypes = [ + 'enrollmentRestriction', + 'windowsEnrollmentStatusPage', + 'deviceEnrollmentLimitConfiguration', + 'deviceEnrollmentPlatformRestrictionsConfiguration', + 'deviceEnrollmentNotificationConfiguration', + ]; + + if (! in_array($policyType, $enrollmentConfigurationTypes, true)) { + return $policyType; + } + + if ($this->isEnrollmentStatusPageItem($policyData)) { + return 'windowsEnrollmentStatusPage'; + } + + if ($this->isEnrollmentNotificationItem($policyData)) { + return 'deviceEnrollmentNotificationConfiguration'; + } + + if ($this->isEnrollmentLimitItem($policyData)) { + return 'deviceEnrollmentLimitConfiguration'; + } + + if ($this->isEnrollmentPlatformRestrictionsItem($policyData)) { + return 'deviceEnrollmentPlatformRestrictionsConfiguration'; + } + + return 'enrollmentRestriction'; + } + + private function resolveConfigurationPolicyType(array $policyData): string + { + if ($this->isSecurityBaselineConfigurationPolicy($policyData)) { + return 'securityBaselinePolicy'; + } + + if ($this->isEndpointSecurityConfigurationPolicy($policyData)) { + return 'endpointSecurityPolicy'; + } + + return 'settingsCatalogPolicy'; + } + + private function isEndpointSecurityConfigurationPolicy(array $policyData): bool + { + $technologies = $policyData['technologies'] ?? null; + + if (is_string($technologies)) { + if (strcasecmp(trim($technologies), 'endpointSecurity') === 0) { + return true; + } + } + + if (is_array($technologies)) { + foreach ($technologies as $technology) { + if (is_string($technology) && strcasecmp(trim($technology), 'endpointSecurity') === 0) { + return true; + } + } + } + + $templateReference = $policyData['templateReference'] ?? null; + + if (! is_array($templateReference)) { + return false; + } + + foreach ($templateReference as $value) { + if (is_string($value) && stripos($value, 'endpoint') !== false) { + return true; + } + } + + return false; + } + + private function isSecurityBaselineConfigurationPolicy(array $policyData): bool + { + $templateReference = $policyData['templateReference'] ?? null; + + if (! is_array($templateReference)) { + return false; + } + + $templateFamily = $templateReference['templateFamily'] ?? null; + if (is_string($templateFamily) && stripos($templateFamily, 'baseline') !== false) { + return true; + } + + foreach ($templateReference as $value) { + if (is_string($value) && stripos($value, 'baseline') !== false) { + return true; + } + } + + return false; + } + + private function isEnrollmentStatusPageItem(array $policyData): bool + { + $odataType = $policyData['@odata.type'] ?? $policyData['@OData.Type'] ?? null; + $configurationType = $policyData['deviceEnrollmentConfigurationType'] ?? null; + + return (is_string($odataType) && strcasecmp($odataType, '#microsoft.graph.windows10EnrollmentCompletionPageConfiguration') === 0) + || (is_string($configurationType) && $configurationType === 'windows10EnrollmentCompletionPageConfiguration'); + } + + private function isEnrollmentLimitItem(array $policyData): bool + { + $odataType = $policyData['@odata.type'] ?? $policyData['@OData.Type'] ?? null; + $configurationType = $policyData['deviceEnrollmentConfigurationType'] ?? null; + + return (is_string($odataType) && strcasecmp($odataType, '#microsoft.graph.deviceEnrollmentLimitConfiguration') === 0) + || (is_string($configurationType) && strcasecmp($configurationType, 'deviceEnrollmentLimitConfiguration') === 0); + } + + private function isEnrollmentPlatformRestrictionsItem(array $policyData): bool + { + $odataType = $policyData['@odata.type'] ?? $policyData['@OData.Type'] ?? null; + $configurationType = $policyData['deviceEnrollmentConfigurationType'] ?? null; + + if (is_string($odataType) && $odataType !== '') { + $odataTypeKey = strtolower($odataType); + + if (in_array($odataTypeKey, [ + '#microsoft.graph.deviceenrollmentplatformrestrictionconfiguration', + '#microsoft.graph.deviceenrollmentplatformrestrictionsconfiguration', + ], true)) { + return true; + } + } + + if (is_string($configurationType) && $configurationType !== '') { + $configurationTypeKey = strtolower($configurationType); + + if (in_array($configurationTypeKey, [ + 'deviceenrollmentplatformrestrictionconfiguration', + 'deviceenrollmentplatformrestrictionsconfiguration', + ], true)) { + return true; + } + } + + return false; + } + + private function isEnrollmentNotificationItem(array $policyData): bool + { + $odataType = $policyData['@odata.type'] ?? $policyData['@OData.Type'] ?? null; + $configurationType = $policyData['deviceEnrollmentConfigurationType'] ?? null; + + if (is_string($odataType) && strcasecmp($odataType, '#microsoft.graph.deviceEnrollmentNotificationConfiguration') === 0) { + return true; + } + + if (! is_string($configurationType) || $configurationType === '') { + return false; + } + + return in_array(strtolower($configurationType), [ + 'enrollmentnotificationsconfiguration', + 'deviceenrollmentnotificationconfiguration', + ], true); + } + + private function reclassifyEnrollmentConfigurationPoliciesIfNeeded(int $tenantId, string $externalId, string $policyType): void + { + $enrollmentTypes = [ + 'enrollmentRestriction', + 'windowsEnrollmentStatusPage', + 'deviceEnrollmentLimitConfiguration', + 'deviceEnrollmentPlatformRestrictionsConfiguration', + 'deviceEnrollmentNotificationConfiguration', + ]; + + if (! in_array($policyType, $enrollmentTypes, true)) { + return; + } + + $existingCorrect = Policy::query() + ->where('tenant_id', $tenantId) + ->where('external_id', $externalId) + ->where('policy_type', $policyType) + ->first(); + + if ($existingCorrect) { + Policy::query() + ->where('tenant_id', $tenantId) + ->where('external_id', $externalId) + ->whereIn('policy_type', $enrollmentTypes) + ->where('policy_type', '!=', $policyType) + ->whereNull('ignored_at') + ->update(['ignored_at' => now()]); + + return; + } + + $existingWrong = Policy::query() + ->where('tenant_id', $tenantId) + ->where('external_id', $externalId) + ->whereIn('policy_type', $enrollmentTypes) + ->where('policy_type', '!=', $policyType) + ->whereNull('ignored_at') + ->first(); + + if (! $existingWrong) { + return; + } + + $existingWrong->forceFill([ + 'policy_type' => $policyType, + ])->save(); + + PolicyVersion::query() + ->where('policy_id', $existingWrong->id) + ->update(['policy_type' => $policyType]); + } + + private function reclassifyConfigurationPoliciesIfNeeded(int $tenantId, string $externalId, string $policyType): void + { + $configurationTypes = ['settingsCatalogPolicy', 'endpointSecurityPolicy', 'securityBaselinePolicy']; + + if (! in_array($policyType, $configurationTypes, true)) { + return; + } + + $existingCorrect = Policy::query() + ->where('tenant_id', $tenantId) + ->where('external_id', $externalId) + ->where('policy_type', $policyType) + ->first(); + + if ($existingCorrect) { + Policy::query() + ->where('tenant_id', $tenantId) + ->where('external_id', $externalId) + ->whereIn('policy_type', $configurationTypes) + ->where('policy_type', '!=', $policyType) + ->whereNull('ignored_at') + ->update(['ignored_at' => now()]); + + return; + } + + $existingWrong = Policy::query() + ->where('tenant_id', $tenantId) + ->where('external_id', $externalId) + ->whereIn('policy_type', $configurationTypes) + ->where('policy_type', '!=', $policyType) + ->whereNull('ignored_at') + ->first(); + + if (! $existingWrong) { + return; + } + + $existingWrong->forceFill([ + 'policy_type' => $policyType, + ])->save(); + + PolicyVersion::query() + ->where('policy_id', $existingWrong->id) + ->update(['policy_type' => $policyType]); } /** diff --git a/app/Services/Intune/RestoreRiskChecker.php b/app/Services/Intune/RestoreRiskChecker.php index 96ff30c..84be502 100644 --- a/app/Services/Intune/RestoreRiskChecker.php +++ b/app/Services/Intune/RestoreRiskChecker.php @@ -16,6 +16,7 @@ class RestoreRiskChecker { public function __construct( private readonly GroupResolver $groupResolver, + private readonly ConfigurationPolicyTemplateResolver $templateResolver, ) {} /** @@ -38,7 +39,9 @@ public function check(Tenant $tenant, BackupSet $backupSet, ?array $selectedItem $results = []; $results[] = $this->checkOrphanedGroups($tenant, $policyItems, $groupMapping); + $results[] = $this->checkMetadataOnlySnapshots($policyItems); $results[] = $this->checkPreviewOnlyPolicies($policyItems); + $results[] = $this->checkEndpointSecurityTemplates($tenant, $policyItems); $results[] = $this->checkMissingPolicies($tenant, $policyItems); $results[] = $this->checkStalePolicies($tenant, $policyItems); $results[] = $this->checkMissingScopeTagsInScope($items, $policyItems, $selectedItemIds !== null); @@ -228,6 +231,176 @@ private function checkPreviewOnlyPolicies(Collection $policyItems): ?array ]; } + /** + * Validate that Endpoint Security policy templates referenced by snapshots exist in the tenant. + * + * @param Collection $policyItems + * @return array{code: string, severity: string, title: string, message: string, meta: array}|null + */ + private function checkEndpointSecurityTemplates(Tenant $tenant, Collection $policyItems): ?array + { + $issues = []; + $hasRestoreEnabled = false; + $graphOptions = $tenant->graphOptions(); + + foreach ($policyItems as $item) { + if ($item->policy_type !== 'endpointSecurityPolicy') { + continue; + } + + $restoreMode = $this->resolveRestoreMode($item->policy_type); + + if ($restoreMode !== 'preview-only') { + $hasRestoreEnabled = true; + } + + $payload = is_array($item->payload) ? $item->payload : []; + $templateReference = $payload['templateReference'] ?? null; + + if (is_string($templateReference)) { + $decoded = json_decode($templateReference, true); + $templateReference = is_array($decoded) ? $decoded : null; + } + + if (! is_array($templateReference)) { + $issues[] = [ + 'backup_item_id' => $item->id, + 'policy_identifier' => $item->policy_identifier, + 'label' => $item->resolvedDisplayName(), + 'reason' => 'Missing templateReference in snapshot.', + ]; + + continue; + } + + $outcome = $this->templateResolver->resolveTemplateReference($tenant, $templateReference, $graphOptions); + + if (! ($outcome['success'] ?? false)) { + $issues[] = [ + 'backup_item_id' => $item->id, + 'policy_identifier' => $item->policy_identifier, + 'label' => $item->resolvedDisplayName(), + 'template_id' => $templateReference['templateId'] ?? null, + 'template_family' => $templateReference['templateFamily'] ?? null, + 'reason' => $outcome['reason'] ?? 'Template could not be resolved in the tenant.', + ]; + } + } + + if ($issues === []) { + return [ + 'code' => 'endpoint_security_templates', + 'severity' => 'safe', + 'title' => 'Endpoint security templates', + 'message' => 'All referenced Endpoint Security templates are available.', + 'meta' => [ + 'count' => 0, + ], + ]; + } + + $severity = $hasRestoreEnabled ? 'blocking' : 'warning'; + $message = $hasRestoreEnabled + ? 'Some Endpoint Security templates are missing or cannot be resolved in the tenant.' + : 'Some Endpoint Security templates are missing or cannot be resolved (execution is preview-only).'; + + return [ + 'code' => 'endpoint_security_templates', + 'severity' => $severity, + 'title' => 'Endpoint security templates', + 'message' => $message, + 'meta' => [ + 'count' => count($issues), + 'items' => $this->truncateList($issues, 10), + ], + ]; + } + + /** + * Detect snapshots that were captured as metadata-only. + * + * These snapshots cannot be safely restored because they do not contain the + * complete settings payload. + * + * @param Collection $policyItems + * @return array{code: string, severity: string, title: string, message: string, meta: array}|null + */ + private function checkMetadataOnlySnapshots(Collection $policyItems): ?array + { + $affected = []; + $hasRestoreEnabled = false; + + foreach ($policyItems as $item) { + if (! $this->isMetadataOnlySnapshot($item)) { + continue; + } + + $restoreMode = $this->resolveRestoreMode($item->policy_type); + if ($restoreMode !== 'preview-only') { + $hasRestoreEnabled = true; + } + + $affected[] = [ + 'backup_item_id' => $item->id, + 'policy_identifier' => $item->policy_identifier, + 'policy_type' => $item->policy_type, + 'label' => $item->resolvedDisplayName(), + 'restore_mode' => $restoreMode, + ]; + } + + if ($affected === []) { + return [ + 'code' => 'metadata_only', + 'severity' => 'safe', + 'title' => 'Snapshot completeness', + 'message' => 'No metadata-only snapshots detected.', + 'meta' => [ + 'count' => 0, + ], + ]; + } + + $severity = $hasRestoreEnabled ? 'blocking' : 'warning'; + $message = $hasRestoreEnabled + ? 'Some selected items were captured as metadata-only. Restore cannot execute until Graph works again.' + : 'Some selected items were captured as metadata-only. Execution is preview-only, but payload completeness is limited.'; + + return [ + 'code' => 'metadata_only', + 'severity' => $severity, + 'title' => 'Snapshot completeness', + 'message' => $message, + 'meta' => [ + 'count' => count($affected), + 'items' => $this->truncateList($affected, 10), + ], + ]; + } + + private function isMetadataOnlySnapshot(BackupItem $item): bool + { + $metadata = is_array($item->metadata) ? $item->metadata : []; + + $source = $metadata['source'] ?? null; + $snapshotSource = $metadata['snapshot_source'] ?? null; + + if ($source === 'metadata_only' || $snapshotSource === 'metadata_only') { + return true; + } + + $warnings = $metadata['warnings'] ?? null; + if (is_array($warnings)) { + foreach ($warnings as $warning) { + if (is_string($warning) && Str::contains(Str::lower($warning), 'metadata only')) { + return true; + } + } + } + + return false; + } + /** * @param Collection $policyItems * @return array{code: string, severity: string, title: string, message: string, meta: array}|null @@ -583,7 +756,17 @@ private function resolveRestoreMode(?string $policyType): string { $meta = $this->resolveTypeMeta($policyType); - return (string) ($meta['restore'] ?? 'enabled'); + if ($meta === []) { + return 'preview-only'; + } + + $restore = $meta['restore'] ?? 'enabled'; + + if (! is_string($restore) || $restore === '') { + return 'enabled'; + } + + return $restore; } private function resolveTypeLabel(?string $policyType): string diff --git a/app/Services/Intune/RestoreService.php b/app/Services/Intune/RestoreService.php index e9e02d1..b62ed49 100644 --- a/app/Services/Intune/RestoreService.php +++ b/app/Services/Intune/RestoreService.php @@ -27,6 +27,7 @@ public function __construct( private readonly VersionService $versionService, private readonly SnapshotValidator $snapshotValidator, private readonly GraphContractRegistry $contracts, + private readonly ConfigurationPolicyTemplateResolver $templateResolver, private readonly AssignmentRestoreService $assignmentRestoreService, private readonly FoundationMappingService $foundationMappingService, ) {} @@ -151,6 +152,18 @@ public function executeFromPolicyVersion( 'version_captured_at' => $version->captured_at?->toIso8601String(), ]; + $versionMetadata = is_array($version->metadata) ? $version->metadata : []; + $snapshotSource = $versionMetadata['source'] ?? null; + + if (is_string($snapshotSource) && $snapshotSource !== '' && $snapshotSource !== 'policy_version') { + $backupItemMetadata['snapshot_source'] = $snapshotSource; + } + + $snapshotWarnings = $versionMetadata['warnings'] ?? null; + if (is_array($snapshotWarnings) && $snapshotWarnings !== []) { + $backupItemMetadata['warnings'] = array_values(array_unique(array_filter($snapshotWarnings, static fn ($value) => is_string($value) && $value !== ''))); + } + if (is_array($scopeTagIds) && $scopeTagIds !== []) { $backupItemMetadata['scope_tag_ids'] = $scopeTagIds; } @@ -418,12 +431,13 @@ public function execute( $createdPolicyMode = null; $settingsApplyEligible = false; - if ($item->policy_type === 'settingsCatalogPolicy') { + if (in_array($item->policy_type, ['settingsCatalogPolicy', 'endpointSecurityPolicy'], true)) { + $policyType = $item->policy_type; $settings = $this->extractSettingsCatalogSettings($originalPayload); $policyPayload = $this->stripSettingsFromPayload($payload); $response = $this->graphClient->applyPolicy( - $item->policy_type, + $policyType, $item->policy_identifier, $policyPayload, $graphOptions + ['method' => $updateMethod] @@ -431,8 +445,19 @@ public function execute( $settingsApplyEligible = $response->successful(); - if ($response->failed() && $this->shouldAttemptPolicyCreate($item->policy_type, $response)) { + if ($response->failed() && $this->shouldAttemptPolicyCreate($policyType, $response)) { + if ($policyType === 'endpointSecurityPolicy') { + $originalPayload = $this->prepareEndpointSecurityPolicyForCreate( + tenant: $tenant, + originalPayload: $originalPayload, + settings: $settings, + graphOptions: $graphOptions, + context: $context, + ); + } + $createOutcome = $this->createSettingsCatalogPolicy( + policyType: $policyType, originalPayload: $originalPayload, settings: $settings, graphOptions: $graphOptions, @@ -476,6 +501,7 @@ public function execute( if ($settingsApplyEligible && $settings !== []) { [$settingsApply, $itemStatus] = $this->applySettingsCatalogPolicySettings( + policyType: $policyType, policyId: $item->policy_identifier, settings: $settings, graphOptions: $graphOptions, @@ -484,7 +510,18 @@ public function execute( if ($itemStatus === 'manual_required' && $settingsApply !== null && $this->shouldAttemptSettingsCatalogCreate($settingsApply)) { + if ($policyType === 'endpointSecurityPolicy') { + $originalPayload = $this->prepareEndpointSecurityPolicyForCreate( + tenant: $tenant, + originalPayload: $originalPayload, + settings: $settings, + graphOptions: $graphOptions, + context: $context, + ); + } + $createOutcome = $this->createSettingsCatalogPolicy( + policyType: $policyType, originalPayload: $originalPayload, settings: $settings, graphOptions: $graphOptions, @@ -527,14 +564,6 @@ public function execute( ]; } } - } elseif ($settingsApplyEligible && $settings !== []) { - $settingsApply = [ - 'total' => count($settings), - 'applied' => 0, - 'failed' => count($settings), - 'manual_required' => 0, - 'issues' => [], - ]; } } else { if ($item->policy_type === 'appProtectionPolicy') { @@ -555,6 +584,23 @@ public function execute( $payload, $graphOptions + ['method' => $updateMethod] ); + } elseif ($item->policy_type === 'windowsUpdateRing') { + $odataType = $this->resolvePayloadString($originalPayload, ['@odata.type']); + $castSegment = $odataType && str_starts_with($odataType, '#') + ? ltrim($odataType, '#') + : 'microsoft.graph.windowsUpdateForBusinessConfiguration'; + + $updatePath = sprintf( + 'deviceManagement/deviceConfigurations/%s/%s', + urlencode($item->policy_identifier), + $castSegment, + ); + + $response = $this->graphClient->request( + $updateMethod, + $updatePath, + ['json' => $payload] + Arr::except($graphOptions, ['platform']) + ); } else { $response = $this->graphClient->applyPolicy( $item->policy_type, @@ -630,6 +676,8 @@ public function execute( 'graph_error_code' => $response->meta['error_code'] ?? null, 'graph_request_id' => $response->meta['request_id'] ?? null, 'graph_client_request_id' => $response->meta['client_request_id'] ?? null, + 'graph_method' => $response->meta['method'] ?? null, + 'graph_path' => $response->meta['path'] ?? null, ]; $hardFailures++; @@ -885,6 +933,11 @@ private function resolveTypeMeta(string $policyType): array private function resolveRestoreMode(string $policyType): string { $meta = $this->resolveTypeMeta($policyType); + + if ($meta === []) { + return 'preview-only'; + } + $restore = $meta['restore'] ?? 'enabled'; if (! is_string($restore) || $restore === '') { @@ -931,6 +984,10 @@ private function isNotFoundResponse(object $response): bool $code = strtolower((string) ($response->meta['error_code'] ?? '')); $message = strtolower((string) ($response->meta['error_message'] ?? '')); + if ($message !== '' && str_contains($message, 'resource not found for the segment')) { + return false; + } + if ($code !== '' && (str_contains($code, 'notfound') || str_contains($code, 'resource'))) { return true; } @@ -1479,15 +1536,16 @@ private function resolveSettingsCatalogSettingId(array $setting): ?string * @return array{0: array{total:int,applied:int,failed:int,manual_required:int,issues:array>}, 1: string} */ private function applySettingsCatalogPolicySettings( + string $policyType, string $policyId, array $settings, array $graphOptions, array $context, ): array { - $method = $this->contracts->settingsWriteMethod('settingsCatalogPolicy'); - $path = $this->contracts->settingsWritePath('settingsCatalogPolicy', $policyId); - $bodyShape = strtolower($this->contracts->settingsWriteBodyShape('settingsCatalogPolicy')); - $fallbackShape = $this->contracts->settingsWriteFallbackBodyShape('settingsCatalogPolicy'); + $method = $this->contracts->settingsWriteMethod($policyType); + $path = $this->contracts->settingsWritePath($policyType, $policyId); + $bodyShape = strtolower($this->contracts->settingsWriteBodyShape($policyType)); + $fallbackShape = $this->contracts->settingsWriteFallbackBodyShape($policyType); $buildIssues = function (string $reason) use ($settings): array { $issues = []; @@ -1520,7 +1578,7 @@ private function applySettingsCatalogPolicySettings( ]; } - $sanitized = $this->contracts->sanitizeSettingsApplyPayload('settingsCatalogPolicy', $settings); + $sanitized = $this->contracts->sanitizeSettingsApplyPayload($policyType, $settings); if (! is_array($sanitized) || $sanitized === []) { return [ @@ -1654,14 +1712,15 @@ private function shouldAttemptSettingsCatalogCreate(array $settingsApply): bool * @return array{success:bool,policy_id:?string,response:?object,mode:string} */ private function createSettingsCatalogPolicy( + string $policyType, array $originalPayload, array $settings, array $graphOptions, array $context, string $fallbackName, ): array { - $resource = $this->contracts->resourcePath('settingsCatalogPolicy') ?? 'deviceManagement/configurationPolicies'; - $sanitizedSettings = $this->contracts->sanitizeSettingsApplyPayload('settingsCatalogPolicy', $settings); + $resource = $this->contracts->resourcePath($policyType) ?? 'deviceManagement/configurationPolicies'; + $sanitizedSettings = $this->contracts->sanitizeSettingsApplyPayload($policyType, $settings); if ($sanitizedSettings === []) { return [ @@ -1718,6 +1777,79 @@ private function createSettingsCatalogPolicy( ]; } + /** + * @param array $originalPayload + * @param array $settings + * @param array $graphOptions + * @param array $context + * @return array + */ + private function prepareEndpointSecurityPolicyForCreate( + Tenant $tenant, + array $originalPayload, + array $settings, + array $graphOptions, + array $context, + ): array { + $templateReference = $this->resolvePayloadArray($originalPayload, ['templateReference', 'TemplateReference']); + + if (! is_array($templateReference)) { + throw new \RuntimeException('Endpoint Security policy snapshot is missing templateReference and cannot be restored safely.'); + } + + $templateOutcome = $this->templateResolver->resolveTemplateReference($tenant, $templateReference, $graphOptions); + + if (! ($templateOutcome['success'] ?? false)) { + $reason = $templateOutcome['reason'] ?? 'Endpoint Security template is not available in the tenant.'; + + throw new \RuntimeException($reason); + } + + $resolvedTemplateId = $templateOutcome['template_id'] ?? null; + $resolvedReference = $templateOutcome['template_reference'] ?? $templateReference; + + if (! is_string($resolvedTemplateId) || $resolvedTemplateId === '') { + throw new \RuntimeException('Endpoint Security template could not be resolved (missing template id).'); + } + + if (is_array($resolvedReference) && $resolvedReference !== []) { + $originalPayload['templateReference'] = $resolvedReference; + } + + if ($settings === []) { + return $originalPayload; + } + + $definitions = $this->templateResolver->fetchTemplateSettingDefinitionIds($tenant, $resolvedTemplateId, $graphOptions); + + if (! ($definitions['success'] ?? false)) { + return $originalPayload; + } + + $templateDefinitionIds = $definitions['definition_ids'] ?? []; + + if (! is_array($templateDefinitionIds) || $templateDefinitionIds === []) { + return $originalPayload; + } + + $policyDefinitionIds = $this->templateResolver->extractSettingDefinitionIds($settings); + $missing = array_values(array_diff($policyDefinitionIds, $templateDefinitionIds)); + + if ($missing === []) { + return $originalPayload; + } + + $sample = implode(', ', array_slice($missing, 0, 5)); + $suffix = count($missing) > 5 ? sprintf(' (and %d more)', count($missing) - 5) : ''; + + throw new \RuntimeException(sprintf( + 'Endpoint Security settings do not match the resolved template (%s). Missing setting definitions: %s%s', + $resolvedTemplateId, + $sample, + $suffix, + )); + } + /** * @return array{attempted:bool,success:bool,policy_id:?string,response:?object} */ diff --git a/app/Services/Intune/ScriptsPolicyNormalizer.php b/app/Services/Intune/ScriptsPolicyNormalizer.php new file mode 100644 index 0000000..172bd8e --- /dev/null +++ b/app/Services/Intune/ScriptsPolicyNormalizer.php @@ -0,0 +1,274 @@ +>, settings_table?: array, warnings: array} + */ + public function normalize(?array $snapshot, string $policyType, ?string $platform = null): array + { + $snapshot = is_array($snapshot) ? $snapshot : []; + + $displayName = Arr::get($snapshot, 'displayName') ?? Arr::get($snapshot, 'name'); + $description = Arr::get($snapshot, 'description'); + + $entries = []; + + $entries[] = ['key' => 'Type', 'value' => $policyType]; + + if (is_string($displayName) && $displayName !== '') { + $entries[] = ['key' => 'Display name', 'value' => $displayName]; + } + + if (is_string($description) && $description !== '') { + $entries[] = ['key' => 'Description', 'value' => $description]; + } + + $fileName = Arr::get($snapshot, 'fileName'); + if (is_string($fileName) && $fileName !== '') { + $entries[] = ['key' => 'File name', 'value' => $fileName]; + } + + $publisher = Arr::get($snapshot, 'publisher'); + if (is_string($publisher) && $publisher !== '') { + $entries[] = ['key' => 'Publisher', 'value' => $publisher]; + } + + $runAsAccount = Arr::get($snapshot, 'runAsAccount'); + if (is_string($runAsAccount) && $runAsAccount !== '') { + $entries[] = ['key' => 'Run as account', 'value' => $runAsAccount]; + } + + $runAs32Bit = Arr::get($snapshot, 'runAs32Bit'); + if (is_bool($runAs32Bit)) { + $entries[] = ['key' => 'Run as 32-bit', 'value' => $runAs32Bit ? 'Enabled' : 'Disabled']; + } + + $enforceSignatureCheck = Arr::get($snapshot, 'enforceSignatureCheck'); + if (is_bool($enforceSignatureCheck)) { + $entries[] = ['key' => 'Enforce signature check', 'value' => $enforceSignatureCheck ? 'Enabled' : 'Disabled']; + } + + $entries = array_merge($entries, $this->contentEntries($snapshot)); + + $schedule = Arr::get($snapshot, 'runSchedule'); + if (is_array($schedule) && $schedule !== []) { + $entries[] = ['key' => 'Run schedule', 'value' => Arr::except($schedule, ['@odata.type'])]; + } + + $frequency = Arr::get($snapshot, 'runFrequency'); + if (is_string($frequency) && $frequency !== '') { + $entries[] = ['key' => 'Run frequency', 'value' => $frequency]; + } + + $roleScopeTagIds = Arr::get($snapshot, 'roleScopeTagIds'); + if (is_array($roleScopeTagIds) && $roleScopeTagIds !== []) { + $entries[] = ['key' => 'Scope tag IDs', 'value' => array_values($roleScopeTagIds)]; + } + + return [ + 'status' => 'ok', + 'settings' => [ + [ + 'type' => 'keyValue', + 'title' => 'Script settings', + 'entries' => $entries, + ], + ], + 'warnings' => [], + ]; + } + + /** + * @return array + */ + private function contentEntries(array $snapshot): array + { + $showContent = (bool) config('tenantpilot.display.show_script_content', false); + $maxChars = (int) config('tenantpilot.display.max_script_content_chars', 5000); + if ($maxChars <= 0) { + $maxChars = 5000; + } + + if (! $showContent) { + return $this->contentSummaryEntries($snapshot); + } + + $entries = []; + + $scriptContent = Arr::get($snapshot, 'scriptContent'); + if (is_string($scriptContent) && $scriptContent !== '') { + $decoded = $this->decodeIfBase64Text($scriptContent); + if (is_string($decoded) && $decoded !== '') { + $scriptContent = $decoded; + } + } + + if (! is_string($scriptContent) || $scriptContent === '') { + $scriptContentBase64 = Arr::get($snapshot, 'scriptContentBase64'); + if (is_string($scriptContentBase64) && $scriptContentBase64 !== '') { + $decoded = base64_decode($this->stripWhitespace($scriptContentBase64), true); + if (is_string($decoded) && $decoded !== '') { + $scriptContent = $this->normalizeDecodedText($decoded); + } + } + } + + if (is_string($scriptContent) && $scriptContent !== '') { + $entries[] = ['key' => 'scriptContent', 'value' => $this->limitContent($scriptContent, $maxChars)]; + } + + foreach (['detectionScriptContent', 'remediationScriptContent'] as $key) { + $value = Arr::get($snapshot, $key); + + if (! is_string($value) || $value === '') { + continue; + } + + $decoded = $this->decodeIfBase64Text($value); + if (is_string($decoded) && $decoded !== '') { + $value = $decoded; + } + + $entries[] = ['key' => $key, 'value' => $this->limitContent($value, $maxChars)]; + } + + return $entries; + } + + private function decodeIfBase64Text(string $candidate): ?string + { + $trimmed = $this->stripWhitespace($candidate); + if ($trimmed === '' || strlen($trimmed) < 16) { + return null; + } + + if (strlen($trimmed) % 4 !== 0) { + return null; + } + + if (! preg_match('/^[A-Za-z0-9+\/=]+$/', $trimmed)) { + return null; + } + + $decoded = base64_decode($trimmed, true); + if (! is_string($decoded) || $decoded === '') { + return null; + } + + $decoded = $this->normalizeDecodedText($decoded); + if ($decoded === '') { + return null; + } + + if (! $this->looksLikeText($decoded)) { + return null; + } + + return $decoded; + } + + private function stripWhitespace(string $value): string + { + return preg_replace('/\s+/', '', $value) ?? ''; + } + + private function normalizeDecodedText(string $decoded): string + { + if (str_starts_with($decoded, "\xFF\xFE")) { + $decoded = mb_convert_encoding(substr($decoded, 2), 'UTF-8', 'UTF-16LE'); + } elseif (str_starts_with($decoded, "\xFE\xFF")) { + $decoded = mb_convert_encoding(substr($decoded, 2), 'UTF-8', 'UTF-16BE'); + } elseif (str_contains($decoded, "\x00")) { + $decoded = mb_convert_encoding($decoded, 'UTF-8', 'UTF-16LE'); + } + + if (str_starts_with($decoded, "\xEF\xBB\xBF")) { + $decoded = substr($decoded, 3); + } + + return $decoded; + } + + private function looksLikeText(string $decoded): bool + { + $length = strlen($decoded); + if ($length === 0) { + return false; + } + + $nonPrintable = preg_match_all('/[\x00-\x08\x0B\x0C\x0E-\x1F\x7F]/', $decoded) ?: 0; + if ($nonPrintable > (int) max(1, $length * 0.05)) { + return false; + } + + // Scripts should typically contain some whitespace or line breaks. + if ($length >= 24 && ! preg_match('/\s/', $decoded)) { + return false; + } + + return true; + } + + /** + * @return array + */ + private function contentSummaryEntries(array $snapshot): array + { + // Script content and large blobs should not dominate normalized output. + // Keep only safe summary fields if present. + $contentKeys = [ + 'scriptContent', + 'scriptContentBase64', + 'detectionScriptContent', + 'remediationScriptContent', + ]; + + $entries = []; + + foreach ($contentKeys as $key) { + $value = Arr::get($snapshot, $key); + + if (is_string($value) && $value !== '') { + $entries[] = ['key' => $key, 'value' => sprintf('[content: %d chars]', strlen($value))]; + } + } + + return $entries; + } + + private function limitContent(string $content, int $maxChars): string + { + if (mb_strlen($content) <= $maxChars) { + return $content; + } + + return mb_substr($content, 0, $maxChars).'…'; + } + + /** + * @return array + */ + public function flattenForDiff(?array $snapshot, string $policyType, ?string $platform = null): array + { + $normalized = $this->normalize($snapshot, $policyType, $platform); + + return $this->defaultNormalizer->flattenNormalizedForDiff($normalized); + } +} diff --git a/app/Services/Intune/SettingsCatalogDefinitionResolver.php b/app/Services/Intune/SettingsCatalogDefinitionResolver.php index 208ffa2..8566be3 100644 --- a/app/Services/Intune/SettingsCatalogDefinitionResolver.php +++ b/app/Services/Intune/SettingsCatalogDefinitionResolver.php @@ -269,10 +269,49 @@ public function prettifyDefinitionId(string $definitionId): string // Remove {tenantid} placeholder - it's a Microsoft template variable, not part of the name $cleaned = str_replace(['{tenantid}', '_tenantid_', '_{tenantid}_'], ['', '_', '_'], $definitionId); + // Remove other template placeholders, e.g. "{FirewallRuleId}" + $cleaned = preg_replace('/\{[^}]+\}/', '', $cleaned); + // Clean up consecutive underscores $cleaned = preg_replace('/_+/', '_', $cleaned); $cleaned = trim($cleaned, '_'); + $lowered = Str::lower($cleaned); + + if (str_starts_with($lowered, 'vendor_msft_firewall_mdmstore_firewallrules')) { + $suffix = ltrim(substr($lowered, strlen('vendor_msft_firewall_mdmstore_firewallrules')), '_'); + + if ($suffix === '') { + return 'Firewall rule'; + } + + $known = [ + 'displayname' => 'Name', + 'name' => 'Name', + 'description' => 'Description', + 'direction' => 'Direction', + 'action' => 'Action', + 'actiontype' => 'Action type', + 'profiles' => 'Profiles', + 'profile' => 'Profile', + 'protocol' => 'Protocol', + 'localport' => 'Local port', + 'remoteport' => 'Remote port', + 'localaddress' => 'Local address', + 'remoteaddress' => 'Remote address', + 'interfacetype' => 'Interface type', + 'interfacetypes' => 'Interface types', + 'edgetraversal' => 'Edge traversal', + 'enabled' => 'Enabled', + ]; + + if (isset($known[$suffix])) { + return $known[$suffix]; + } + + return Str::headline($suffix); + } + // Convert to title case $prettified = Str::title(str_replace('_', ' ', $cleaned)); diff --git a/app/Services/Intune/SettingsCatalogPolicyNormalizer.php b/app/Services/Intune/SettingsCatalogPolicyNormalizer.php index 1b74907..e42a7a2 100644 --- a/app/Services/Intune/SettingsCatalogPolicyNormalizer.php +++ b/app/Services/Intune/SettingsCatalogPolicyNormalizer.php @@ -10,7 +10,7 @@ public function __construct( public function supports(string $policyType): bool { - return $policyType === 'settingsCatalogPolicy'; + return in_array($policyType, ['settingsCatalogPolicy', 'endpointSecurityPolicy', 'securityBaselinePolicy'], true); } /** diff --git a/app/Services/Intune/TermsAndConditionsNormalizer.php b/app/Services/Intune/TermsAndConditionsNormalizer.php new file mode 100644 index 0000000..9af263e --- /dev/null +++ b/app/Services/Intune/TermsAndConditionsNormalizer.php @@ -0,0 +1,94 @@ +>, warnings: array} + */ + public function normalize(?array $snapshot, string $policyType, ?string $platform = null): array + { + $snapshot = is_array($snapshot) ? $snapshot : []; + $entries = []; + + $this->pushEntry($entries, 'Display name', Arr::get($snapshot, 'displayName')); + $this->pushEntry($entries, 'Title', Arr::get($snapshot, 'title')); + $this->pushEntry($entries, 'Description', Arr::get($snapshot, 'description')); + $this->pushEntry($entries, 'Acceptance statement', Arr::get($snapshot, 'acceptanceStatement')); + $this->pushEntry($entries, 'Body text', $this->limitText(Arr::get($snapshot, 'bodyText'))); + $this->pushEntry($entries, 'Version', Arr::get($snapshot, 'version')); + + $roleScopeTagIds = Arr::get($snapshot, 'roleScopeTagIds'); + if (is_array($roleScopeTagIds) && $roleScopeTagIds !== []) { + $this->pushEntry($entries, 'Scope tag IDs', array_values($roleScopeTagIds)); + } + + if ($entries === []) { + return [ + 'status' => 'warning', + 'settings' => [], + 'warnings' => ['Terms & Conditions snapshot contains no readable fields.'], + ]; + } + + return [ + 'status' => 'ok', + 'settings' => [ + [ + 'type' => 'keyValue', + 'title' => 'Terms & Conditions', + 'entries' => $entries, + ], + ], + 'warnings' => [], + ]; + } + + public function flattenForDiff(?array $snapshot, string $policyType, ?string $platform = null): array + { + $normalized = $this->normalize($snapshot ?? [], $policyType, $platform); + + return $this->defaultNormalizer->flattenNormalizedForDiff($normalized); + } + + /** + * @param array> $entries + */ + private function pushEntry(array &$entries, string $key, mixed $value): void + { + if ($value === null) { + return; + } + + if (is_string($value) && $value === '') { + return; + } + + $entries[] = [ + 'key' => $key, + 'value' => $value, + ]; + } + + private function limitText(mixed $value): mixed + { + if (! is_string($value)) { + return $value; + } + + return Str::limit($value, 1000); + } +} diff --git a/app/Services/Intune/VersionService.php b/app/Services/Intune/VersionService.php index f1f300e..01992ef 100644 --- a/app/Services/Intune/VersionService.php +++ b/app/Services/Intune/VersionService.php @@ -85,6 +85,8 @@ public function captureFromGraph( } $payload = $snapshot['payload']; + $snapshotMetadata = is_array($snapshot['metadata'] ?? null) ? $snapshot['metadata'] : []; + $snapshotWarnings = is_array($snapshot['warnings'] ?? null) ? $snapshot['warnings'] : []; $assignments = null; $scopeTags = null; $assignmentMetadata = []; @@ -141,11 +143,17 @@ public function captureFromGraph( } $metadata = array_merge( - ['source' => 'version_capture'], + $snapshotMetadata, + ['capture_source' => 'version_capture'], $metadata, - $assignmentMetadata + $assignmentMetadata, ); + if ($snapshotWarnings !== []) { + $existingWarnings = is_array($metadata['warnings'] ?? null) ? $metadata['warnings'] : []; + $metadata['warnings'] = array_values(array_unique(array_merge($existingWarnings, $snapshotWarnings))); + } + return $this->captureVersion( policy: $policy, payload: $payload, diff --git a/app/Services/Intune/WindowsDriverUpdateProfileNormalizer.php b/app/Services/Intune/WindowsDriverUpdateProfileNormalizer.php new file mode 100644 index 0000000..0bd657e --- /dev/null +++ b/app/Services/Intune/WindowsDriverUpdateProfileNormalizer.php @@ -0,0 +1,125 @@ +>, settings_table?: array, warnings: array} + */ + public function normalize(?array $snapshot, string $policyType, ?string $platform = null): array + { + $snapshot = $snapshot ?? []; + $normalized = $this->defaultNormalizer->normalize($snapshot, $policyType, $platform); + + if ($snapshot === []) { + return $normalized; + } + + $block = $this->buildDriverUpdateBlock($snapshot); + + if ($block !== null) { + $normalized['settings'][] = $block; + $normalized['settings'] = array_values(array_filter($normalized['settings'])); + } + + return $normalized; + } + + /** + * @return array + */ + public function flattenForDiff(?array $snapshot, string $policyType, ?string $platform = null): array + { + $snapshot = $snapshot ?? []; + $normalized = $this->normalize($snapshot, $policyType, $platform); + + return $this->defaultNormalizer->flattenNormalizedForDiff($normalized); + } + + private function buildDriverUpdateBlock(array $snapshot): ?array + { + $entries = []; + + $displayName = Arr::get($snapshot, 'displayName'); + + if (is_string($displayName) && $displayName !== '') { + $entries[] = ['key' => 'Name', 'value' => $displayName]; + } + + $approvalType = Arr::get($snapshot, 'approvalType'); + + if (is_string($approvalType) && $approvalType !== '') { + $entries[] = ['key' => 'Approval type', 'value' => $approvalType]; + } + + $deferral = Arr::get($snapshot, 'deploymentDeferralInDays'); + + if (is_int($deferral) || (is_numeric($deferral) && (string) (int) $deferral === (string) $deferral)) { + $entries[] = ['key' => 'Deployment deferral (days)', 'value' => (int) $deferral]; + } + + $deviceReporting = Arr::get($snapshot, 'deviceReporting'); + + if (is_int($deviceReporting) || (is_numeric($deviceReporting) && (string) (int) $deviceReporting === (string) $deviceReporting)) { + $entries[] = ['key' => 'Devices reporting', 'value' => (int) $deviceReporting]; + } + + $newUpdates = Arr::get($snapshot, 'newUpdates'); + + if (is_int($newUpdates) || (is_numeric($newUpdates) && (string) (int) $newUpdates === (string) $newUpdates)) { + $entries[] = ['key' => 'New driver updates', 'value' => (int) $newUpdates]; + } + + $inventorySyncStatus = Arr::get($snapshot, 'inventorySyncStatus'); + + if (is_array($inventorySyncStatus)) { + $state = Arr::get($inventorySyncStatus, 'driverInventorySyncState'); + + if (is_string($state) && $state !== '') { + $entries[] = ['key' => 'Inventory sync state', 'value' => $state]; + } + + $lastSuccessful = $this->formatDateTime(Arr::get($inventorySyncStatus, 'lastSuccessfulSyncDateTime')); + + if ($lastSuccessful !== null) { + $entries[] = ['key' => 'Last successful inventory sync', 'value' => $lastSuccessful]; + } + } + + if ($entries === []) { + return null; + } + + return [ + 'type' => 'keyValue', + 'title' => 'Driver Update Profile', + 'entries' => $entries, + ]; + } + + private function formatDateTime(mixed $value): ?string + { + if (! is_string($value) || $value === '') { + return null; + } + + try { + return CarbonImmutable::parse($value)->toDateTimeString(); + } catch (\Throwable) { + return $value; + } + } +} diff --git a/app/Services/Intune/WindowsFeatureUpdateProfileNormalizer.php b/app/Services/Intune/WindowsFeatureUpdateProfileNormalizer.php new file mode 100644 index 0000000..4e95d52 --- /dev/null +++ b/app/Services/Intune/WindowsFeatureUpdateProfileNormalizer.php @@ -0,0 +1,107 @@ +>, settings_table?: array, warnings: array} + */ + public function normalize(?array $snapshot, string $policyType, ?string $platform = null): array + { + $snapshot = $snapshot ?? []; + $normalized = $this->defaultNormalizer->normalize($snapshot, $policyType, $platform); + + if ($snapshot === []) { + return $normalized; + } + + $normalized['settings'][] = $this->buildFeatureUpdateBlock($snapshot); + $normalized['settings'] = array_values(array_filter($normalized['settings'])); + + return $normalized; + } + + /** + * @return array + */ + public function flattenForDiff(?array $snapshot, string $policyType, ?string $platform = null): array + { + $snapshot = $snapshot ?? []; + $normalized = $this->normalize($snapshot, $policyType, $platform); + + return $this->defaultNormalizer->flattenNormalizedForDiff($normalized); + } + + private function buildFeatureUpdateBlock(array $snapshot): ?array + { + $entries = []; + + $displayName = Arr::get($snapshot, 'displayName'); + + if (is_string($displayName) && $displayName !== '') { + $entries[] = ['key' => 'Name', 'value' => $displayName]; + } + + $version = Arr::get($snapshot, 'featureUpdateVersion'); + + if (is_string($version) && $version !== '') { + $entries[] = ['key' => 'Feature update version', 'value' => $version]; + } + + $rollout = Arr::get($snapshot, 'rolloutSettings'); + + if (is_array($rollout)) { + $start = $this->formatDateTime($rollout['offerStartDateTimeInUTC'] ?? null); + $end = $this->formatDateTime($rollout['offerEndDateTimeInUTC'] ?? null); + $interval = $rollout['offerIntervalInDays'] ?? null; + + if ($start !== null) { + $entries[] = ['key' => 'Rollout start', 'value' => $start]; + } + + if ($end !== null) { + $entries[] = ['key' => 'Rollout end', 'value' => $end]; + } + + if ($interval !== null) { + $entries[] = ['key' => 'Rollout interval (days)', 'value' => $interval]; + } + } + + if ($entries === []) { + return null; + } + + return [ + 'type' => 'keyValue', + 'title' => 'Feature Update Profile', + 'entries' => $entries, + ]; + } + + private function formatDateTime(mixed $value): ?string + { + if (! is_string($value) || $value === '') { + return null; + } + + try { + return CarbonImmutable::parse($value)->toDateTimeString(); + } catch (\Throwable) { + return $value; + } + } +} diff --git a/app/Services/Intune/WindowsQualityUpdateProfileNormalizer.php b/app/Services/Intune/WindowsQualityUpdateProfileNormalizer.php new file mode 100644 index 0000000..b1ff849 --- /dev/null +++ b/app/Services/Intune/WindowsQualityUpdateProfileNormalizer.php @@ -0,0 +1,83 @@ +>, settings_table?: array, warnings: array} + */ + public function normalize(?array $snapshot, string $policyType, ?string $platform = null): array + { + $snapshot = $snapshot ?? []; + $normalized = $this->defaultNormalizer->normalize($snapshot, $policyType, $platform); + + if ($snapshot === []) { + return $normalized; + } + + $block = $this->buildQualityUpdateBlock($snapshot); + + if ($block !== null) { + $normalized['settings'][] = $block; + $normalized['settings'] = array_values(array_filter($normalized['settings'])); + } + + return $normalized; + } + + /** + * @return array + */ + public function flattenForDiff(?array $snapshot, string $policyType, ?string $platform = null): array + { + $snapshot = $snapshot ?? []; + $normalized = $this->normalize($snapshot, $policyType, $platform); + + return $this->defaultNormalizer->flattenNormalizedForDiff($normalized); + } + + private function buildQualityUpdateBlock(array $snapshot): ?array + { + $entries = []; + + $displayName = Arr::get($snapshot, 'displayName'); + + if (is_string($displayName) && $displayName !== '') { + $entries[] = ['key' => 'Name', 'value' => $displayName]; + } + + $release = Arr::get($snapshot, 'releaseDateDisplayName'); + + if (is_string($release) && $release !== '') { + $entries[] = ['key' => 'Release', 'value' => $release]; + } + + $content = Arr::get($snapshot, 'deployableContentDisplayName'); + + if (is_string($content) && $content !== '') { + $entries[] = ['key' => 'Deployable content', 'value' => $content]; + } + + if ($entries === []) { + return null; + } + + return [ + 'type' => 'keyValue', + 'title' => 'Quality Update Profile', + 'entries' => $entries, + ]; + } +} diff --git a/app/Services/Intune/WindowsUpdateRingNormalizer.php b/app/Services/Intune/WindowsUpdateRingNormalizer.php new file mode 100644 index 0000000..66ec7a0 --- /dev/null +++ b/app/Services/Intune/WindowsUpdateRingNormalizer.php @@ -0,0 +1,137 @@ +>, settings_table?: array, warnings: array} + */ + public function normalize(?array $snapshot, string $policyType, ?string $platform = null): array + { + $snapshot = $snapshot ?? []; + $normalized = $this->defaultNormalizer->normalize($snapshot, $policyType, $platform); + + if ($snapshot === []) { + return $normalized; + } + + $normalized['settings'] = array_values(array_filter( + $normalized['settings'], + fn (array $block) => strtolower((string) ($block['title'] ?? '')) !== 'general' + )); + + $normalized['settings'][] = $this->buildUpdateSettingsBlock($snapshot); + $normalized['settings'][] = $this->buildUserExperienceBlock($snapshot); + $normalized['settings'][] = $this->buildAdvancedOptionsBlock($snapshot); + + $normalized['settings'] = array_values(array_filter($normalized['settings'])); + + return $normalized; + } + + /** + * @return array + */ + public function flattenForDiff(?array $snapshot, string $policyType, ?string $platform = null): array + { + $snapshot = $snapshot ?? []; + $normalized = $this->normalize($snapshot, $policyType, $platform); + + return $this->defaultNormalizer->flattenNormalizedForDiff($normalized); + } + + private function buildUpdateSettingsBlock(array $snapshot): ?array + { + $keys = [ + 'allowWindows11Upgrade', + 'automaticUpdateMode', + 'featureUpdatesDeferralPeriodInDays', + 'featureUpdatesPaused', + 'featureUpdatesPauseExpiryDateTime', + 'qualityUpdatesDeferralPeriodInDays', + 'qualityUpdatesPaused', + 'qualityUpdatesPauseExpiryDateTime', + 'updateWindowsDeviceDriverExclusion', + ]; + + return $this->buildBlock('Update Settings', $snapshot, $keys); + } + + private function buildUserExperienceBlock(array $snapshot): ?array + { + $keys = [ + 'deadlineForFeatureUpdatesInDays', + 'deadlineForQualityUpdatesInDays', + 'deadlineGracePeriodInDays', + 'gracePeriodInDays', + 'restartActiveHoursStart', + 'restartActiveHoursEnd', + 'setActiveHours', + 'userPauseAccess', + 'userCheckAccess', + ]; + + return $this->buildBlock('User Experience', $snapshot, $keys); + } + + private function buildAdvancedOptionsBlock(array $snapshot): ?array + { + $keys = [ + 'deliveryOptimizationMode', + 'prereleaseFeatures', + 'servicingChannel', + 'microsoftUpdateServiceAllowed', + ]; + + return $this->buildBlock('Advanced Options', $snapshot, $keys); + } + + private function buildBlock(string $title, array $snapshot, array $keys): ?array + { + $entries = []; + + foreach ($keys as $key) { + if (array_key_exists($key, $snapshot)) { + $entries[] = [ + 'key' => Str::headline($key), + 'value' => $this->formatValue($snapshot[$key]), + ]; + } + } + + if ($entries === []) { + return null; + } + + return [ + 'type' => 'keyValue', + 'title' => $title, + 'entries' => $entries, + ]; + } + + private function formatValue(mixed $value): mixed + { + if (is_bool($value)) { + return $value ? 'Yes' : 'No'; + } + + if (is_array($value)) { + return json_encode($value, JSON_PRETTY_PRINT); + } + + return $value; + } +} diff --git a/app/Support/Concerns/InteractsWithODataTypes.php b/app/Support/Concerns/InteractsWithODataTypes.php index eb5274a..5ce7c3e 100644 --- a/app/Support/Concerns/InteractsWithODataTypes.php +++ b/app/Support/Concerns/InteractsWithODataTypes.php @@ -29,6 +29,14 @@ protected static function odataTypeMap(): array 'windows' => '#microsoft.graph.windowsUpdateForBusinessConfiguration', 'all' => '#microsoft.graph.windowsUpdateForBusinessConfiguration', ], + 'windowsFeatureUpdateProfile' => [ + 'windows' => '#microsoft.graph.windowsFeatureUpdateProfile', + 'all' => '#microsoft.graph.windowsFeatureUpdateProfile', + ], + 'windowsQualityUpdateProfile' => [ + 'windows' => '#microsoft.graph.windowsQualityUpdateProfile', + 'all' => '#microsoft.graph.windowsQualityUpdateProfile', + ], 'deviceCompliancePolicy' => [ 'windows' => '#microsoft.graph.windows10CompliancePolicy', 'ios' => '#microsoft.graph.iosCompliancePolicy', @@ -54,9 +62,26 @@ protected static function odataTypeMap(): array 'windows' => '#microsoft.graph.deviceHealthScript', 'all' => '#microsoft.graph.deviceHealthScript', ], + 'termsAndConditions' => [ + 'windows' => '#microsoft.graph.termsAndConditions', + 'all' => '#microsoft.graph.termsAndConditions', + ], + 'deviceComplianceScript' => [ + 'windows' => '#microsoft.graph.deviceComplianceScript', + 'all' => '#microsoft.graph.deviceComplianceScript', + ], 'enrollmentRestriction' => [ 'all' => '#microsoft.graph.deviceEnrollmentConfiguration', ], + 'deviceEnrollmentLimitConfiguration' => [ + 'all' => '#microsoft.graph.deviceEnrollmentLimitConfiguration', + ], + 'deviceEnrollmentPlatformRestrictionsConfiguration' => [ + 'all' => '#microsoft.graph.deviceEnrollmentPlatformRestrictionsConfiguration', + ], + 'deviceEnrollmentNotificationConfiguration' => [ + 'all' => '#microsoft.graph.deviceEnrollmentNotificationConfiguration', + ], 'windowsAutopilotDeploymentProfile' => [ 'windows' => '#microsoft.graph.windowsAutopilotDeploymentProfile', ], diff --git a/app/Support/TenantRole.php b/app/Support/TenantRole.php new file mode 100644 index 0000000..38c8a00 --- /dev/null +++ b/app/Support/TenantRole.php @@ -0,0 +1,40 @@ + true, + self::Readonly => false, + }; + } + + public function canManageBackupSchedules(): bool + { + return match ($this) { + self::Owner, + self::Manager => true, + default => false, + }; + } + + public function canRunBackupSchedules(): bool + { + return match ($this) { + self::Owner, + self::Manager, + self::Operator => true, + self::Readonly => false, + }; + } +} diff --git a/composer.json b/composer.json index ccdbb83..b681b4b 100644 --- a/composer.json +++ b/composer.json @@ -8,6 +8,7 @@ "require": { "php": "^8.2", "filament/filament": "^4.0", + "lara-zeus/torch-filament": "^2.0", "laravel/framework": "^12.0", "laravel/tinker": "^2.10.1", "pepperfm/filament-json": "^4" diff --git a/composer.lock b/composer.lock index 33f7c64..4a56c47 100644 --- a/composer.lock +++ b/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "c4f08fd9fc4b86cc13b75332dd6e1b7a", + "content-hash": "20819254265bddd0aa70006919cb735f", "packages": [ { "name": "anourvalar/eloquent-serialize", @@ -2082,6 +2082,87 @@ }, "time": "2025-11-13T14:57:49+00:00" }, + { + "name": "lara-zeus/torch-filament", + "version": "2.0.1", + "source": { + "type": "git", + "url": "https://github.com/lara-zeus/torch-filament.git", + "reference": "71dbe8df4a558a80308781ba20c5922943b33009" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/lara-zeus/torch-filament/zipball/71dbe8df4a558a80308781ba20c5922943b33009", + "reference": "71dbe8df4a558a80308781ba20c5922943b33009", + "shasum": "" + }, + "require": { + "filament/filament": "^4.0", + "php": "^8.1", + "spatie/laravel-package-tools": "^1.16", + "torchlight/engine": "^0.1.0" + }, + "require-dev": { + "larastan/larastan": "^2.0", + "laravel/pint": "^1.0", + "nunomaduro/collision": "^7.0", + "nunomaduro/phpinsights": "^2.8", + "orchestra/testbench": "^8.0", + "phpstan/extension-installer": "^1.1" + }, + "type": "library", + "extra": { + "laravel": { + "providers": [ + "LaraZeus\\TorchFilament\\TorchFilamentServiceProvider" + ] + } + }, + "autoload": { + "psr-4": { + "LaraZeus\\TorchFilament\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Lara Zeus", + "email": "info@larazeus.com" + } + ], + "description": "Infolist component to highlight code using Torchlight Engine", + "homepage": "https://larazeus.com/torch-filament", + "keywords": [ + "code", + "design", + "engine", + "filamentphp", + "highlight", + "input", + "lara-zeus", + "laravel", + "torchlight", + "ui" + ], + "support": { + "issues": "https://github.com/lara-zeus/torch-filament/issues", + "source": "https://github.com/lara-zeus/torch-filament" + }, + "funding": [ + { + "url": "https://www.buymeacoffee.com/larazeus", + "type": "custom" + }, + { + "url": "https://github.com/atmonshi", + "type": "github" + } + ], + "time": "2025-06-11T19:32:10+00:00" + }, { "name": "laravel/framework", "version": "v12.42.0", @@ -4265,6 +4346,60 @@ }, "time": "2025-02-26T00:08:40+00:00" }, + { + "name": "phiki/phiki", + "version": "v1.1.6", + "source": { + "type": "git", + "url": "https://github.com/phikiphp/phiki.git", + "reference": "3174d8cb309bdccc32b7a33500379de76148256b" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/phikiphp/phiki/zipball/3174d8cb309bdccc32b7a33500379de76148256b", + "reference": "3174d8cb309bdccc32b7a33500379de76148256b", + "shasum": "" + }, + "require": { + "league/commonmark": "^2.5.3", + "php": "^8.2" + }, + "require-dev": { + "illuminate/support": "^11.30", + "laravel/pint": "^1.18.1", + "pestphp/pest": "^3.5.1", + "phpstan/extension-installer": "^1.4.3", + "phpstan/phpstan": "^2.0", + "symfony/var-dumper": "^7.1.6" + }, + "bin": [ + "bin/phiki" + ], + "type": "library", + "autoload": { + "psr-4": { + "Phiki\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Ryan Chandler", + "email": "support@ryangjchandler.co.uk", + "homepage": "https://ryangjchandler.co.uk", + "role": "Developer" + } + ], + "description": "Syntax highlighting using TextMate grammars in PHP.", + "support": { + "issues": "https://github.com/phikiphp/phiki/issues", + "source": "https://github.com/phikiphp/phiki/tree/v1.1.6" + }, + "time": "2025-06-06T20:18:29+00:00" + }, { "name": "phpoption/phpoption", "version": "1.9.4", @@ -8110,6 +8245,62 @@ }, "time": "2024-12-21T16:25:41+00:00" }, + { + "name": "torchlight/engine", + "version": "v0.1.0", + "source": { + "type": "git", + "url": "https://github.com/torchlight-api/engine.git", + "reference": "8d12f611efb0b22406ec0744abb453ddd2f1fe9d" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/torchlight-api/engine/zipball/8d12f611efb0b22406ec0744abb453ddd2f1fe9d", + "reference": "8d12f611efb0b22406ec0744abb453ddd2f1fe9d", + "shasum": "" + }, + "require": { + "league/commonmark": "^2.5.3", + "phiki/phiki": "^1.1.4", + "php": "^8.2" + }, + "require-dev": { + "ext-dom": "*", + "ext-libxml": "*", + "laravel/pint": "^1.13", + "pestphp/pest": "^2" + }, + "type": "library", + "autoload": { + "psr-4": { + "Torchlight\\Engine\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Aaron Francis", + "email": "aaron@hammerstone.dev" + }, + { + "name": "John Koster", + "email": "john@stillat.com" + } + ], + "description": "The PHP-based Torchlight code annotation and rendering engine.", + "keywords": [ + "Code highlighting", + "syntax highlighting" + ], + "support": { + "issues": "https://github.com/torchlight-api/engine/issues", + "source": "https://github.com/torchlight-api/engine/tree/v0.1.0" + }, + "time": "2025-04-02T01:47:48+00:00" + }, { "name": "ueberdosis/tiptap-php", "version": "2.0.0", diff --git a/config/graph_contracts.php b/config/graph_contracts.php index 856eb14..f1220a5 100644 --- a/config/graph_contracts.php +++ b/config/graph_contracts.php @@ -80,7 +80,7 @@ ], 'settingsCatalogPolicy' => [ 'resource' => 'deviceManagement/configurationPolicies', - 'allowed_select' => ['id', 'name', 'displayName', 'description', '@odata.type', 'version', 'platforms', 'technologies', 'roleScopeTagIds', 'lastModifiedDateTime'], + 'allowed_select' => ['id', 'name', 'description', '@odata.type', 'platforms', 'technologies', 'templateReference', 'roleScopeTagIds', 'lastModifiedDateTime'], 'allowed_expand' => ['settings'], 'type_family' => [ '#microsoft.graph.deviceManagementConfigurationPolicy', @@ -134,6 +134,96 @@ 'supports_scope_tags' => true, 'scope_tag_field' => 'roleScopeTagIds', ], + 'endpointSecurityPolicy' => [ + 'resource' => 'deviceManagement/configurationPolicies', + 'allowed_select' => ['id', 'name', 'description', '@odata.type', 'platforms', 'technologies', 'roleScopeTagIds', 'lastModifiedDateTime', 'templateReference'], + 'allowed_expand' => [], + 'type_family' => [ + '#microsoft.graph.deviceManagementConfigurationPolicy', + ], + 'create_method' => 'POST', + 'update_method' => 'PATCH', + 'id_field' => 'id', + 'hydration' => 'properties', + 'update_whitelist' => [ + 'name', + 'description', + ], + 'update_map' => [ + 'displayName' => 'name', + ], + 'update_strip_keys' => [ + 'platforms', + 'technologies', + 'templateReference', + 'assignments', + ], + 'member_hydration_strategy' => 'subresource_settings', + 'subresources' => [ + 'settings' => [ + 'path' => 'deviceManagement/configurationPolicies/{id}/settings', + 'collection' => true, + 'paging' => true, + 'allowed_select' => [], + 'allowed_expand' => [], + ], + ], + 'settings_write' => [ + 'path_template' => 'deviceManagement/configurationPolicies/{id}/settings', + 'method' => 'POST', + 'bulk' => true, + 'body_shape' => 'collection', + 'fallback_body_shape' => 'wrapped', + ], + + // Assignments CRUD (standard Graph pattern) + 'assignments_list_path' => '/deviceManagement/configurationPolicies/{id}/assignments', + 'assignments_create_path' => '/deviceManagement/configurationPolicies/{id}/assign', + 'assignments_create_method' => 'POST', + 'assignments_update_path' => '/deviceManagement/configurationPolicies/{id}/assignments/{assignmentId}', + 'assignments_update_method' => 'PATCH', + 'assignments_delete_path' => '/deviceManagement/configurationPolicies/{id}/assignments/{assignmentId}', + 'assignments_delete_method' => 'DELETE', + + // Scope Tags + 'supports_scope_tags' => true, + 'scope_tag_field' => 'roleScopeTagIds', + ], + 'securityBaselinePolicy' => [ + 'resource' => 'deviceManagement/configurationPolicies', + 'allowed_select' => ['id', 'name', 'description', '@odata.type', 'platforms', 'technologies', 'roleScopeTagIds', 'lastModifiedDateTime', 'templateReference'], + 'allowed_expand' => [], + 'type_family' => [ + '#microsoft.graph.deviceManagementConfigurationPolicy', + ], + 'create_method' => 'POST', + 'update_method' => 'PATCH', + 'id_field' => 'id', + 'hydration' => 'properties', + 'member_hydration_strategy' => 'subresource_settings', + 'subresources' => [ + 'settings' => [ + 'path' => 'deviceManagement/configurationPolicies/{id}/settings', + 'collection' => true, + 'paging' => true, + 'allowed_select' => [], + 'allowed_expand' => [], + ], + ], + + // Assignments CRUD (standard Graph pattern) + 'assignments_list_path' => '/deviceManagement/configurationPolicies/{id}/assignments', + 'assignments_create_path' => '/deviceManagement/configurationPolicies/{id}/assign', + 'assignments_create_method' => 'POST', + 'assignments_update_path' => '/deviceManagement/configurationPolicies/{id}/assignments/{assignmentId}', + 'assignments_update_method' => 'PATCH', + 'assignments_delete_path' => '/deviceManagement/configurationPolicies/{id}/assignments/{assignmentId}', + 'assignments_delete_method' => 'DELETE', + + // Scope Tags + 'supports_scope_tags' => true, + 'scope_tag_field' => 'roleScopeTagIds', + ], 'windowsUpdateRing' => [ 'resource' => 'deviceManagement/deviceConfigurations', 'allowed_select' => ['id', 'displayName', 'description', '@odata.type', 'version', 'lastModifiedDateTime'], @@ -145,6 +235,13 @@ 'update_method' => 'PATCH', 'id_field' => 'id', 'hydration' => 'properties', + 'update_strip_keys' => [ + 'version', + 'qualityUpdatesPauseStartDate', + 'featureUpdatesPauseStartDate', + 'qualityUpdatesWillBeRolledBack', + 'featureUpdatesWillBeRolledBack', + ], 'assignments_list_path' => '/deviceManagement/deviceConfigurations/{id}/assignments', 'assignments_create_path' => '/deviceManagement/deviceConfigurations/{id}/assign', 'assignments_create_method' => 'POST', @@ -155,6 +252,88 @@ 'supports_scope_tags' => true, 'scope_tag_field' => 'roleScopeTagIds', ], + 'windowsFeatureUpdateProfile' => [ + 'resource' => 'deviceManagement/windowsFeatureUpdateProfiles', + 'allowed_select' => ['id', 'displayName', 'description', '@odata.type', 'createdDateTime', 'lastModifiedDateTime'], + 'allowed_expand' => [], + 'type_family' => [ + '#microsoft.graph.windowsFeatureUpdateProfile', + ], + 'create_method' => 'POST', + 'update_method' => 'PATCH', + 'id_field' => 'id', + 'hydration' => 'properties', + 'update_strip_keys' => [ + 'deployableContentDisplayName', + 'endOfSupportDate', + ], + 'assignments_list_path' => '/deviceManagement/windowsFeatureUpdateProfiles/{id}/assignments', + 'assignments_create_path' => '/deviceManagement/windowsFeatureUpdateProfiles/{id}/assign', + 'assignments_create_method' => 'POST', + 'assignments_update_path' => '/deviceManagement/windowsFeatureUpdateProfiles/{id}/assignments/{assignmentId}', + 'assignments_update_method' => 'PATCH', + 'assignments_delete_path' => '/deviceManagement/windowsFeatureUpdateProfiles/{id}/assignments/{assignmentId}', + 'assignments_delete_method' => 'DELETE', + ], + 'windowsQualityUpdateProfile' => [ + 'resource' => 'deviceManagement/windowsQualityUpdateProfiles', + 'allowed_select' => ['id', 'displayName', 'description', '@odata.type', 'createdDateTime', 'lastModifiedDateTime'], + 'allowed_expand' => [], + 'type_family' => [ + '#microsoft.graph.windowsQualityUpdateProfile', + ], + 'create_method' => 'POST', + 'update_method' => 'PATCH', + 'id_field' => 'id', + 'hydration' => 'properties', + 'update_strip_keys' => [ + 'releaseDateDisplayName', + 'deployableContentDisplayName', + ], + 'assignments_list_path' => '/deviceManagement/windowsQualityUpdateProfiles/{id}/assignments', + 'assignments_create_path' => '/deviceManagement/windowsQualityUpdateProfiles/{id}/assign', + 'assignments_create_method' => 'POST', + 'assignments_update_path' => '/deviceManagement/windowsQualityUpdateProfiles/{id}/assignments/{assignmentId}', + 'assignments_update_method' => 'PATCH', + 'assignments_delete_path' => '/deviceManagement/windowsQualityUpdateProfiles/{id}/assignments/{assignmentId}', + 'assignments_delete_method' => 'DELETE', + ], + 'windowsDriverUpdateProfile' => [ + 'resource' => 'deviceManagement/windowsDriverUpdateProfiles', + 'allowed_select' => [ + 'id', + 'displayName', + 'description', + '@odata.type', + 'createdDateTime', + 'lastModifiedDateTime', + 'approvalType', + 'deploymentDeferralInDays', + 'roleScopeTagIds', + ], + 'allowed_expand' => [], + 'type_family' => [ + '#microsoft.graph.windowsDriverUpdateProfile', + ], + 'create_method' => 'POST', + 'update_method' => 'PATCH', + 'id_field' => 'id', + 'hydration' => 'properties', + 'update_strip_keys' => [ + 'deviceReporting', + 'newUpdates', + 'inventorySyncStatus', + ], + 'assignments_list_path' => '/deviceManagement/windowsDriverUpdateProfiles/{id}/assignments', + 'assignments_create_path' => '/deviceManagement/windowsDriverUpdateProfiles/{id}/assign', + 'assignments_create_method' => 'POST', + 'assignments_update_path' => '/deviceManagement/windowsDriverUpdateProfiles/{id}/assignments/{assignmentId}', + 'assignments_update_method' => 'PATCH', + 'assignments_delete_path' => '/deviceManagement/windowsDriverUpdateProfiles/{id}/assignments/{assignmentId}', + 'assignments_delete_method' => 'DELETE', + 'supports_scope_tags' => true, + 'scope_tag_field' => 'roleScopeTagIds', + ], 'deviceCompliancePolicy' => [ 'resource' => 'deviceManagement/deviceCompliancePolicies', 'allowed_select' => ['id', 'displayName', 'description', '@odata.type', 'version', 'lastModifiedDateTime'], @@ -215,6 +394,43 @@ 'assignments_create_method' => 'POST', 'assignments_payload_key' => 'assignments', ], + 'mamAppConfiguration' => [ + 'resource' => 'deviceAppManagement/targetedManagedAppConfigurations', + 'allowed_select' => ['id', 'displayName', 'description', '@odata.type', 'version', 'createdDateTime', 'lastModifiedDateTime', 'roleScopeTagIds'], + 'allowed_expand' => [], + 'type_family' => [ + '#microsoft.graph.targetedManagedAppConfiguration', + ], + 'create_method' => 'POST', + 'update_method' => 'PATCH', + 'id_field' => 'id', + 'hydration' => 'properties', + 'assignments_list_path' => '/deviceAppManagement/targetedManagedAppConfigurations/{id}/assignments', + 'assignments_create_path' => '/deviceAppManagement/targetedManagedAppConfigurations/{id}/assign', + 'assignments_create_method' => 'POST', + 'assignments_payload_key' => 'assignments', + 'supports_scope_tags' => true, + 'scope_tag_field' => 'roleScopeTagIds', + ], + 'managedDeviceAppConfiguration' => [ + 'resource' => 'deviceAppManagement/mobileAppConfigurations', + 'allowed_select' => ['id', 'displayName', 'description', '@odata.type', 'createdDateTime', 'lastModifiedDateTime', 'roleScopeTagIds'], + 'allowed_expand' => [], + 'type_family' => [ + '#microsoft.graph.managedDeviceMobileAppConfiguration', + '#microsoft.graph.mobileAppConfiguration', + ], + 'create_method' => 'POST', + 'update_method' => 'PATCH', + 'id_field' => 'id', + 'hydration' => 'properties', + 'assignments_list_path' => '/deviceAppManagement/mobileAppConfigurations/{id}/assignments', + 'assignments_create_path' => '/deviceAppManagement/mobileAppConfigurations/{id}/microsoft.graph.managedDeviceMobileAppConfiguration/assign', + 'assignments_create_method' => 'POST', + 'assignments_payload_key' => 'assignments', + 'supports_scope_tags' => true, + 'scope_tag_field' => 'roleScopeTagIds', + ], 'conditionalAccessPolicy' => [ 'resource' => 'identity/conditionalAccess/policies', 'allowed_select' => ['id', 'displayName', 'state', 'createdDateTime', 'modifiedDateTime', '@odata.type'], @@ -227,6 +443,26 @@ 'id_field' => 'id', 'hydration' => 'properties', ], + 'deviceComplianceScript' => [ + 'resource' => 'deviceManagement/deviceComplianceScripts', + 'allowed_select' => ['id', 'displayName', 'description', '@odata.type', 'lastModifiedDateTime'], + 'allowed_expand' => [], + 'type_family' => [ + '#microsoft.graph.deviceComplianceScript', + ], + 'create_method' => 'POST', + 'update_method' => 'PATCH', + 'id_field' => 'id', + 'hydration' => 'properties', + 'assignments_list_path' => '/deviceManagement/deviceComplianceScripts/{id}/assignments', + 'assignments_create_path' => '/deviceManagement/deviceComplianceScripts/{id}/assign', + 'assignments_create_method' => 'POST', + 'assignments_payload_key' => 'deviceHealthScriptAssignments', + 'assignments_update_path' => '/deviceManagement/deviceComplianceScripts/{id}/assignments/{assignmentId}', + 'assignments_update_method' => 'PATCH', + 'assignments_delete_path' => '/deviceManagement/deviceComplianceScripts/{id}/assignments/{assignmentId}', + 'assignments_delete_method' => 'DELETE', + ], 'deviceManagementScript' => [ 'resource' => 'deviceManagement/deviceManagementScripts', 'allowed_select' => ['id', 'displayName', 'description', '@odata.type', 'lastModifiedDateTime'], @@ -287,13 +523,64 @@ 'assignments_delete_path' => '/deviceManagement/deviceHealthScripts/{id}/assignments/{assignmentId}', 'assignments_delete_method' => 'DELETE', ], + 'deviceEnrollmentLimitConfiguration' => [ + 'resource' => 'deviceManagement/deviceEnrollmentConfigurations', + 'allowed_select' => ['id', 'displayName', 'description', '@odata.type', 'version'], + 'allowed_expand' => [], + 'type_family' => [ + '#microsoft.graph.deviceEnrollmentLimitConfiguration', + ], + 'create_method' => 'POST', + 'update_method' => 'PATCH', + 'id_field' => 'id', + 'hydration' => 'properties', + 'assignments_list_path' => '/deviceManagement/deviceEnrollmentConfigurations/{id}/assignments', + 'assignments_create_path' => '/deviceManagement/deviceEnrollmentConfigurations/{id}/assign', + 'assignments_create_method' => 'POST', + 'assignments_payload_key' => 'enrollmentConfigurationAssignments', + ], + 'deviceEnrollmentPlatformRestrictionsConfiguration' => [ + 'resource' => 'deviceManagement/deviceEnrollmentConfigurations', + 'allowed_select' => ['id', 'displayName', 'description', '@odata.type', 'version'], + 'allowed_expand' => [], + 'type_family' => [ + '#microsoft.graph.deviceEnrollmentPlatformRestrictionConfiguration', + '#microsoft.graph.deviceEnrollmentPlatformRestrictionsConfiguration', + ], + 'create_method' => 'POST', + 'update_method' => 'PATCH', + 'id_field' => 'id', + 'hydration' => 'properties', + 'assignments_list_path' => '/deviceManagement/deviceEnrollmentConfigurations/{id}/assignments', + 'assignments_create_path' => '/deviceManagement/deviceEnrollmentConfigurations/{id}/assign', + 'assignments_create_method' => 'POST', + 'assignments_payload_key' => 'enrollmentConfigurationAssignments', + ], + 'deviceEnrollmentNotificationConfiguration' => [ + 'resource' => 'deviceManagement/deviceEnrollmentConfigurations', + 'allowed_select' => ['id', 'displayName', 'description', '@odata.type', 'version'], + 'allowed_expand' => [], + 'type_family' => [ + '#microsoft.graph.deviceEnrollmentNotificationConfiguration', + ], + 'create_method' => 'POST', + 'update_method' => 'PATCH', + 'id_field' => 'id', + 'hydration' => 'properties', + 'update_strip_keys' => [ + 'notificationTemplateSnapshots', + ], + 'assignments_list_path' => '/deviceManagement/deviceEnrollmentConfigurations/{id}/assignments', + 'assignments_create_path' => '/deviceManagement/deviceEnrollmentConfigurations/{id}/assign', + 'assignments_create_method' => 'POST', + 'assignments_payload_key' => 'enrollmentConfigurationAssignments', + ], 'enrollmentRestriction' => [ 'resource' => 'deviceManagement/deviceEnrollmentConfigurations', 'allowed_select' => ['id', 'displayName', 'description', '@odata.type', 'version'], 'allowed_expand' => [], 'type_family' => [ '#microsoft.graph.deviceEnrollmentConfiguration', - '#microsoft.graph.windows10EnrollmentCompletionPageConfiguration', '#microsoft.graph.deviceEnrollmentWindowsHelloForBusinessConfiguration', '#microsoft.graph.windowsRestoreDeviceEnrollmentConfiguration', ], @@ -306,6 +593,48 @@ 'assignments_create_method' => 'POST', 'assignments_payload_key' => 'enrollmentConfigurationAssignments', ], + 'termsAndConditions' => [ + 'resource' => 'deviceManagement/termsAndConditions', + 'allowed_select' => [ + 'id', + 'displayName', + 'description', + 'title', + 'bodyText', + 'acceptanceStatement', + 'version', + 'roleScopeTagIds', + 'lastModifiedDateTime', + 'createdDateTime', + ], + 'allowed_expand' => [], + 'type_family' => [ + '#microsoft.graph.termsAndConditions', + ], + 'create_method' => 'POST', + 'update_method' => 'PATCH', + 'id_field' => 'id', + 'hydration' => 'properties', + 'update_strip_keys' => [ + 'createdDateTime', + 'lastModifiedDateTime', + 'modifiedDateTime', + 'version', + 'acceptanceStatuses', + 'assignments', + 'groupAssignments', + ], + 'assignments_list_path' => '/deviceManagement/termsAndConditions/{id}/assignments', + 'assignments_create_path' => '/deviceManagement/termsAndConditions/{id}/assignments', + 'assignments_create_method' => 'POST', + 'assignments_payload_key' => 'termsAndConditionsAssignments', + 'assignments_update_path' => '/deviceManagement/termsAndConditions/{id}/assignments/{assignmentId}', + 'assignments_update_method' => 'PATCH', + 'assignments_delete_path' => '/deviceManagement/termsAndConditions/{id}/assignments/{assignmentId}', + 'assignments_delete_method' => 'DELETE', + 'supports_scope_tags' => true, + 'scope_tag_field' => 'roleScopeTagIds', + ], 'windowsAutopilotDeploymentProfile' => [ 'resource' => 'deviceManagement/windowsAutopilotDeploymentProfiles', 'allowed_select' => ['id', 'displayName', 'description', '@odata.type', 'lastModifiedDateTime'], @@ -360,6 +689,11 @@ 'update_method' => 'PATCH', 'id_field' => 'id', 'hydration' => 'properties', + 'update_strip_keys' => [ + 'isAssigned', + 'templateId', + 'isMigratingToConfigurationPolicy', + ], ], 'mobileApp' => [ 'resource' => 'deviceAppManagement/mobileApps', diff --git a/config/tenantpilot.php b/config/tenantpilot.php index 1e214df..d59d3cf 100644 --- a/config/tenantpilot.php +++ b/config/tenantpilot.php @@ -8,7 +8,7 @@ 'category' => 'Configuration', 'platform' => 'all', 'endpoint' => 'deviceManagement/deviceConfigurations', - 'filter' => "@odata.type ne '#microsoft.graph.windowsUpdateForBusinessConfiguration'", + 'filter' => "not isof('microsoft.graph.windowsUpdateForBusinessConfiguration')", 'backup' => 'full', 'restore' => 'enabled', 'risk' => 'medium', @@ -39,11 +39,41 @@ 'category' => 'Update Management', 'platform' => 'windows', 'endpoint' => 'deviceManagement/deviceConfigurations', - 'filter' => "@odata.type eq '#microsoft.graph.windowsUpdateForBusinessConfiguration'", + 'filter' => "isof('microsoft.graph.windowsUpdateForBusinessConfiguration')", 'backup' => 'full', 'restore' => 'enabled', 'risk' => 'medium-high', ], + [ + 'type' => 'windowsFeatureUpdateProfile', + 'label' => 'Feature Updates (Windows)', + 'category' => 'Update Management', + 'platform' => 'windows', + 'endpoint' => 'deviceManagement/windowsFeatureUpdateProfiles', + 'backup' => 'full', + 'restore' => 'enabled', + 'risk' => 'high', + ], + [ + 'type' => 'windowsQualityUpdateProfile', + 'label' => 'Quality Updates (Windows)', + 'category' => 'Update Management', + 'platform' => 'windows', + 'endpoint' => 'deviceManagement/windowsQualityUpdateProfiles', + 'backup' => 'full', + 'restore' => 'enabled', + 'risk' => 'high', + ], + [ + 'type' => 'windowsDriverUpdateProfile', + 'label' => 'Driver Updates (Windows)', + 'category' => 'Update Management', + 'platform' => 'windows', + 'endpoint' => 'deviceManagement/windowsDriverUpdateProfiles', + 'backup' => 'full', + 'restore' => 'enabled', + 'risk' => 'high', + ], [ 'type' => 'deviceCompliancePolicy', 'label' => 'Device Compliance', @@ -64,6 +94,27 @@ 'restore' => 'enabled', 'risk' => 'medium-high', ], + [ + 'type' => 'mamAppConfiguration', + 'label' => 'App Configuration (MAM)', + 'category' => 'Apps/MAM', + 'platform' => 'mobile', + 'endpoint' => 'deviceAppManagement/targetedManagedAppConfigurations', + 'backup' => 'full', + 'restore' => 'enabled', + 'risk' => 'medium-high', + ], + [ + 'type' => 'managedDeviceAppConfiguration', + 'label' => 'App Configuration (Device)', + 'category' => 'Apps/MAM', + 'platform' => 'mobile', + 'endpoint' => 'deviceAppManagement/mobileAppConfigurations', + 'filter' => "microsoft.graph.androidManagedStoreAppConfiguration/appSupportsOemConfig eq false or isof('microsoft.graph.androidManagedStoreAppConfiguration') eq false", + 'backup' => 'full', + 'restore' => 'enabled', + 'risk' => 'medium-high', + ], [ 'type' => 'conditionalAccessPolicy', 'label' => 'Conditional Access', @@ -105,14 +156,14 @@ 'risk' => 'medium', ], [ - 'type' => 'enrollmentRestriction', - 'label' => 'Enrollment Restrictions', - 'category' => 'Enrollment', - 'platform' => 'all', - 'endpoint' => 'deviceManagement/deviceEnrollmentConfigurations', + 'type' => 'deviceComplianceScript', + 'label' => 'Custom Compliance Scripts', + 'category' => 'Compliance', + 'platform' => 'windows', + 'endpoint' => 'deviceManagement/deviceComplianceScripts', 'backup' => 'full', - 'restore' => 'preview-only', - 'risk' => 'high', + 'restore' => 'enabled', + 'risk' => 'medium-high', ], [ 'type' => 'windowsAutopilotDeploymentProfile', @@ -130,11 +181,61 @@ 'category' => 'Enrollment', 'platform' => 'all', 'endpoint' => 'deviceManagement/deviceEnrollmentConfigurations', - 'filter' => "@odata.type eq '#microsoft.graph.windows10EnrollmentCompletionPageConfiguration'", 'backup' => 'full', 'restore' => 'enabled', 'risk' => 'medium', ], + [ + 'type' => 'deviceEnrollmentLimitConfiguration', + 'label' => 'Enrollment Limits', + 'category' => 'Enrollment', + 'platform' => 'all', + 'endpoint' => 'deviceManagement/deviceEnrollmentConfigurations', + 'backup' => 'full', + 'restore' => 'preview-only', + 'risk' => 'high', + ], + [ + 'type' => 'deviceEnrollmentPlatformRestrictionsConfiguration', + 'label' => 'Platform Restrictions (Enrollment)', + 'category' => 'Enrollment', + 'platform' => 'all', + 'endpoint' => 'deviceManagement/deviceEnrollmentConfigurations', + 'backup' => 'full', + 'restore' => 'preview-only', + 'risk' => 'high', + ], + [ + 'type' => 'deviceEnrollmentNotificationConfiguration', + 'label' => 'Enrollment Notifications', + 'category' => 'Enrollment', + 'platform' => 'all', + 'endpoint' => 'deviceManagement/deviceEnrollmentConfigurations', + 'filter' => "deviceEnrollmentConfigurationType eq 'EnrollmentNotificationsConfiguration'", + 'backup' => 'full', + 'restore' => 'preview-only', + 'risk' => 'high', + ], + [ + 'type' => 'enrollmentRestriction', + 'label' => 'Enrollment Restrictions', + 'category' => 'Enrollment', + 'platform' => 'all', + 'endpoint' => 'deviceManagement/deviceEnrollmentConfigurations', + 'backup' => 'full', + 'restore' => 'preview-only', + 'risk' => 'high', + ], + [ + 'type' => 'termsAndConditions', + 'label' => 'Terms & Conditions', + 'category' => 'Enrollment', + 'platform' => 'all', + 'endpoint' => 'deviceManagement/termsAndConditions', + 'backup' => 'full', + 'restore' => 'enabled', + 'risk' => 'medium-high', + ], [ 'type' => 'endpointSecurityIntent', 'label' => 'Endpoint Security Intents', @@ -145,6 +246,26 @@ 'restore' => 'enabled', 'risk' => 'high', ], + [ + 'type' => 'endpointSecurityPolicy', + 'label' => 'Endpoint Security Policies', + 'category' => 'Endpoint Security', + 'platform' => 'windows', + 'endpoint' => 'deviceManagement/configurationPolicies', + 'backup' => 'full', + 'restore' => 'enabled', + 'risk' => 'high', + ], + [ + 'type' => 'securityBaselinePolicy', + 'label' => 'Security Baselines', + 'category' => 'Endpoint Security', + 'platform' => 'windows', + 'endpoint' => 'deviceManagement/configurationPolicies', + 'backup' => 'full', + 'restore' => 'preview-only', + 'risk' => 'high', + ], [ 'type' => 'mobileApp', 'label' => 'Applications (Metadata only)', @@ -198,4 +319,9 @@ 'chunk_size' => (int) env('TENANTPILOT_BULK_CHUNK_SIZE', 10), 'poll_interval_seconds' => (int) env('TENANTPILOT_BULK_POLL_INTERVAL_SECONDS', 3), ], + + 'display' => [ + 'show_script_content' => (bool) env('TENANTPILOT_SHOW_SCRIPT_CONTENT', false), + 'max_script_content_chars' => (int) env('TENANTPILOT_MAX_SCRIPT_CONTENT_CHARS', 5000), + ], ]; diff --git a/database/factories/TenantFactory.php b/database/factories/TenantFactory.php index 0938ebe..3abfbdd 100644 --- a/database/factories/TenantFactory.php +++ b/database/factories/TenantFactory.php @@ -26,6 +26,7 @@ public function definition(): array 'app_status' => 'ok', 'app_notes' => null, 'status' => 'active', + 'environment' => 'other', 'is_current' => false, 'metadata' => [], ]; diff --git a/database/migrations/2026_01_04_135956_add_environment_to_tenants_table.php b/database/migrations/2026_01_04_135956_add_environment_to_tenants_table.php new file mode 100644 index 0000000..8576c8c --- /dev/null +++ b/database/migrations/2026_01_04_135956_add_environment_to_tenants_table.php @@ -0,0 +1,24 @@ +string('environment')->default('other')->after('status'); + $table->index('environment'); + }); + } + + public function down(): void + { + Schema::table('tenants', function (Blueprint $table) { + $table->dropIndex(['environment']); + $table->dropColumn('environment'); + }); + } +}; diff --git a/database/migrations/2026_01_04_135957_create_tenant_user_table.php b/database/migrations/2026_01_04_135957_create_tenant_user_table.php new file mode 100644 index 0000000..c0b5dcf --- /dev/null +++ b/database/migrations/2026_01_04_135957_create_tenant_user_table.php @@ -0,0 +1,61 @@ +foreignId('tenant_id')->constrained()->cascadeOnDelete(); + $table->foreignId('user_id')->constrained()->cascadeOnDelete(); + $table->string('role')->default('owner'); + $table->timestamps(); + + $table->unique(['tenant_id', 'user_id']); + }); + + $now = now(); + + $tenantIds = DB::table('tenants') + ->whereNull('deleted_at') + ->pluck('id'); + + $userIds = DB::table('users')->pluck('id'); + + if ($tenantIds->isEmpty() || $userIds->isEmpty()) { + return; + } + + $rows = []; + + foreach ($tenantIds as $tenantId) { + foreach ($userIds as $userId) { + $rows[] = [ + 'tenant_id' => $tenantId, + 'user_id' => $userId, + 'role' => 'owner', + 'created_at' => $now, + 'updated_at' => $now, + ]; + + if (count($rows) >= 500) { + DB::table('tenant_user')->insertOrIgnore($rows); + $rows = []; + } + } + } + + if ($rows !== []) { + DB::table('tenant_user')->insertOrIgnore($rows); + } + } + + public function down(): void + { + Schema::dropIfExists('tenant_user'); + } +}; diff --git a/database/migrations/2026_01_04_135957_create_user_tenant_preferences_table.php b/database/migrations/2026_01_04_135957_create_user_tenant_preferences_table.php new file mode 100644 index 0000000..e460b08 --- /dev/null +++ b/database/migrations/2026_01_04_135957_create_user_tenant_preferences_table.php @@ -0,0 +1,28 @@ +id(); + $table->foreignId('user_id')->constrained()->cascadeOnDelete(); + $table->foreignId('tenant_id')->constrained()->cascadeOnDelete(); + $table->boolean('is_favorite')->default(false); + $table->timestamp('last_used_at')->nullable(); + $table->timestamps(); + + $table->unique(['user_id', 'tenant_id']); + $table->index(['user_id', 'last_used_at']); + }); + } + + public function down(): void + { + Schema::dropIfExists('user_tenant_preferences'); + } +}; diff --git a/database/migrations/2026_01_05_011014_create_backup_schedules_table.php b/database/migrations/2026_01_05_011014_create_backup_schedules_table.php new file mode 100644 index 0000000..08bdf6e --- /dev/null +++ b/database/migrations/2026_01_05_011014_create_backup_schedules_table.php @@ -0,0 +1,43 @@ +id(); + $table->foreignId('tenant_id')->constrained('tenants')->cascadeOnDelete(); + $table->string('name'); + $table->boolean('is_enabled')->default(true); + $table->string('timezone')->default('UTC'); + $table->enum('frequency', ['daily', 'weekly']); + $table->time('time_of_day'); + $table->json('days_of_week')->nullable(); + $table->json('policy_types'); + $table->boolean('include_foundations')->default(true); + $table->integer('retention_keep_last')->default(30); + $table->dateTime('last_run_at')->nullable(); + $table->string('last_run_status')->nullable(); + $table->dateTime('next_run_at')->nullable(); + $table->timestamps(); + + $table->index(['tenant_id', 'is_enabled']); + $table->index('next_run_at'); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('backup_schedules'); + } +}; diff --git a/database/migrations/2026_01_05_011034_create_backup_schedule_runs_table.php b/database/migrations/2026_01_05_011034_create_backup_schedule_runs_table.php new file mode 100644 index 0000000..edc1021 --- /dev/null +++ b/database/migrations/2026_01_05_011034_create_backup_schedule_runs_table.php @@ -0,0 +1,41 @@ +id(); + $table->foreignId('backup_schedule_id')->constrained('backup_schedules')->cascadeOnDelete(); + $table->foreignId('tenant_id')->constrained('tenants')->cascadeOnDelete(); + $table->dateTime('scheduled_for'); + $table->dateTime('started_at')->nullable(); + $table->dateTime('finished_at')->nullable(); + $table->enum('status', ['running', 'success', 'partial', 'failed', 'canceled', 'skipped']); + $table->json('summary')->nullable(); + $table->string('error_code')->nullable(); + $table->text('error_message')->nullable(); + $table->foreignId('backup_set_id')->nullable()->constrained('backup_sets')->nullOnDelete(); + $table->timestamps(); + + $table->unique(['backup_schedule_id', 'scheduled_for']); + $table->index(['backup_schedule_id', 'scheduled_for']); + $table->index(['tenant_id', 'created_at']); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('backup_schedule_runs'); + } +}; diff --git a/database/migrations/2026_01_06_211013_add_user_id_to_backup_schedule_runs_table.php b/database/migrations/2026_01_06_211013_add_user_id_to_backup_schedule_runs_table.php new file mode 100644 index 0000000..b01bc37 --- /dev/null +++ b/database/migrations/2026_01_06_211013_add_user_id_to_backup_schedule_runs_table.php @@ -0,0 +1,35 @@ +foreignId('user_id') + ->nullable() + ->after('tenant_id') + ->constrained() + ->nullOnDelete(); + + $table->index(['user_id', 'created_at'], 'backup_schedule_runs_user_created'); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::table('backup_schedule_runs', function (Blueprint $table) { + $table->dropIndex('backup_schedule_runs_user_created'); + $table->dropConstrainedForeignId('user_id'); + }); + } +}; diff --git a/phpunit.xml b/phpunit.xml index d703241..75c4ea3 100644 --- a/phpunit.xml +++ b/phpunit.xml @@ -18,7 +18,9 @@ + + diff --git a/resources/views/admin-consent-callback.blade.php b/resources/views/admin-consent-callback.blade.php index 9e7cb3f..3cfe8df 100644 --- a/resources/views/admin-consent-callback.blade.php +++ b/resources/views/admin-consent-callback.blade.php @@ -31,7 +31,11 @@

Admin consent wurde bestätigt.

@endif -

Zurück zur Tenant-Detailseite

+

+ + Zurück zur Tenant-Detailseite + +

diff --git a/resources/views/filament/infolists/entries/normalized-diff.blade.php b/resources/views/filament/infolists/entries/normalized-diff.blade.php index c57ebd1..0390c23 100644 --- a/resources/views/filament/infolists/entries/normalized-diff.blade.php +++ b/resources/views/filament/infolists/entries/normalized-diff.blade.php @@ -1,6 +1,7 @@ @php $diff = $getState() ?? ['summary' => [], 'added' => [], 'removed' => [], 'changed' => []]; $summary = $diff['summary'] ?? []; + $policyType = $diff['policy_type'] ?? null; $groupByBlock = static function (array $items): array { $groups = []; @@ -50,6 +51,180 @@ return is_string($value) && strlen($value) > 160; }; + + $isScriptKey = static function (mixed $name): bool { + return in_array((string) $name, ['scriptContent', 'detectionScriptContent', 'remediationScriptContent'], true); + }; + + $canHighlightScripts = static function (?string $policyType): bool { + return (bool) config('tenantpilot.display.show_script_content', false) + && in_array($policyType, ['deviceManagementScript', 'deviceShellScript', 'deviceHealthScript', 'deviceComplianceScript'], true); + }; + + $selectGrammar = static function (?string $policyType, string $code): string { + if ($policyType === 'deviceShellScript') { + $firstLine = strtok($code, "\n") ?: ''; + $shebang = trim($firstLine); + + if (str_starts_with($shebang, '#!')) { + if (str_contains($shebang, 'zsh')) { + return 'zsh'; + } + + if (str_contains($shebang, 'bash')) { + return 'bash'; + } + + return 'sh'; + } + + return 'sh'; + } + + return 'powershell'; + }; + + $highlight = static function (?string $policyType, string $code, string $fallbackClass = '') use ($selectGrammar): ?string { + if (! class_exists(\Torchlight\Engine\Engine::class)) { + return null; + } + + try { + return (new \Torchlight\Engine\Engine())->codeToHtml( + code: $code, + grammar: $selectGrammar($policyType, $code), + theme: [ + 'light' => 'github-light', + 'dark' => 'github-dark', + ], + withGutter: false, + withWrapper: true, + ); + } catch (\Throwable $e) { + return null; + } + }; + + $highlightInline = static function (?string $policyType, string $code) use ($selectGrammar): ?string { + if (! class_exists(\Torchlight\Engine\Engine::class)) { + return null; + } + + if ($code === '') { + return ''; + } + + try { + $html = (new \Torchlight\Engine\Engine())->codeToHtml( + code: $code, + grammar: $selectGrammar($policyType, $code), + theme: [ + 'light' => 'github-light', + 'dark' => 'github-dark', + ], + withGutter: false, + withWrapper: false, + ); + + $html = (string) preg_replace('//', '', $html); + + if (! preg_match('/]*>.*?<\\/code>/s', $html, $matches)) { + return null; + } + + return trim((string) ($matches[0] ?? '')); + } catch (\Throwable $e) { + return null; + } + }; + + $splitLines = static function (string $text): array { + $text = str_replace(["\r\n", "\r"], "\n", $text); + + return $text === '' ? [] : explode("\n", $text); + }; + + $myersLineDiff = static function (array $a, array $b): array { + $n = count($a); + $m = count($b); + $max = $n + $m; + + $v = [1 => 0]; + $trace = []; + + for ($d = 0; $d <= $max; $d++) { + $trace[$d] = $v; + + for ($k = -$d; $k <= $d; $k += 2) { + $kPlus = $v[$k + 1] ?? 0; + $kMinus = $v[$k - 1] ?? 0; + + if ($k === -$d || ($k !== $d && $kMinus < $kPlus)) { + $x = $kPlus; + } else { + $x = $kMinus + 1; + } + + $y = $x - $k; + + while ($x < $n && $y < $m && $a[$x] === $b[$y]) { + $x++; + $y++; + } + + $v[$k] = $x; + + if ($x >= $n && $y >= $m) { + break 2; + } + } + } + + $ops = []; + $x = $n; + $y = $m; + + for ($d = count($trace) - 1; $d >= 0; $d--) { + $v = $trace[$d]; + $k = $x - $y; + + $kPlus = $v[$k + 1] ?? 0; + $kMinus = $v[$k - 1] ?? 0; + + if ($k === -$d || ($k !== $d && $kMinus < $kPlus)) { + $prevK = $k + 1; + } else { + $prevK = $k - 1; + } + + $prevX = $v[$prevK] ?? 0; + $prevY = $prevX - $prevK; + + while ($x > $prevX && $y > $prevY) { + $ops[] = ['type' => 'equal', 'line' => $a[$x - 1]]; + $x--; + $y--; + } + + if ($d === 0) { + break; + } + + if ($x === $prevX) { + $ops[] = ['type' => 'insert', 'line' => $b[$y - 1] ?? '']; + $y--; + } else { + $ops[] = ['type' => 'delete', 'line' => $a[$x - 1] ?? '']; + $x--; + } + } + + return array_reverse($ops); + }; + + $scriptLineDiff = static function (string $fromText, string $toText) use ($splitLines, $myersLineDiff): array { + return $myersLineDiff($splitLines($fromText), $splitLines($toText)); + }; @endphp
@@ -103,37 +278,467 @@ $to = $value['to']; $fromText = $stringify($from); $toText = $stringify($to); + + $isScriptContent = $canHighlightScripts($policyType) && $isScriptKey($name); + $ops = $isScriptContent ? $scriptLineDiff((string) $fromText, (string) $toText) : []; + $useTorchlight = $isScriptContent && class_exists(\Torchlight\Engine\Engine::class); + + $rows = []; + if ($isScriptContent) { + $count = count($ops); + + for ($i = 0; $i < $count; $i++) { + $op = $ops[$i]; + $next = $ops[$i + 1] ?? null; + $type = $op['type'] ?? null; + $line = (string) ($op['line'] ?? ''); + + if ($type === 'equal') { + $rows[] = [ + 'left' => ['type' => 'equal', 'line' => $line], + 'right' => ['type' => 'equal', 'line' => $line], + ]; + continue; + } + + if ($type === 'delete' && is_array($next) && ($next['type'] ?? null) === 'insert') { + $rows[] = [ + 'left' => ['type' => 'delete', 'line' => $line], + 'right' => ['type' => 'insert', 'line' => (string) ($next['line'] ?? '')], + ]; + $i++; + continue; + } + + if ($type === 'delete') { + $rows[] = [ + 'left' => ['type' => 'delete', 'line' => $line], + 'right' => ['type' => 'blank', 'line' => ''], + ]; + continue; + } + + if ($type === 'insert') { + $rows[] = [ + 'left' => ['type' => 'blank', 'line' => ''], + 'right' => ['type' => 'insert', 'line' => $line], + ]; + continue; + } + } + } @endphp
{{ (string) $name }}
-
- From - @if ($isExpandable($from)) -
+ + @if ($isScriptContent) +
+ Script +
View -
{{ $fromText }}
+ +
+
+ + Diff + + + Before + + + After + + + + ⤢ Fullscreen + +
+ +
+
+
+
Old
+
@php
+foreach ($rows as $row) {
+    $left = $row['left'];
+    $leftType = $left['type'];
+    $leftLine = (string) ($left['line'] ?? '');
+
+    $leftHighlighted = $useTorchlight ? $highlightInline($policyType, $leftLine) : null;
+    $leftRendered = (is_string($leftHighlighted) && $leftHighlighted !== '') ? $leftHighlighted : e($leftLine);
+
+    if ($leftType === 'equal') {
+        if ($useTorchlight) {
+            @endphp
+            @once
+                @include('filament.partials.torchlight-dark-overrides')
+                
+            @endonce
+            @php
+        }
+
+        echo ''.$leftRendered."\n";
+        continue;
+    }
+
+    if ($leftType === 'delete') {
+        if ($useTorchlight) {
+            @endphp
+            @once
+                @include('filament.partials.torchlight-dark-overrides')
+                
+            @endonce
+            @php
+        }
+
+        echo '- '.$leftRendered."\n";
+        continue;
+    }
+
+    echo "\n";
+}
+@endphp
+
+ +
+
New
+
@php
+foreach ($rows as $row) {
+    $right = $row['right'];
+    $rightType = $right['type'];
+    $rightLine = (string) ($right['line'] ?? '');
+
+    $rightHighlighted = $useTorchlight ? $highlightInline($policyType, $rightLine) : null;
+    $rightRendered = (is_string($rightHighlighted) && $rightHighlighted !== '') ? $rightHighlighted : e($rightLine);
+
+    if ($rightType === 'equal') {
+        if ($useTorchlight) {
+            @endphp
+            @once
+                @include('filament.partials.torchlight-dark-overrides')
+                
+            @endonce
+            @php
+        }
+
+        echo ''.$rightRendered."\n";
+        continue;
+    }
+
+    if ($rightType === 'insert') {
+        if ($useTorchlight) {
+            @endphp
+            @once
+                @include('filament.partials.torchlight-dark-overrides')
+                
+            @endonce
+            @php
+        }
+
+        echo '+ '.$rightRendered."\n";
+        continue;
+    }
+
+    echo "\n";
+}
+@endphp
+
+
+
+ +
+
Before
+ @php + $highlightedBefore = $useTorchlight ? $highlight($policyType, (string) $fromText) : null; + @endphp + + @if (is_string($highlightedBefore) && $highlightedBefore !== '') + @once + @include('filament.partials.torchlight-dark-overrides') + @endonce + +
{!! $highlightedBefore !!}
+ @else +
{{ (string) $fromText }}
+ @endif +
+ +
+
After
+ @php + $highlightedAfter = $useTorchlight ? $highlight($policyType, (string) $toText) : null; + @endphp + + @if (is_string($highlightedAfter) && $highlightedAfter !== '') + @once + @include('filament.partials.torchlight-dark-overrides') + @endonce + +
{!! $highlightedAfter !!}
+ @else +
{{ (string) $toText }}
+ @endif +
+
+ +
+
+
+
+
Script diff
+
+ + Close + +
+
+ +
+
+
+ + Diff + + + Before + + + After + +
+ +
+
+
+
Old
+
@php
+foreach ($rows as $row) {
+    $left = $row['left'];
+    $leftType = $left['type'];
+    $leftLine = (string) ($left['line'] ?? '');
+
+    $leftHighlighted = $useTorchlight ? $highlightInline($policyType, $leftLine) : null;
+    $leftRendered = (is_string($leftHighlighted) && $leftHighlighted !== '') ? $leftHighlighted : e($leftLine);
+
+    if ($leftType === 'equal') {
+        if ($useTorchlight) {
+            @endphp
+            @once
+                @include('filament.partials.torchlight-dark-overrides')
+                
+            @endonce
+            @php
+        }
+
+        echo ''.$leftRendered."\n";
+        continue;
+    }
+
+    if ($leftType === 'delete') {
+        if ($useTorchlight) {
+            @endphp
+            @once
+                @include('filament.partials.torchlight-dark-overrides')
+                
+            @endonce
+            @php
+        }
+
+        echo '- '.$leftRendered."\n";
+        continue;
+    }
+
+    echo "\n";
+}
+@endphp
+
+ +
+
New
+
@php
+foreach ($rows as $row) {
+    $right = $row['right'];
+    $rightType = $right['type'];
+    $rightLine = (string) ($right['line'] ?? '');
+
+    $rightHighlighted = $useTorchlight ? $highlightInline($policyType, $rightLine) : null;
+    $rightRendered = (is_string($rightHighlighted) && $rightHighlighted !== '') ? $rightHighlighted : e($rightLine);
+
+    if ($rightType === 'equal') {
+        if ($useTorchlight) {
+            @endphp
+            @once
+                @include('filament.partials.torchlight-dark-overrides')
+                
+            @endonce
+            @php
+        }
+
+        echo ''.$rightRendered."\n";
+        continue;
+    }
+
+    if ($rightType === 'insert') {
+        if ($useTorchlight) {
+            @endphp
+            @once
+                @include('filament.partials.torchlight-dark-overrides')
+                
+            @endonce
+            @php
+        }
+
+        echo '+ '.$rightRendered."\n";
+        continue;
+    }
+
+    echo "\n";
+}
+@endphp
+
+
+
+ +
+
Before
+ @php + $highlightedBeforeFullscreen = $useTorchlight ? $highlight($policyType, (string) $fromText) : null; + @endphp + + @if (is_string($highlightedBeforeFullscreen) && $highlightedBeforeFullscreen !== '') + @once + @include('filament.partials.torchlight-dark-overrides') + @endonce + +
{!! $highlightedBeforeFullscreen !!}
+ @else +
{{ (string) $fromText }}
+ @endif +
+ +
+
After
+ @php + $highlightedAfterFullscreen = $useTorchlight ? $highlight($policyType, (string) $toText) : null; + @endphp + + @if (is_string($highlightedAfterFullscreen) && $highlightedAfterFullscreen !== '') + @once + @include('filament.partials.torchlight-dark-overrides') + @endonce + +
{!! $highlightedAfterFullscreen !!}
+ @else +
{{ (string) $toText }}
+ @endif +
+
+
+
+
- @else -
{{ $fromText }}
- @endif -
-
- To - @if ($isExpandable($to)) -
- - View - -
{{ $toText }}
-
- @else -
{{ $toText }}
- @endif -
+
+ @else +
+ From + @if ($isExpandable($from)) +
+ + View + +
{{ $fromText }}
+
+ @else +
{{ $fromText }}
+ @endif +
+
+ To + @if ($isExpandable($to)) +
+ + View + +
{{ $toText }}
+
+ @else +
{{ $toText }}
+ @endif +
+ @endif
@else @php @@ -149,7 +754,20 @@ View -
{{ $text }}
+ @php + $isScriptContent = $canHighlightScripts($policyType) && $isScriptKey($name); + $highlighted = $isScriptContent ? $highlight($policyType, (string) $text) : null; + @endphp + + @if (is_string($highlighted) && $highlighted !== '') + @once + @include('filament.partials.torchlight-dark-overrides') + @endonce + +
{!! $highlighted !!}
+ @else +
{{ $text }}
+ @endif @else
{{ $text }}
diff --git a/resources/views/filament/infolists/entries/policy-general.blade.php b/resources/views/filament/infolists/entries/policy-general.blade.php index 69bd5f8..c22bc0a 100644 --- a/resources/views/filament/infolists/entries/policy-general.blade.php +++ b/resources/views/filament/infolists/entries/policy-general.blade.php @@ -1,4 +1,7 @@ @php + use Carbon\CarbonImmutable; + use Illuminate\Support\Str; + $general = $getState(); $entries = is_array($general) ? ($general['entries'] ?? []) : []; $cards = []; @@ -61,6 +64,27 @@ 'teal' => 'bg-teal-100/80 text-teal-700 dark:bg-teal-900/40 dark:text-teal-200', 'slate' => 'bg-slate-100/80 text-slate-700 dark:bg-slate-900/40 dark:text-slate-200', ]; + + $formatIsoDateTime = static function (string $value): ?string { + $trimmed = trim($value); + + if ($trimmed === '') { + return null; + } + + if (! preg_match('/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}/', $trimmed)) { + return null; + } + + // Graph can return 7 fractional digits; PHP supports 6 (microseconds). + $normalized = preg_replace('/\.(\d{6})\d+Z$/', '.$1Z', $trimmed); + + try { + return CarbonImmutable::parse($normalized)->toDateTimeString(); + } catch (\Throwable) { + return null; + } + }; @endphp @if (empty($cards)) @@ -72,6 +96,9 @@ $keyLower = $entry['key_lower'] ?? ''; $value = $entry['value'] ?? null; $isPlatform = str_contains($keyLower, 'platform'); + $isTechnologies = str_contains($keyLower, 'technolog'); + $isTemplateReference = str_contains($keyLower, 'template'); + $isDateTime = is_string($value) && ($formattedDateTime = $formatIsoDateTime($value)) !== null; $toneKey = match (true) { str_contains($keyLower, 'name') => 'name', str_contains($keyLower, 'platform') => 'platform', @@ -88,6 +115,15 @@ $isBooleanValue = is_bool($value); $isBooleanString = is_string($value) && in_array(strtolower($value), ['true', 'false', 'enabled', 'disabled'], true); $isNumericValue = is_numeric($value); + + $badgeItems = null; + + if ($isListValue) { + $badgeItems = $value; + } elseif (($isPlatform || $isTechnologies) && is_string($value)) { + $split = array_values(array_filter(array_map('trim', explode(',', $value)), static fn (string $item): bool => $item !== '')); + $badgeItems = $split !== [] ? $split : [$value]; + } @endphp
@@ -100,16 +136,50 @@ {{ $entry['key'] ?? '-' }}
- @if ($isListValue) + @if ($isTemplateReference && is_array($value)) + @php + $templateDisplayName = $value['templateDisplayName'] ?? null; + $templateFamily = $value['templateFamily'] ?? null; + $templateDisplayVersion = $value['templateDisplayVersion'] ?? null; + $templateId = $value['templateId'] ?? null; + + $familyLabel = is_string($templateFamily) && $templateFamily !== '' ? Str::headline($templateFamily) : null; + @endphp + +
+
+ {{ is_string($templateDisplayName) && $templateDisplayName !== '' ? $templateDisplayName : 'Template' }} +
+ +
+ @if ($familyLabel) + {{ $familyLabel }} + @endif + @if (is_string($templateDisplayVersion) && $templateDisplayVersion !== '') + {{ $templateDisplayVersion }} + @endif +
+ + @if (is_string($templateId) && $templateId !== '') +
+ {{ $templateId }} +
+ @endif +
+ @elseif ($isDateTime) +
+ {{ $formattedDateTime }} +
+ @elseif (is_array($badgeItems) && $badgeItems !== [])
- @foreach ($value as $item) + @foreach ($badgeItems as $item) {{ $item }} @endforeach
@elseif ($isJsonValue) -
{{ json_encode($value, JSON_PRETTY_PRINT) }}
+
{{ json_encode($value, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE) }}
@elseif ($isBooleanValue || $isBooleanString) @php $boolValue = $isBooleanValue @@ -126,7 +196,7 @@
@else
- {{ is_string($value) ? $value : json_encode($value, JSON_PRETTY_PRINT) }} + {{ is_string($value) ? $value : json_encode($value, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE) }}
@endif diff --git a/resources/views/filament/infolists/entries/policy-settings-standard.blade.php b/resources/views/filament/infolists/entries/policy-settings-standard.blade.php index 9fb9398..2707044 100644 --- a/resources/views/filament/infolists/entries/policy-settings-standard.blade.php +++ b/resources/views/filament/infolists/entries/policy-settings-standard.blade.php @@ -7,6 +7,76 @@ $warnings = $state['warnings'] ?? []; $settings = $state['settings'] ?? []; $settingsTable = $state['settings_table'] ?? null; + + $policyType = $state['policy_type'] ?? null; + + $stringifyValue = function (mixed $value): string { + if (is_null($value)) { + return 'N/A'; + } + + if (is_bool($value)) { + return $value ? 'Enabled' : 'Disabled'; + } + + if (is_scalar($value)) { + return (string) $value; + } + + if (is_array($value)) { + $encoded = json_encode($value, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE); + + return is_string($encoded) ? $encoded : 'N/A'; + } + + if (is_object($value)) { + if (method_exists($value, '__toString')) { + return (string) $value; + } + + $encoded = json_encode((array) $value, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE); + + return is_string($encoded) ? $encoded : 'N/A'; + } + + return 'N/A'; + }; + + $shouldRenderBadges = function (mixed $value): bool { + if (! is_array($value) || $value === []) { + return false; + } + + if (! array_is_list($value)) { + return false; + } + + foreach ($value as $item) { + if (! is_scalar($item) && ! is_null($item)) { + return false; + } + } + + return true; + }; + + $asEnabledDisabledBadgeValue = function (mixed $value): ?bool { + if (is_bool($value)) { + return $value; + } + + if (! is_string($value)) { + return null; + } + + $normalized = strtolower(trim($value)); + + return match ($normalized) { + 'enabled', 'true', 'yes', '1' => true, + 'disabled', 'false', 'no', '0' => false, + default => null, + }; + }; @endphp
@@ -46,9 +116,13 @@
- @if(is_bool($row['value'])) - - {{ $row['value'] ? 'Enabled' : 'Disabled' }} + @php + $badgeValue = $asEnabledDisabledBadgeValue($row['value'] ?? null); + @endphp + + @if(! is_null($badgeValue)) + + {{ $badgeValue ? 'Enabled' : 'Disabled' }} @elseif(is_numeric($row['value'])) {{ $row['value'] }} @@ -65,7 +139,11 @@ {{-- Settings Blocks (for OMA Settings, Key/Value pairs, etc.) --}} @foreach($settings as $block) - @if($block['type'] === 'table') + @php + $blockType = is_array($block) ? ($block['type'] ?? null) : null; + @endphp + + @if($blockType === 'table') @foreach($block['rows'] ?? [] as $row)
-
+
{{ $row['label'] ?? $row['path'] ?? 'Setting' }} @if(!empty($row['description']))

{{ Str::limit($row['description'], 80) }}

@endif
- @if(is_bool($row['value'])) - - {{ $row['value'] ? 'Enabled' : 'Disabled' }} + @php + $badgeValue = $asEnabledDisabledBadgeValue($row['value'] ?? null); + @endphp + + @if(! is_null($badgeValue)) + + {{ $badgeValue ? 'Enabled' : 'Disabled' }} @elseif(is_numeric($row['value'])) {{ $row['value'] }} + @elseif($shouldRenderBadges($row['value'] ?? null)) +
+ @foreach(($row['value'] ?? []) as $item) + + {{ is_bool($item) ? ($item ? 'Enabled' : 'Disabled') : (string) $item }} + + @endforeach +
@else - {{ Str::limit($row['value'] ?? 'N/A', 200) }} + {{ Str::limit($stringifyValue($row['value'] ?? null), 200) }} @endif
@@ -105,7 +195,7 @@
- @elseif($block['type'] === 'keyValue') + @elseif($blockType === 'keyValue')
- - {{ Str::limit($entry['value'] ?? 'N/A', 200) }} - + @php + $rawValue = $entry['value'] ?? null; + + $isScriptContent = in_array($entry['key'] ?? null, ['scriptContent', 'detectionScriptContent', 'remediationScriptContent'], true) + && (bool) config('tenantpilot.display.show_script_content', false); + + $badgeValue = $asEnabledDisabledBadgeValue($rawValue); + @endphp + + @if($isScriptContent) + @php + $code = is_string($rawValue) ? $rawValue : $stringifyValue($rawValue); + $firstLine = strtok($code, "\n") ?: ''; + + $grammar = 'powershell'; + + if ($policyType === 'deviceShellScript') { + $shebang = trim($firstLine); + + if (str_starts_with($shebang, '#!')) { + if (str_contains($shebang, 'zsh')) { + $grammar = 'zsh'; + } elseif (str_contains($shebang, 'bash')) { + $grammar = 'bash'; + } else { + $grammar = 'sh'; + } + } else { + $grammar = 'sh'; + } + } elseif ($policyType === 'deviceManagementScript' || $policyType === 'deviceHealthScript') { + $grammar = 'powershell'; + } + + $highlightedHtml = null; + + if (class_exists(\Torchlight\Engine\Engine::class)) { + try { + $highlightedHtml = (new \Torchlight\Engine\Engine())->codeToHtml( + code: $code, + grammar: $grammar, + theme: [ + 'light' => 'github-light', + 'dark' => 'github-dark', + ], + withGutter: false, + withWrapper: true, + ); + } catch (\Throwable $e) { + $highlightedHtml = null; + } + } + @endphp + +
+
+ + Show + Hide + + + + {{ number_format(Str::length($code)) }} chars + +
+ +
+ @if (is_string($highlightedHtml) && $highlightedHtml !== '') + @once + @include('filament.partials.torchlight-dark-overrides') + @endonce + +
{!! $highlightedHtml !!}
+ @else +
{{ $code }}
+ @endif +
+
+ @elseif($shouldRenderBadges($rawValue)) +
+ @foreach(($rawValue ?? []) as $item) + + {{ is_bool($item) ? ($item ? 'Enabled' : 'Disabled') : (string) $item }} + + @endforeach +
+ @elseif(! is_null($badgeValue)) + + {{ $badgeValue ? 'Enabled' : 'Disabled' }} + + @else + + {{ Str::limit($stringifyValue($rawValue), 200) }} + + @endif
@endforeach diff --git a/resources/views/filament/infolists/entries/restore-results.blade.php b/resources/views/filament/infolists/entries/restore-results.blade.php index 38a6ce4..0c9e694 100644 --- a/resources/views/filament/infolists/entries/restore-results.blade.php +++ b/resources/views/filament/infolists/entries/restore-results.blade.php @@ -268,10 +268,16 @@ @if (! empty($item['graph_error_code']))
Code: {{ $item['graph_error_code'] }}
@endif - @if (! empty($item['graph_request_id']) || ! empty($item['graph_client_request_id'])) + @if (! empty($item['graph_request_id']) || ! empty($item['graph_client_request_id']) || ! empty($item['graph_method']) || ! empty($item['graph_path']))
Details
+ @if (! empty($item['graph_method'])) +
method: {{ $item['graph_method'] }}
+ @endif + @if (! empty($item['graph_path'])) +
path: {{ $item['graph_path'] }}
+ @endif @if (! empty($item['graph_request_id']))
request-id: {{ $item['graph_request_id'] }}
@endif diff --git a/resources/views/filament/modals/backup-schedule-run-view.blade.php b/resources/views/filament/modals/backup-schedule-run-view.blade.php new file mode 100644 index 0000000..e1fa38c --- /dev/null +++ b/resources/views/filament/modals/backup-schedule-run-view.blade.php @@ -0,0 +1,48 @@ + +
+
+
+
Scheduled for
+
{{ optional($run->scheduled_for)->toDateTimeString() ?? '—' }}
+
+
+
Status
+
{{ $run->status ?? '—' }}
+
+
+ +
+
+
Started at
+
{{ optional($run->started_at)->toDateTimeString() ?? '—' }}
+
+
+
Finished at
+
{{ optional($run->finished_at)->toDateTimeString() ?? '—' }}
+
+
+ +
+
+
Error code
+
{{ $run->error_code ?: '—' }}
+
+
+
Backup set
+
{{ $run->backup_set_id ?: '—' }}
+
+
+ +
+
Error message
+
{{ $run->error_message ?: '—' }}
+
+ +
+
Summary
+
+
{{ json_encode($run->summary ?? [], JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES) }}
+
+
+
+
diff --git a/resources/views/filament/modals/backup-set-policy-picker.blade.php b/resources/views/filament/modals/backup-set-policy-picker.blade.php new file mode 100644 index 0000000..8d5502f --- /dev/null +++ b/resources/views/filament/modals/backup-set-policy-picker.blade.php @@ -0,0 +1,3 @@ +
+ +
diff --git a/resources/views/filament/partials/torchlight-dark-overrides.blade.php b/resources/views/filament/partials/torchlight-dark-overrides.blade.php new file mode 100644 index 0000000..e93868a --- /dev/null +++ b/resources/views/filament/partials/torchlight-dark-overrides.blade.php @@ -0,0 +1,13 @@ + diff --git a/resources/views/livewire/backup-set-policy-picker-table.blade.php b/resources/views/livewire/backup-set-policy-picker-table.blade.php new file mode 100644 index 0000000..2c8b681 --- /dev/null +++ b/resources/views/livewire/backup-set-policy-picker-table.blade.php @@ -0,0 +1,20 @@ +
+
+ + + + + +
+ + {{ $this->table }} +
diff --git a/resources/views/livewire/bulk-operation-progress.blade.php b/resources/views/livewire/bulk-operation-progress.blade.php index c254211..faf75fb 100644 --- a/resources/views/livewire/bulk-operation-progress.blade.php +++ b/resources/views/livewire/bulk-operation-progress.blade.php @@ -13,12 +13,13 @@

@if($run->status === 'pending') + @php($isStalePending = $run->created_at->lt(now()->subSeconds(30))) - Starting... + {{ $isStalePending ? 'Queued…' : 'Starting...' }} @elseif($run->status === 'running') @@ -28,6 +29,10 @@ Processing... + @elseif(in_array($run->status, ['completed', 'completed_with_errors'], true)) + Done + @elseif(in_array($run->status, ['failed', 'aborted'], true)) + Failed @endif

diff --git a/routes/console.php b/routes/console.php index 3c9adf1..f2ce44a 100644 --- a/routes/console.php +++ b/routes/console.php @@ -2,7 +2,10 @@ use Illuminate\Foundation\Inspiring; use Illuminate\Support\Facades\Artisan; +use Illuminate\Support\Facades\Schedule; Artisan::command('inspire', function () { $this->comment(Inspiring::quote()); })->purpose('Display an inspiring quote'); + +Schedule::command('tenantpilot:schedules:dispatch')->everyMinute(); diff --git a/specs/012-windows-update-rings/plan.md b/specs/012-windows-update-rings/plan.md index 5520cb5..624d738 100644 --- a/specs/012-windows-update-rings/plan.md +++ b/specs/012-windows-update-rings/plan.md @@ -7,9 +7,12 @@ # Implementation Plan: Windows Update Rings (012) ## Summary Make `windowsUpdateRing` snapshots/restores accurate by correctly capturing and applying its settings, and present a readable normalized view in Filament. +Also add coverage for Windows Feature Update Profiles (`windowsFeatureUpdateProfile`) and Windows Quality Update Profiles (`windowsQualityUpdateProfile`) so they can be synced, snapshotted, restored, and displayed in a readable normalized format. + ## Execution Steps 1. **Graph contract verification**: Ensure `config/graph_contracts.php` entry for `windowsUpdateRing` is correct and complete. 2. **Snapshot capture hydration**: Extend `PolicySnapshotService` to correctly hydrate `windowsUpdateForBusinessConfiguration` settings into the policy payload. 3. **Restore**: Extend `RestoreService` to apply `windowsUpdateRing` settings from a snapshot to the target policy in Intune. 4. **UI normalization**: Add a dedicated normalizer for `windowsUpdateRing` that renders configured settings as readable rows in the Filament UI. -5. **Tests + formatting**: Add targeted Pest tests for snapshot hydration, normalized display, and restore functionality. Run `./vendor/bin/pint --dirty` and the affected tests. +5. **Feature/Quality Update Profiles**: Add Graph contract + supported types, and normalizers for `windowsFeatureUpdateProfile` and `windowsQualityUpdateProfile`. +6. **Tests + formatting**: Add targeted Pest tests for sync filters/types, snapshot/normalized display (as applicable), and restore payload sanitization. Run `./vendor/bin/pint --dirty` and the affected tests. diff --git a/specs/012-windows-update-rings/spec.md b/specs/012-windows-update-rings/spec.md index 5df781e..1f05b92 100644 --- a/specs/012-windows-update-rings/spec.md +++ b/specs/012-windows-update-rings/spec.md @@ -8,6 +8,10 @@ # Feature Specification: Windows Update Rings (012) ## Overview Add reliable coverage for **Windows Update Rings** (`windowsUpdateRing`) in the existing inventory/backup/version/restore flows. +This feature also extends coverage to **Windows Feature Update Profiles** ("Feature Updates"), which are managed under the `deviceManagement/windowsFeatureUpdateProfiles` endpoint and have `@odata.type` `#microsoft.graph.windowsFeatureUpdateProfile`. + +This feature also extends coverage to **Windows Quality Update Profiles** ("Quality Updates"), which are managed under the `deviceManagement/windowsQualityUpdateProfiles` endpoint and have `@odata.type` `#microsoft.graph.windowsQualityUpdateProfile`. + This policy type is defined in `graph_contracts.php` and uses the `deviceManagement/deviceConfigurations` endpoint, identified by the `@odata.type` `#microsoft.graph.windowsUpdateForBusinessConfiguration`. This feature will focus on implementing the necessary UI normalization and ensuring the sync, backup, versioning, and restore flows function correctly for this policy type. ## In Scope @@ -17,6 +21,18 @@ ## In Scope - Restore: Restore a Windows Update Ring policy from a snapshot. - UI: Display the settings of a Windows Update Ring policy in a readable, normalized format. +- Policy type: `windowsFeatureUpdateProfile` +- Sync: Feature Update Profiles should be listed and synced from `deviceManagement/windowsFeatureUpdateProfiles`. +- Snapshot capture: Full snapshot of the Feature Update Profile payload. +- Restore: Restore a Feature Update Profile from a snapshot. +- UI: Display the key settings of a Feature Update Profile in a readable, normalized format. + +- Policy type: `windowsQualityUpdateProfile` +- Sync: Quality Update Profiles should be listed and synced from `deviceManagement/windowsQualityUpdateProfiles`. +- Snapshot capture: Full snapshot of the Quality Update Profile payload. +- Restore: Restore a Quality Update Profile from a snapshot. +- UI: Display the key settings of a Quality Update Profile in a readable, normalized format. + ## Out of Scope (v1) - Advanced analytics or reporting on update compliance. - Per-setting partial restore. @@ -43,3 +59,19 @@ ### User Story 3 — Restore settings **Acceptance** 1. The restore operation correctly applies the settings from the snapshot to the target policy in Intune. 2. The restore process is audited. + +### User Story 4 — Feature Updates inventory + readable view +As an admin, I can see my Windows Feature Update Profiles in the policy list and view their configured rollout/version settings in a clear, understandable format. + +**Acceptance** +1. Feature Update Profiles are listed in the main policy table with the correct type name. +2. The policy detail view shows a structured list/table of configured settings (e.g., feature update version, rollout window). +3. Policy Versions store the snapshot and render the settings in the “Normalized settings” view. + +### User Story 5 — Quality Updates inventory + readable view +As an admin, I can see my Windows Quality Update Profiles in the policy list and view their configured release/content settings in a clear, understandable format. + +**Acceptance** +1. Quality Update Profiles are listed in the main policy table with the correct type name. +2. The policy detail view shows a structured list/table of configured settings (e.g., release, deployable content). +3. Policy Versions store the snapshot and render the settings in the “Normalized settings” view. diff --git a/specs/012-windows-update-rings/tasks.md b/specs/012-windows-update-rings/tasks.md index 96272e9..ed5d9a6 100644 --- a/specs/012-windows-update-rings/tasks.md +++ b/specs/012-windows-update-rings/tasks.md @@ -4,20 +4,23 @@ # Tasks: Windows Update Rings (012) **Input**: [spec.md](./spec.md), [plan.md](./plan.md) ## Phase 1: Contracts + Snapshot Hydration -- [ ] T001 Verify `config/graph_contracts.php` for `windowsUpdateRing` (resource, allowed_select, type_family, etc.). -- [ ] T002 Extend `PolicySnapshotService` to hydrate `windowsUpdateForBusinessConfiguration` settings. +- [X] T001 Verify `config/graph_contracts.php` for `windowsUpdateRing` (resource, allowed_select, type_family, etc.). +- [X] T002 Extend `PolicySnapshotService` to hydrate `windowsUpdateForBusinessConfiguration` settings. +- [X] T001b Fix Graph filters for update rings (`isof(...)`) and add `windowsFeatureUpdateProfile` support. ## Phase 2: Restore -- [ ] T003 Implement restore apply for `windowsUpdateRing` settings in `RestoreService.php`. +- [X] T003 Implement restore apply for `windowsUpdateRing` settings in `RestoreService.php`. ## Phase 3: UI Normalization -- [ ] T004 Add `WindowsUpdateRingNormalizer` and register it (Policy “Normalized settings” is readable). +- [X] T004 Add `WindowsUpdateRingNormalizer` and register it (Policy “Normalized settings” is readable). +- [X] T004b Add `WindowsFeatureUpdateProfileNormalizer` and register it (Policy “Normalized settings” is readable). +- [X] T004c Add `WindowsQualityUpdateProfileNormalizer` and register it (Policy “Normalized settings” is readable). ## Phase 4: Tests + Verification -- [ ] T005 Add tests for hydration + UI display. -- [ ] T006 Add tests for restore apply. -- [ ] T007 Run tests (targeted). -- [ ] T008 Run Pint (`./vendor/bin/pint --dirty`). +- [X] T005 Add tests for sync filters + supported types. +- [X] T006 Add tests for restore apply. +- [X] T007 Run tests (targeted). +- [X] T008 Run Pint (`./vendor/bin/pint --dirty`). ## Open TODOs (Follow-up) - None yet. diff --git a/specs/013-scripts-management/checklists/requirements.md b/specs/013-scripts-management/checklists/requirements.md new file mode 100644 index 0000000..89849c9 --- /dev/null +++ b/specs/013-scripts-management/checklists/requirements.md @@ -0,0 +1,34 @@ +# Specification Quality Checklist: Scripts Management + +**Purpose**: Validate specification completeness and quality before proceeding to planning +**Created**: 2026-01-01 +**Feature**: [spec.md](../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 + +- Assumptions: Supported script policy types are already discoverable in the product, and restore/assignments follow existing system patterns. diff --git a/specs/013-scripts-management/plan.md b/specs/013-scripts-management/plan.md new file mode 100644 index 0000000..57970df --- /dev/null +++ b/specs/013-scripts-management/plan.md @@ -0,0 +1,42 @@ +# Plan: Scripts Management (013) + +**Branch**: `013-scripts-management` +**Date**: 2026-01-01 +**Input**: [spec.md](./spec.md) + +## Goal +Provide end-to-end support for script policies (PowerShell scripts, macOS shell scripts, and proactive remediations) with readable normalized settings and safe restore behavior including assignments. + +## Scope + +### In scope +- Script policy types: + - `deviceManagementScript` + - `deviceShellScript` + - `deviceHealthScript` +- Readable “Normalized settings” output for the above types. +- Restore apply safety is preserved (type mismatch fails; preview vs execute follows existing system behavior). +- Assignment restore is supported (using existing assignment restore mechanisms and contract metadata). + +### Out of scope +- Adding new UI flows or pages. +- Introducing new external services or background infrastructure. +- Changing how authentication/authorization works. + +## Approach +1. Confirm contract entries exist and are correct for the three script policy types (resource, type families, assignment paths/payload keys). +2. Add a policy normalizer that supports the three script policy types and outputs a stable, readable structure. +3. Register the normalizer in the application normalizer tag. +4. Add tests: + - Normalized output shape/stability for each type. + - Filament “Normalized settings” tab renders without errors for a version of each type. +5. Run targeted tests and Pint. + +## Risks & Mitigations +- Scripts may contain large content blobs: normalized view must be readable and avoid overwhelming output (truncate or summarize where needed). +- Platform-specific fields vary: normalizer must handle missing keys safely and remain stable. + +## Success Criteria +- Normalized settings views are readable and stable for all three script policy types. +- Restore execution remains safe and assignment behavior is unchanged/regression-free. +- Tests cover the new normalizer behavior and basic UI render. diff --git a/specs/013-scripts-management/spec.md b/specs/013-scripts-management/spec.md new file mode 100644 index 0000000..b8446df --- /dev/null +++ b/specs/013-scripts-management/spec.md @@ -0,0 +1,112 @@ +# Feature Specification: Scripts Management + +**Feature Branch**: `013-scripts-management` +**Created**: 2026-01-01 +**Status**: Draft +**Input**: User description: "Add end-to-end support for management scripts (Windows PowerShell scripts, macOS shell scripts, and proactive remediations) including readable normalized settings, backup snapshots, and safe restore with assignments." + +## User Scenarios & Testing *(mandatory)* + + + +### User Story 1 - Restore a script safely (Priority: P1) + +As an admin, I want to restore a script policy from a saved snapshot so I can recover from accidental or unwanted changes. + +**Why this priority**: Restoring known-good configuration is the core safety value of the product. + +**Independent Test**: Can be fully tested by restoring one script policy into a tenant where the script is missing or changed, and verifying the script and its assignments match the snapshot. + +**Acceptance Scenarios**: + +1. **Given** a saved script snapshot and a target tenant where the script does not exist, **When** I run restore for that item, **Then** the system creates a new script policy from the snapshot and reports success. +2. **Given** a saved script snapshot and a target tenant where the script exists with differences, **When** I run restore for that item, **Then** the system updates the existing script policy to match the snapshot and reports success. +3. **Given** a saved script snapshot with assignments, **When** I run restore, **Then** the system applies the assignments using the snapshot data and reports assignment outcomes. + +--- + +### User Story 2 - Readable script configuration (Priority: P2) + +As an admin, I want to view a readable, normalized representation of a script policy so I can understand what it does and compare versions reliably. + +**Why this priority**: If admins cannot quickly understand changes, version history and restore become risky and slow. + +**Independent Test**: Can be tested by opening a script policy version page and confirming that normalized settings display key fields consistently across versions. + +**Acceptance Scenarios**: + +1. **Given** a script policy version, **When** I open the policy version details, **Then** I see a normalized settings view that is stable (same input yields same output ordering/shape). +2. **Given** two versions of the same script policy with changes, **When** I view their normalized settings, **Then** the differences are visible without reading raw JSON. + +--- + +### User Story 3 - Reliable backup capture (Priority: P3) + +As an admin, I want backups/version snapshots of script policies to be captured reliably so I can restore later with confidence. + +**Why this priority**: Restore is only as good as the snapshot quality. + +**Independent Test**: Can be tested by capturing a snapshot of each script policy type and validating it contains the expected configuration fields for that policy. + +**Acceptance Scenarios**: + +1. **Given** an existing script policy, **When** I capture a snapshot/backup, **Then** the saved snapshot contains the complete configuration needed to restore the script policy. + +--- + +[Add more user stories as needed, each with an assigned priority] + +### Edge Cases + +- Restoring a snapshot whose policy type does not match the target item (type mismatch) must fail clearly without making changes. +- Restoring when the snapshot contains fields that are not accepted by the target environment must result in a clear failure reason and no partial silent data loss. +- Assignments referencing groups or foundations that cannot be mapped must be reported as manual-required for those assignments. +- Script policies with very large or complex configuration should still render a readable normalized settings view (with safe truncation if needed). + +## Requirements *(mandatory)* + + + +### Functional Requirements + +- **FR-001**: System MUST support listing and viewing script policies for the supported script policy types. +- **FR-002**: System MUST allow capturing a snapshot of a script policy that is sufficient to restore the policy later. +- **FR-003**: System MUST allow restoring a script policy from a snapshot in a safe manner (create when missing; update when present). +- **FR-004**: System MUST support restoring assignments for script policies using the assignments saved with the snapshot. +- **FR-005**: System MUST present a readable normalized settings view for script policies and script policy versions. +- **FR-006**: System MUST prevent execution of restore if the snapshot policy type does not match the restore item type. +- **FR-007**: System MUST record an audit trail for restore preview and restore execution attempts. + +### Key Entities *(include if feature involves data)* + +- **Script Policy**: A configuration object representing a management script (platform-specific variants), identified by a stable external identifier and a display name. +- **Script Policy Snapshot**: An immutable capture of a script policy’s configuration at a point in time, used for diffing and restore. +- **Script Assignment**: A target association that applies a script policy to a defined scope (e.g., groups/filters), stored with the snapshot and restored with mapping when needed. + +## Success Criteria *(mandatory)* + + + +### Measurable Outcomes + +- **SC-001**: An admin can complete a restore preview for a single script policy in under 1 minute. +- **SC-002**: In a test tenant, restoring a script policy results in the target script policy and assignments matching the snapshot for 100% of supported script policy types. +- **SC-003**: Normalized settings for a script policy are readable and stable: repeated views of the same snapshot produce identical normalized output. +- **SC-004**: Restore failures provide a clear reason (actionable message) in 100% of failure cases. diff --git a/specs/013-scripts-management/tasks.md b/specs/013-scripts-management/tasks.md new file mode 100644 index 0000000..92d99ee --- /dev/null +++ b/specs/013-scripts-management/tasks.md @@ -0,0 +1,28 @@ +# Tasks: Scripts Management (013) + +**Branch**: `013-scripts-management` | **Date**: 2026-01-01 +**Input**: [spec.md](./spec.md), [plan.md](./plan.md) + +## Phase 1: Contracts Review +- [x] T001 Verify `config/graph_contracts.php` entries for `deviceManagementScript`, `deviceShellScript`, `deviceHealthScript` (resource, type_family, assignment payload key). + +## Phase 2: UI Normalization +- [x] T002 Add a `ScriptsPolicyNormalizer` (or equivalent) to produce readable normalized settings for the three script policy types. +- [x] T003 Register the normalizer in `AppServiceProvider`. + +## Phase 3: Tests + Verification +- [x] T004 Add tests for normalized output (shape + stability) for each script policy type. +- [x] T005 Add Filament render tests for “Normalized settings” tab for each script policy type. +- [x] T006 Run targeted tests. +- [x] T007 Run Pint (`./vendor/bin/pint --dirty`). + +## Phase 4: Script Content Display (Safe) +- [x] T008 Add opt-in display + base64 decoding for `scriptContent` in normalized settings. +- [x] T009 Highlight script content with Torch (shebang-based shell + PowerShell default). +- [x] T010 Hide script content behind a Show/Hide button (collapsed by default). +- [x] T011 Highlight script content in Normalized Diff view (From/To). +- [x] T012 Enable Torchlight highlighting in Diff + Before/After views. +- [x] T013 Add “Fullscreen” overlay for script diffs (scroll sync). + +## Open TODOs (Follow-up) +- None yet. diff --git a/specs/014-enrollment-autopilot/checklists/requirements.md b/specs/014-enrollment-autopilot/checklists/requirements.md new file mode 100644 index 0000000..d73ba6c --- /dev/null +++ b/specs/014-enrollment-autopilot/checklists/requirements.md @@ -0,0 +1,34 @@ +# Specification Quality Checklist: Enrollment & Autopilot + +**Purpose**: Validate specification completeness and quality before proceeding to planning +**Created**: 2026-01-01 +**Feature**: [spec.md](../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 + +- Assumptions: Restore behavior for enrollment restrictions remains preview-only until a separate product decision explicitly enables it. diff --git a/specs/014-enrollment-autopilot/plan.md b/specs/014-enrollment-autopilot/plan.md new file mode 100644 index 0000000..a283ad3 --- /dev/null +++ b/specs/014-enrollment-autopilot/plan.md @@ -0,0 +1,48 @@ +# Plan: Enrollment & Autopilot (014) + +**Branch**: `014-enrollment-autopilot` +**Date**: 2026-01-01 +**Input**: [spec.md](./spec.md) + +## Goal +Provide end-to-end support for enrollment & Autopilot configuration items with readable normalized settings and safe restore behavior. + +## Scope + +### In scope +- Policy types: + - `windowsAutopilotDeploymentProfile` (restore enabled) + - `windowsEnrollmentStatusPage` (restore enabled) + - `enrollmentRestriction` (restore preview-only) +- Readable “Normalized settings” for the above types. +- Restore behavior: + - Autopilot/ESP: apply via existing restore mechanisms (create-if-missing allowed) + - Enrollment restrictions: must be skipped on execution by default (preview-only) +- Tests for normalization + UI rendering + preview-only enforcement. + +### Out of scope +- New restore wizard flows/pages. +- Enabling execution for enrollment restrictions (requires product decision). +- New external services. + +## Approach +1. Verify `config/graph_contracts.php` and `config/tenantpilot.php` entries for the three policy types. +2. Implement a new policy type normalizer to provide stable, enrollment-relevant blocks for: + - Autopilot deployment profiles + - Enrollment Status Page + - Enrollment restrictions +3. Register the normalizer with the `policy-type-normalizers` tag. +4. Add tests: + - Unit tests for normalized output stability/shape. + - Filament feature tests verifying “Normalized settings” renders for each type. + - Feature test verifying `enrollmentRestriction` restore is preview-only and skipped on execution. +5. Run targeted tests and Pint. + +## Risks & Mitigations +- Payload shape variance across tenants: normalizer must handle missing keys safely. +- Enrollment restrictions are high impact: execution must remain disabled by default (preview-only). + +## Success Criteria +- Normalized settings are stable and readable for all in-scope types. +- Restore execution skips preview-only types and reports clear result reasons. +- Tests cover normalization and preview-only enforcement. diff --git a/specs/014-enrollment-autopilot/spec.md b/specs/014-enrollment-autopilot/spec.md new file mode 100644 index 0000000..edef695 --- /dev/null +++ b/specs/014-enrollment-autopilot/spec.md @@ -0,0 +1,111 @@ +# Feature Specification: Enrollment & Autopilot + +**Feature Branch**: `014-enrollment-autopilot` +**Created**: 2026-01-01 +**Status**: Draft +**Input**: User description: "Improve enrollment and Autopilot configuration safety by adding readable normalized settings, reliable snapshot capture, and safe restore behavior for enrollment restrictions, enrollment status page, and Autopilot deployment profiles." + +## User Scenarios & Testing *(mandatory)* + + + +### User Story 1 - Restore Autopilot/ESP safely (Priority: P1) + +As an admin, I want to restore Autopilot deployment profiles and the Enrollment Status Page configuration from saved snapshots so I can recover enrollment readiness after changes. + +**Why this priority**: Enrollment misconfiguration blocks device onboarding; fast recovery is critical. + +**Independent Test**: Can be tested by restoring one Autopilot profile and one Enrollment Status Page item from snapshots into a target tenant and verifying they match the snapshot. + +**Acceptance Scenarios**: + +1. **Given** a saved Autopilot deployment profile snapshot and a target tenant where the profile is missing, **When** I restore it, **Then** a new profile is created and restore reports success. +2. **Given** a saved Enrollment Status Page snapshot and a target tenant where the item exists with differences, **When** I restore it, **Then** the configuration is updated to match the snapshot and restore reports success. + +--- + +### User Story 2 - Restore behavior is explicit for high-risk enrollment restrictions (Priority: P2) + +As an admin, I want high-risk enrollment restrictions to be handled explicitly (preview-only unless intentionally enabled) so I do not accidentally break enrollment flows. + +**Why this priority**: Enrollment restrictions can lock out device onboarding; accidental changes are high impact. + +**Independent Test**: Can be tested by attempting restore of an enrollment restriction item and verifying the system does not apply changes when it is configured as preview-only. + +**Acceptance Scenarios**: + +1. **Given** an enrollment restriction snapshot and the feature is allowed for preview-only, **When** I run restore execution, **Then** the system skips applying changes and records a result indicating preview-only behavior. + +--- + +### User Story 3 - Readable normalized settings (Priority: P3) + +As an admin, I want to view readable normalized settings for Autopilot and Enrollment configurations so I can understand what will happen during device onboarding. + +**Why this priority**: Enrollment troubleshooting is faster when key settings are visible and consistent. + +**Independent Test**: Can be tested by opening a version details page and confirming a stable normalized settings view is present and readable. + +**Acceptance Scenarios**: + +1. **Given** a saved Autopilot/ESP snapshot, **When** I view the policy version, **Then** I see a normalized settings view that highlights key enrollment-relevant fields. + +--- + +[Add more user stories as needed, each with an assigned priority] + +### Edge Cases + +- Autopilot or ESP configuration in the target tenant is missing: system must create or clearly fail with an actionable reason. +- Restoring Enrollment Status Page items must not silently drop settings; failures must be explicit. +- Enrollment restrictions remain preview-only unless explicitly enabled by product decision; execution must not apply them by default. +- Assignments (if present for these types) that cannot be mapped must be reported as manual-required. + +## Requirements *(mandatory)* + + + +### Functional Requirements + +- **FR-001**: System MUST support listing and viewing enrollment and Autopilot configuration items for the supported types. +- **FR-002**: System MUST capture snapshots for these configuration items that are sufficient for later restore. +- **FR-003**: System MUST support restore for Autopilot deployment profiles and Enrollment Status Page configuration. +- **FR-004**: System MUST treat enrollment restrictions as high risk and default them to preview-only behavior unless explicitly enabled. +- **FR-005**: System MUST present a readable normalized settings view for these configuration items and their versions. +- **FR-006**: System MUST prevent restore execution if the snapshot type does not match the target item type. +- **FR-007**: System MUST record audit entries for restore preview and restore execution attempts. + +### Key Entities *(include if feature involves data)* + +- **Autopilot Deployment Profile**: A configuration object that defines device provisioning behavior during Autopilot. +- **Enrollment Status Page Configuration**: A configuration object that defines the onboarding status experience during enrollment. +- **Enrollment Restriction**: A high-risk configuration object that can block or constrain enrollment. +- **Snapshot**: An immutable capture of a configuration object at a point in time. + +## Success Criteria *(mandatory)* + + + +### Measurable Outcomes + +- **SC-001**: An admin can complete a restore preview for a single Autopilot/ESP item in under 1 minute. +- **SC-002**: In a test tenant, restoring Autopilot deployment profiles and Enrollment Status Page results in configurations matching the snapshot for 100% of supported items. +- **SC-003**: Enrollment restrictions remain non-executable by default (preview-only) with clear status reporting in 100% of attempts. +- **SC-004**: Normalized settings views for these items are stable and readable (same snapshot yields identical normalized output). diff --git a/specs/014-enrollment-autopilot/tasks.md b/specs/014-enrollment-autopilot/tasks.md new file mode 100644 index 0000000..0d507a1 --- /dev/null +++ b/specs/014-enrollment-autopilot/tasks.md @@ -0,0 +1,33 @@ +# Tasks: Enrollment & Autopilot (014) + +**Branch**: `014-enrollment-autopilot` | **Date**: 2026-01-01 +**Input**: [spec.md](./spec.md), [plan.md](./plan.md) + +## Phase 1: Contracts Review +- [x] T001 Verify `config/graph_contracts.php` entries for: + - `windowsAutopilotDeploymentProfile` + - `windowsEnrollmentStatusPage` + - `enrollmentRestriction` + (resource, type_family, create/update methods, assignment paths/payload keys) +- [x] T002 Verify `config/tenantpilot.php` entries and restore modes: + - Autopilot/ESP = `enabled` + - Enrollment restrictions = `preview-only` + +## Phase 2: UI Normalization +- [x] T003 Add an `EnrollmentAutopilotPolicyNormalizer` (or equivalent) that produces readable normalized settings for the three policy types. +- [x] T004 Register the normalizer in the app container/provider (tag `policy-type-normalizers`). + +## Phase 3: Restore Safety +- [x] T005 Add a feature test verifying `enrollmentRestriction` restore is preview-only and skipped on execution (no Graph apply calls). + +## Phase 3b: Enrollment Configuration Type Collisions +- [x] T005b Fix ESP vs enrollment restriction collision on `deviceEnrollmentConfigurations` sync (canonical type resolution + safe reclassification). + +## Phase 4: Tests + Verification +- [x] T006 Add unit tests for normalized output (shape + stability) for the three policy types. +- [x] T007 Add Filament render tests for “Normalized settings” tab for the three policy types. +- [x] T008 Run targeted tests. +- [x] T009 Run Pint (`./vendor/bin/pint --dirty`). + +## Open TODOs (Follow-up) +- None. diff --git a/specs/015-policy-picker-ux/checklists/requirements.md b/specs/015-policy-picker-ux/checklists/requirements.md new file mode 100644 index 0000000..5281408 --- /dev/null +++ b/specs/015-policy-picker-ux/checklists/requirements.md @@ -0,0 +1,30 @@ +# Specification Quality Checklist: Policy Picker UX + +**Purpose**: Validate specification completeness and quality before proceeding to planning +**Created**: 2026-01-02 +**Feature**: [spec.md](../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 diff --git a/specs/015-policy-picker-ux/plan.md b/specs/015-policy-picker-ux/plan.md new file mode 100644 index 0000000..72ee802 --- /dev/null +++ b/specs/015-policy-picker-ux/plan.md @@ -0,0 +1,32 @@ +# Plan: Policy Picker UX (015) + +**Branch**: `015-policy-picker-ux` +**Date**: 2026-01-02 +**Input**: [spec.md](./spec.md) + +## Goal +Improve the “Add Policies” picker UX by making option labels self-describing (type/platform/external id) to reduce mistakes with duplicate policy names. + +## Scope + +### In scope +- Update the “Add Policies” action in the Backup Set items relation manager. +- Present the picker as a modal table (row selection). +- Table shows: display name, policy type (human label if available), platform, short external id. +- Filters: policy type, platform, last synced, ignored, has versions. +- “Select all” selects the current filtered results. +- Add a unit/feature test covering the label formatting. + +### Out of scope +- Adding filters, select-all, new pages, or additional UI flows. + +## Approach +1. Replace the Select-based picker with a Livewire/Filament table component rendered inside the action modal. +2. Add the required filters and columns. +3. Implement a bulk action to add selected policies to the backup set. +4. Add tests asserting the picker table bulk action works and filters are available. +4. Run targeted tests and Pint. + +## Success Criteria +- Picker options are clearly distinguishable for policies with duplicate names. +- Tests are green. diff --git a/specs/015-policy-picker-ux/spec.md b/specs/015-policy-picker-ux/spec.md new file mode 100644 index 0000000..2a62264 --- /dev/null +++ b/specs/015-policy-picker-ux/spec.md @@ -0,0 +1,37 @@ +# Feature Specification: Policy Picker UX (015) + +**Feature Branch**: `015-policy-picker-ux` +**Created**: 2026-01-02 +**Status**: Draft + +## User Scenarios & Testing + +### User Story 1 — Disambiguate duplicate policy names (Priority: P1) + +As an admin, I want policy options in the “Add Policies” picker to be clearly distinguishable, so I can confidently select the correct policy even when multiple policies share the same display name. + +**Acceptance Scenarios** +1. Given multiple policies with the same display name, when I open the “Add Policies” picker, then each option shows additional identifiers (type, platform, short external id). +2. Given a policy option, when I search in the picker, then results remain searchable by display name. + +### User Story 2 — Add policies efficiently (Priority: P1) + +As an admin, I want to browse and select policies in a table with filters and multi-select, so I can add the right set of policies without repetitive searching. + +**Acceptance Scenarios** +1. When I open the “Add Policies” picker, then I see a table with policy rows and selectable checkboxes. +2. When I filter by policy type / platform / last synced / ignored / has versions, then only matching policies are shown. +3. When I click “select all”, then only the currently filtered results are selected. + +## Requirements + +### Functional Requirements +- **FR-001**: The “Add Policies” picker MUST be presented as a table inside the modal. +- **FR-002**: Each policy row MUST show: display name, policy type, platform, and a short external id. +- **FR-003**: The picker MUST support multi-select. +- **FR-004**: The picker MUST provide filtering for: policy type, platform, last synced, ignored, and has versions. +- **FR-005**: The picker MUST support “select all” for the currently filtered results (not all policies in the tenant). + +## Success Criteria +- **SC-001**: In tenants with duplicate policy names, admins can identify the correct policy from the picker without trial-and-error. +- **SC-002**: Admins can add large sets of policies efficiently using filters + multi-select. diff --git a/specs/015-policy-picker-ux/tasks.md b/specs/015-policy-picker-ux/tasks.md new file mode 100644 index 0000000..7f888ed --- /dev/null +++ b/specs/015-policy-picker-ux/tasks.md @@ -0,0 +1,24 @@ +# Tasks: Policy Picker UX (015) + +**Branch**: `015-policy-picker-ux` | **Date**: 2026-01-02 +**Input**: [spec.md](./spec.md), [plan.md](./plan.md) + +## Phase 1: Setup +- [X] T001 Create spec/plan/tasks and checklist. + +## Phase 2: Core +- [X] T002 Update “Add Policies” picker option labels to include type/platform/short external id. +- [X] T006 Replace picker with a modal table (multi-select). +- [X] T007 Add filters: policy type, platform, last synced, ignored, has versions. +- [X] T008 Implement “select all” for filtered results (via Filament table selection). +- [X] T012 Group row actions (View/Remove) in backup items table. +- [X] T013 Add bulk remove action for backup items. + +## Phase 3: Tests + Verification +- [X] T003 Add test coverage for policy picker option labels. +- [X] T004 Run targeted tests. +- [X] T005 Run Pint (`./vendor/bin/pint --dirty`). +- [X] T009 Update/add tests for table picker bulk add. +- [X] T010 Run targeted tests. +- [X] T011 Run Pint (`./vendor/bin/pint --dirty`). +- [X] T014 Add test coverage for bulk remove. diff --git a/specs/016-backup-version-reuse/checklists/requirements.md b/specs/016-backup-version-reuse/checklists/requirements.md new file mode 100644 index 0000000..9452bf7 --- /dev/null +++ b/specs/016-backup-version-reuse/checklists/requirements.md @@ -0,0 +1,9 @@ +# Specification Quality Checklist: Backup Version Reuse + +**Created**: 2026-01-02 +**Feature**: [spec.md](../spec.md) + +- [x] User story and acceptance scenarios defined +- [x] Requirements are testable and unambiguous +- [x] Scope bounded +- [x] No implementation details required by spec diff --git a/specs/016-backup-version-reuse/plan.md b/specs/016-backup-version-reuse/plan.md new file mode 100644 index 0000000..e123e5b --- /dev/null +++ b/specs/016-backup-version-reuse/plan.md @@ -0,0 +1,18 @@ +# Plan: Backup Version Reuse (016) + +**Branch**: `016-backup-version-reuse` +**Date**: 2026-01-02 +**Input**: [spec.md](./spec.md) + +## Goal +Reduce unnecessary `PolicyVersion` creation when policies are added to backup sets by reusing an existing suitable latest version where safe. + +## Approach +1. Always capture from Intune when a policy is added to a backup set (admin expectation: "backup = current state"). +2. Rely on `PolicyCaptureOrchestrator` snapshot-hash reuse to avoid redundant `PolicyVersion` creation when nothing changed. +3. Still respect capture options (assignments / scope tags) via orchestrator backfill behavior. +4. Add tests for both reuse and capture paths. + +## Out of scope +- UI toggles/config flags unless required. +- Cross-policy dedup or historical compaction. diff --git a/specs/016-backup-version-reuse/spec.md b/specs/016-backup-version-reuse/spec.md new file mode 100644 index 0000000..35756a4 --- /dev/null +++ b/specs/016-backup-version-reuse/spec.md @@ -0,0 +1,29 @@ +# Feature Specification: Backup Version Reuse (016) + +**Feature Branch**: `016-backup-version-reuse` +**Created**: 2026-01-02 +**Status**: Draft + +## User Scenarios & Testing + +### User Story 1 — Avoid unnecessary version growth (Priority: P1) +As an admin, I want adding policies to a backup set to reuse an existing recent policy version when safe, so backups don’t create redundant versions and operations stay fast. + +**Acceptance Scenarios** +1. Given a policy already has an identical captured snapshot, when I add it to a backup set, then the backup item links to the existing version (no new version is created). +2. Given a policy has no suitable version, when I add it to a backup set, then a new version is captured and linked. + +## Requirements + +### Functional Requirements +- **FR-001**: Adding policies to a backup set SHOULD avoid creating redundant `PolicyVersion` records by reusing an existing version when the captured snapshot is unchanged. +- **FR-002**: If reuse is not safe/possible, the system MUST capture a new `PolicyVersion` as it does today. +- **FR-003**: Reuse MUST respect capture options: + - If assignments are requested, the reused version must include assignments. + - If scope tags are requested, the reused version must include scope tags. +- **FR-005**: Adding a policy to a backup set MUST capture from Intune to ensure the backup reflects the current state. +- **FR-004**: Behavior changes MUST be covered by automated tests. + +## Success Criteria +- **SC-001**: Backups avoid creating redundant policy versions in the common case. +- **SC-002**: Backup correctness is preserved (no missing required data for restore/preview). diff --git a/specs/016-backup-version-reuse/tasks.md b/specs/016-backup-version-reuse/tasks.md new file mode 100644 index 0000000..8e85570 --- /dev/null +++ b/specs/016-backup-version-reuse/tasks.md @@ -0,0 +1,18 @@ +# Tasks: Backup Version Reuse (016) + +**Branch**: `016-backup-version-reuse` | **Date**: 2026-01-02 +**Input**: [spec.md](./spec.md), [plan.md](./plan.md) + +## Phase 1: Setup +- [X] T001 Create spec/plan/tasks and checklist. + +## Phase 2: Tests (TDD) +- [X] T002 Add tests for reusing an existing suitable PolicyVersion. +- [X] T003 Add tests for capturing a new PolicyVersion when reuse is not possible. + +## Phase 3: Core +- [X] T004 Implement reuse decision + reuse path in BackupService. + +## Phase 4: Verification +- [X] T005 Run targeted tests. +- [X] T006 Run Pint (`./vendor/bin/pint --dirty`). diff --git a/specs/017-policy-types-mam-endpoint-security-baselines/checklists/requirements.md b/specs/017-policy-types-mam-endpoint-security-baselines/checklists/requirements.md new file mode 100644 index 0000000..90a1936 --- /dev/null +++ b/specs/017-policy-types-mam-endpoint-security-baselines/checklists/requirements.md @@ -0,0 +1,7 @@ +# Requirements Checklist (017) + +- [x] Type keys and Graph resources confirmed for App Config Policies. +- [x] Type keys and Graph resources confirmed for Endpoint Security Policies. +- [x] Type keys and Graph resources confirmed for Security Baselines. +- [x] Restore mode decisions documented (enabled vs preview-only) per type. +- [x] Tests planned for sync + backup + preview. diff --git a/specs/017-policy-types-mam-endpoint-security-baselines/plan.md b/specs/017-policy-types-mam-endpoint-security-baselines/plan.md new file mode 100644 index 0000000..0f4d942 --- /dev/null +++ b/specs/017-policy-types-mam-endpoint-security-baselines/plan.md @@ -0,0 +1,41 @@ +# Plan: Policy Types (MAM App Config + Endpoint Security Policies + Security Baselines) (017) + +**Branch**: `feat/017-policy-types-mam-endpoint-security-baselines` +**Date**: 2026-01-02 +**Input**: [spec.md](./spec.md) + +## Approach +1. Inventory current supported types (config + graph contracts) and identify gaps. +2. Define new type keys and metadata in `config/tenantpilot.php`. +3. Add graph contracts in `config/graph_contracts.php` (resource, assigns, scope tags, create/update methods). +4. Extend snapshot/capture and restore services as needed (special casing only when required). +5. Add tests for: sync listing + backup capture + restore preview entry. + +## Decisions + +### Type keys + Graph resources +- `mamAppConfiguration` (MAM App Config) + - Graph collection: `deviceAppManagement/targetedManagedAppConfigurations` + - Primary `@odata.type`: `#microsoft.graph.targetedManagedAppConfiguration` +- `endpointSecurityPolicy` (Endpoint Security Policies) + - Graph collection: `deviceManagement/configurationPolicies` + - Primary `@odata.type`: `#microsoft.graph.deviceManagementConfigurationPolicy` + - Classification: configuration policies where the snapshot indicates Endpoint Security via `technologies` and/or `templateReference`. +- `securityBaselinePolicy` (Security Baselines) + - Graph collection: `deviceManagement/configurationPolicies` + - Primary `@odata.type`: `#microsoft.graph.deviceManagementConfigurationPolicy` + - Classification: configuration policies where the snapshot indicates a baseline via `templateReference` (template family/type). + +### Restore modes +- `mamAppConfiguration`: `enabled` (risk: medium-high) +- `endpointSecurityPolicy`: `preview-only` (risk: high) +- `securityBaselinePolicy`: `preview-only` (risk: high) + +### Test plan +- Sync: new types show up with correct labels and do not leak into `settingsCatalogPolicy` / `appProtectionPolicy`. +- Backup: items created and snapshots captured for each new type. +- Restore: at minimum, restore preview produces entries; execution remains blocked for preview-only types. + +## Notes +- Default restore mode for security-sensitive types should be conservative (preview-only) unless we already have safe restore semantics. +- Prefer using existing generic graph-contract-driven code paths. diff --git a/specs/017-policy-types-mam-endpoint-security-baselines/spec.md b/specs/017-policy-types-mam-endpoint-security-baselines/spec.md new file mode 100644 index 0000000..293037a --- /dev/null +++ b/specs/017-policy-types-mam-endpoint-security-baselines/spec.md @@ -0,0 +1,47 @@ +# Feature Specification: Policy Types (MAM App Config + Endpoint Security Policies + Security Baselines) (017) + +**Feature Branch**: `feat/017-policy-types-mam-endpoint-security-baselines` +**Created**: 2026-01-02 +**Status**: Draft + +## User Scenarios & Testing + +### User Story 1 — MAM App Config backup & restore (Priority: P1) +As an admin, I want Managed App Configuration policies (App Config) to be inventoried, backed up, and restorable, so I can safely manage MAM configurations (Outlook, Teams, Edge, OneDrive, etc.) at scale. + +This includes both: +- App configuration (app-targeted) via `deviceAppManagement/targetedManagedAppConfigurations` +- App configuration (managed device) via `deviceAppManagement/mobileAppConfigurations` + +**Acceptance Scenarios** +1. Given a tenant with App Config policies, when I sync policies, then I can see them in the policy inventory with correct type labels. +2. Given a policy, when I add it to a backup set, then it is captured and a backup item is created. +3. Given a backup item, when I start a restore preview, then I can see a safe preview of changes. + +### User Story 2 — Endpoint Security policies (not only intents) (Priority: P1) +As an admin, I want Endpoint Security policies (Firewall/Defender/ASR/BitLocker etc.) supported, so the Windows security core can be backed up and restored. + +**Acceptance Scenarios** +1. Given Endpoint Security policies exist, sync shows them as their own policy type. +2. Backup captures them successfully. + +### User Story 3 — Security baselines (Priority: P1) +As an admin, I want Security Baselines supported because they are commonly used and are expected in a complete solution. + +**Acceptance Scenarios** +1. Given baseline policies exist, sync shows them. +2. Backup captures them. + +## Requirements + +### Functional Requirements +- **FR-001**: Add support for Managed App Configuration policies. +- **FR-002**: Add support for Endpoint Security policies beyond intents. +- **FR-003**: Add support for Security Baselines. +- **FR-004**: Each new type must integrate with: inventory, backup, restore preview, and (where safe) restore execution. +- **FR-005**: Changes must be covered by automated tests. + +## Success Criteria +- **SC-001**: New policy types appear in inventory & picker. +- **SC-002**: Backup/restore preview works for new types. +- **SC-003**: No regressions in existing policy flows. diff --git a/specs/017-policy-types-mam-endpoint-security-baselines/tasks.md b/specs/017-policy-types-mam-endpoint-security-baselines/tasks.md new file mode 100644 index 0000000..a031c80 --- /dev/null +++ b/specs/017-policy-types-mam-endpoint-security-baselines/tasks.md @@ -0,0 +1,56 @@ +# Tasks: Policy Types (MAM App Config + Endpoint Security Policies + Security Baselines) (017) + +**Branch**: `feat/017-policy-types-mam-endpoint-security-baselines` +**Date**: 2026-01-02 +**Input**: [spec.md](./spec.md), [plan.md](./plan.md) + +## Phase 1: Setup +- [x] T001 Create spec/plan/tasks and checklist. + +## Phase 2: Inventory & Design +- [x] T002 Inventory existing policy types and identify missing graph resources. +- [x] T003 Decide type keys + restore modes for: app config, endpoint security policies, security baselines. + +## Phase 3: Tests (TDD) +- [x] T004 Add tests for policy sync listing new types (`mamAppConfiguration`, `endpointSecurityPolicy`, `securityBaselinePolicy`). +- [x] T005 Add tests for backup capture creating backup items for new types (`mamAppConfiguration`, `endpointSecurityPolicy`, `securityBaselinePolicy`). +- [x] T006 Add tests for restore preview for new types (at least preview-only for `endpointSecurityPolicy`, `securityBaselinePolicy`). + +## Phase 4: Implementation +- [x] T007 Add new types to `config/tenantpilot.php`. +- [x] T008 Add new graph contracts to `config/graph_contracts.php`. +- [x] T009 Implement any required snapshot/capture/restore handling. + +## Phase 4b: Follow-up (MAM Device App Config) +- [x] T012 Add managed device app configurations (`mobileAppConfigurations`) to supported types + graph contracts + sync test. + +## Phase 5: Verification +- [x] T010 Run targeted tests. +- [x] T011 Run Pint (`./vendor/bin/pint --dirty`). + +## Phase 5b: UI Polish +- [x] T013 Render Enabled/Disabled-like string values as badges in settings views for consistent UI. + +## Phase 4c: Bugfix +- [x] T014 Ensure configuration policy list sync selects `technologies`/`templateReference` so Endpoint Security + Baselines can be classified. + +## Phase 4d: UX Debuggability +- [x] T015 Show per-type sync failures in Policy sync UI so 0-synced cases are actionable. + +## Phase 4e: Bugfix (Graph OData) +- [x] T016 Fix configuration policy list sync `$select` to avoid unsupported `version` field (Graph 400). + +## Phase 4f: Bugfix (Enrollment OData) +- [x] T017 Fix ESP (`windowsEnrollmentStatusPage`) sync filter to avoid Graph 400 "Invalid filter PropertyName". + +## Phase 4g: Bugfix (Endpoint Security Classification) +- [x] T018 Fix endpoint security configuration policies being misclassified as settings catalog when `technologies=mdm`. + +## Phase 4h: Bugfix (Graph Pagination) +- [x] T019 Paginate Graph list responses so Endpoint Security policies on page 2+ are synced. + +## Phase 4i: Feature (Endpoint Security Settings Display) +- [x] T020 Hydrate `configurationPolicies/{id}/settings` for `endpointSecurityPolicy` + `securityBaselinePolicy` snapshots. +- [x] T021 Render Endpoint Security + Baselines via Settings Catalog normalizer/table (diff + UI). +- [x] T022 Prettify Endpoint Security template settings (use `templateReference.templateDisplayName` as fallback category + nicer Firewall rule labels/values). +- [x] T023 Improve Policy General tab cards (template reference summary, badges, readable timestamps). diff --git a/specs/018-driver-updates-wufb/checklists/requirements.md b/specs/018-driver-updates-wufb/checklists/requirements.md new file mode 100644 index 0000000..d6c149e --- /dev/null +++ b/specs/018-driver-updates-wufb/checklists/requirements.md @@ -0,0 +1,14 @@ +# Requirements Checklist (018) + +**Created**: 2026-01-03 +**Feature**: [spec.md](../spec.md) + +- [x] `windowsDriverUpdateProfile` is added to `config/tenantpilot.php` (metadata, endpoint, backup/restore mode, risk). +- [x] Graph contract exists in `config/graph_contracts.php` (resource, type family, create/update methods, assignments paths). +- [x] Sync lists and stores driver update profiles in the Policies inventory. +- [x] Snapshot capture stores a complete payload for backups and versions. +- [x] Restore preview is available and respects the configured restore mode. +- [x] Restore execution applies only patchable properties and records audit logs. +- [x] Normalized settings view is readable for admins (no raw-only UX). +- [x] Pest tests cover sync + snapshot + restore + normalized display. +- [x] Pint run (`./vendor/bin/pint --dirty`) on touched files. diff --git a/specs/018-driver-updates-wufb/plan.md b/specs/018-driver-updates-wufb/plan.md new file mode 100644 index 0000000..1b26dda --- /dev/null +++ b/specs/018-driver-updates-wufb/plan.md @@ -0,0 +1,24 @@ +# Plan: Driver Updates (WUfB Add-on) (018) + +**Branch**: `feat/018-driver-updates-wufb` +**Date**: 2026-01-03 +**Input**: [spec.md](./spec.md) + +## Goal +Add first-class support for Windows Driver Update profiles (`windowsDriverUpdateProfile`) across inventory, backup/version snapshots, restore (preview + execution), and normalized display. + +## Approach +1. Confirm Graph API details for driver update profiles (resource path, `@odata.type`, patchable properties, assignment endpoints). +2. Add type metadata to `config/tenantpilot.php` (category, endpoint, backup/restore mode, risk). +3. Add Graph contract entry in `config/graph_contracts.php` (resource, type family, create/update methods, assignments). +4. Ensure sync lists and stores these policies (config-driven loop) and add a targeted sync test. +5. Ensure snapshots capture the complete payload and add tests for version/backup capture. +6. Implement restore apply via contract-driven sanitization; add failure-safe behavior and tests. +7. Add a normalizer for readable UI output; add tests for normalized display. +8. Run Pint and targeted tests. + +## Decisions / Notes +- Default to contract-driven restore semantics; avoid bespoke Graph calls unless strictly required. +- If Graph rejects PATCH due to read-only fields, extend `update_strip_keys` for this type (do not loosen safety). +- Keep restore risk high; require clear preview and audit trail. + diff --git a/specs/018-driver-updates-wufb/spec.md b/specs/018-driver-updates-wufb/spec.md new file mode 100644 index 0000000..5ef2dcc --- /dev/null +++ b/specs/018-driver-updates-wufb/spec.md @@ -0,0 +1,79 @@ +# Feature Specification: Driver Updates (WUfB Add-on) (018) + +**Feature Branch**: `feat/018-driver-updates-wufb` +**Created**: 2026-01-03 +**Status**: Implemented +**Priority**: P1 + +## Context +TenantPilot already covers core Windows Update for Business (WUfB) objects like: +- Update Rings (`windowsUpdateRing`) +- Feature Update Profiles (`windowsFeatureUpdateProfile`) +- Quality Update Profiles (`windowsQualityUpdateProfile`) + +This feature adds **Windows Driver Updates** coverage to the same Update Management area so driver rollout configuration can be inventoried, snapshotted, diffed, and restored safely. + +## In Scope +- New policy type: `windowsDriverUpdateProfile` +- Inventory/sync: list driver update profiles from Microsoft Graph and store them as policies. +- Snapshot capture: full snapshot of the profile payload (and assignments where supported). +- Restore: + - Preview/dry-run with diff + risk checks. + - Execution (PATCH/POST) as allowed by Graph, with audit logging. +- UI: normalized settings display (readable, admin-focused). + +## Out of Scope (v1) +- Per-driver approval workflows / driver inventory insights. +- Advanced reporting on driver compliance. +- Partial per-setting restore. + +## Graph API Details (confirmed) +- **Resource**: `deviceManagement/windowsDriverUpdateProfiles` +- **@odata.type**: `#microsoft.graph.windowsDriverUpdateProfile` +- **Patchable fields**: `displayName`, `description`, `approvalType`, `deploymentDeferralInDays`, `roleScopeTagIds` +- **Read-only fields (strip on PATCH)**: `deviceReporting`, `newUpdates`, `inventorySyncStatus`, `createdDateTime`, `lastModifiedDateTime` +- **Assignments**: + - list: `/deviceManagement/windowsDriverUpdateProfiles/{id}/assignments` + - assign action: `/deviceManagement/windowsDriverUpdateProfiles/{id}/assign` + - update/delete: `/deviceManagement/windowsDriverUpdateProfiles/{id}/assignments/{assignmentId}` + +## User Scenarios & Testing + +### User Story 1 — Inventory + readable view (P1) +As an admin, I can see Windows Driver Update profiles in the Policies list and view their configuration in a readable way. + +**Acceptance** +1. Driver update profiles appear in the policy inventory with the correct type and category. +2. Policy detail shows a normalized settings table (not only raw JSON). +3. Policy Versions render “Normalized settings” consistently. + +### User Story 2 — Snapshot capture (P1) +As an admin, when I capture a version or add a driver update profile to a backup set, the snapshot contains all relevant settings. + +**Acceptance** +1. Snapshot stores the full Graph payload in JSON (immutable). +2. Any non-patchable/read-only properties are still preserved in the snapshot (but not sent on restore). + +### User Story 3 — Restore preview + execution (P1) +As an admin, I can restore a driver update profile from a snapshot with a clear preview and safe execution. + +**Acceptance** +1. Preview shows what would change and blocks if risk checks fail. +2. Execution applies only patchable properties (contract-driven sanitization). +3. Restore results include Graph error details (request-id, client-request-id, path/method) on failure. + +## Requirements + +### Functional Requirements +- **FR-001**: Add `windowsDriverUpdateProfile` to `config/tenantpilot.php` with category “Update Management”. +- **FR-002**: Add Graph contract entry for `windowsDriverUpdateProfile` in `config/graph_contracts.php` (resource, type family, create/update methods, assignments paths). +- **FR-003**: Ensure `PolicySyncService` syncs driver update profiles via config-driven type list. +- **FR-004**: Ensure `PolicySnapshotService` captures a complete payload for this type. +- **FR-005**: Ensure `RestoreService` applies snapshots using contract-driven sanitization and audit logging. +- **FR-006**: Add normalized display support for the key driver update profile fields. +- **FR-007**: Add automated Pest tests for sync + snapshot + restore preview/execution. + +### Non-Functional Requirements +- **NFR-001**: Preserve tenant isolation and least privilege. +- **NFR-002**: Keep restore safe-by-default (preview/confirmation/audit). +- **NFR-003**: No new external services or dependencies. diff --git a/specs/018-driver-updates-wufb/tasks.md b/specs/018-driver-updates-wufb/tasks.md new file mode 100644 index 0000000..19bf842 --- /dev/null +++ b/specs/018-driver-updates-wufb/tasks.md @@ -0,0 +1,32 @@ +# Tasks: Driver Updates (WUfB Add-on) (018) + +**Branch**: `feat/018-driver-updates-wufb` +**Date**: 2026-01-03 +**Input**: [spec.md](./spec.md), [plan.md](./plan.md) + +## Phase 1: Setup +- [x] T001 Create/confirm spec, plan, tasks, checklist. + +## Phase 2: Research & Design +- [x] T002 Verify Graph resource + `@odata.type` for driver update profiles. +- [x] T003 Verify PATCHable fields and define `update_strip_keys` / `update_whitelist`. +- [x] T004 Verify assignment endpoints (`/assignments`, `/assign`) for this resource. +- [x] T005 Decide restore mode (`enabled` vs `preview-only`) based on risk + patchability. + +## Phase 3: Tests (TDD) +- [x] T006 Add sync test ensuring `windowsDriverUpdateProfile` policies are imported and typed correctly. +- [x] T007 Add snapshot/version capture test asserting full payload is stored. +- [x] T008 Add restore preview test for this type (entries + restore_mode shown). +- [x] T009 Add restore execution test asserting only patchable properties are sent and failures are reported with Graph metadata. +- [x] T010 Add normalized display test for key fields. + +## Phase 4: Implementation +- [x] T011 Add `windowsDriverUpdateProfile` to `config/tenantpilot.php`. +- [x] T012 Add Graph contract entry in `config/graph_contracts.php`. +- [x] T013 Implement any required snapshot hydration (if Graph uses subresources). +- [x] T014 Implement restore apply support in `RestoreService` (contract-driven sanitization). +- [x] T015 Add a `WindowsDriverUpdateProfileNormalizer` and register it. + +## Phase 5: Verification +- [x] T016 Run targeted tests. +- [x] T017 Run Pint (`./vendor/bin/pint --dirty`). diff --git a/specs/023-endpoint-security-restore/checklists/requirements.md b/specs/023-endpoint-security-restore/checklists/requirements.md new file mode 100644 index 0000000..7984f8a --- /dev/null +++ b/specs/023-endpoint-security-restore/checklists/requirements.md @@ -0,0 +1,13 @@ +# Requirements Checklist (023) + +**Created**: 2026-01-03 +**Feature**: [spec.md](../spec.md) + +- [x] `endpointSecurityPolicy.restore` is changed to `enabled` in `config/tenantpilot.php`. +- [x] Restore preview validates template existence and reports missing/ambiguous templates. +- [x] Restore execution blocks on missing/ambiguous templates with a clear, actionable error message. +- [x] Settings instances are validated against resolved template definitions before execution. +- [x] Template mapping strategy is defined for cross-tenant differences (if required) and is tested. +- [x] Restore create + update paths for Endpoint Security policies are covered by automated tests. +- [x] Assignments mapping/application for Endpoint Security policies are covered by automated tests. +- [x] Audit log entries exist for restore execution attempts (success and failure). diff --git a/specs/023-endpoint-security-restore/plan.md b/specs/023-endpoint-security-restore/plan.md new file mode 100644 index 0000000..8109384 --- /dev/null +++ b/specs/023-endpoint-security-restore/plan.md @@ -0,0 +1,33 @@ +# Plan: Endpoint Security Policy Restore (023) + +**Branch**: `feat/023-endpoint-security-restore` +**Date**: 2026-01-03 +**Input**: [spec.md](./spec.md) +**Status**: Implemented (ready to merge) + +## Goal +Enable full restore execution for Endpoint Security Policies (`endpointSecurityPolicy`) instead of preview-only, with defensive validation around templates and settings payloads. + +## Approach +1. Enable restore execution in `config/tenantpilot.php` by switching `endpointSecurityPolicy.restore` from `preview-only` to `enabled`. +2. Add template existence validation during restore preview: + - Resolve the snapshot’s `templateReference` (family/id/display name where available). + - Confirm the referenced template is resolvable in the target tenant before execution. + - Surface warnings in preview and fail execution with a clear error when missing. +3. Add settings instance validation prior to execution: + - Resolve template definitions for the target tenant. + - Validate that settings instances are structurally compatible with the resolved template. + - Treat validation failures as preview warnings, and block execution when the payload cannot be made safe. +4. Ensure restore uses the existing generic configuration policy create/update flow: + - Create when no match exists; update when matched (per existing restore matching rules). + - Apply assignments using existing mapping logic. +5. Add targeted tests covering: + - Create + update restore execution for `endpointSecurityPolicy`. + - Preview warnings and execution failure when template is missing. + - Settings validation failure paths. + - Assignment application expectations. + +## Decisions / Notes +- Assume template identifiers may differ across tenants; prefer mapping by `templateFamily` with display-name fallback when required. +- Safety-first: if template resolution is ambiguous, treat as missing and block execution. + - Incident hardening: make restore failures actionable by surfacing Graph path/method and avoid unsafe fallback endpoints. diff --git a/specs/023-endpoint-security-restore/spec.md b/specs/023-endpoint-security-restore/spec.md new file mode 100644 index 0000000..c8cfe5f --- /dev/null +++ b/specs/023-endpoint-security-restore/spec.md @@ -0,0 +1,93 @@ +# Feature Specification: Enable Endpoint Security Policy Restore (023) + +**Feature Branch**: `feat/023-endpoint-security-restore` +**Created**: 2026-01-03 +**Status**: Implemented (ready to merge) +**Priority**: P1 (Quick Win) + +## Context +Endpoint Security Policies are already in the `tenantpilot.php` config as `endpointSecurityPolicy` with `restore => 'preview-only'`. Based on Microsoft's recommendation to use the unified `deviceManagement/configurationPolicies` endpoint (over the deprecated `intents` API for new creations), we should enable full restore for this type. + +This is a **restore-mode enablement** with additional validation/testing and targeted restore hardening, not a new policy type implementation. + +## User Scenarios & Testing + +### User Story 1 — Restore Endpoint Security Policies (Priority: P1) +As an admin, I want to restore Endpoint Security Policies (Firewall, Defender, ASR, BitLocker, etc.) from backup, so I can recover from configuration errors or replicate security baselines across tenants. + +**Why this priority**: These are high-impact security policies; restore is a core safety feature. + +**Independent Test**: Restore an Endpoint Security Policy snapshot; verify settings and assignments are applied correctly. + +**Acceptance Scenarios** +1. Given an Endpoint Security Policy snapshot (e.g., Firewall), when I restore to a tenant without that policy, then a new policy is created with matching settings. +2. Given an Endpoint Security Policy snapshot, when I restore to a tenant with an existing policy (name match), then the policy is updated. +3. Given such a policy has assignments, when I restore, then assignments are mapped and applied. + +### User Story 2 — Template Validation (Priority: P1) +As an admin, I want clear warnings if an Endpoint Security template is not available in the target tenant, so I understand restore limitations. + +**Why this priority**: Templates are version-dependent; missing templates must be surfaced. + +**Independent Test**: Attempt to restore a policy referencing a template not present in target; verify preview shows a warning. + +**Acceptance Scenarios** +1. Given a policy snapshot references a template ID, when I restore to a tenant without that template, then preview warns about missing template. +2. Given such a scenario, when I execute restore, then the operation fails gracefully with a clear error message. + +### User Story 3 — Settings Instance Consistency (Priority: P2) +As an admin, I want settings instances to be validated against template definitions, so restored policies are valid. + +**Why this priority**: Settings must match template structure; invalid settings break policies. + +**Independent Test**: Restore a policy with settings; verify Graph API accepts the settings payload. + +**Acceptance Scenarios** +1. Given a policy snapshot with settings, when I restore, then settings are validated before submission to Graph API. +2. Given settings validation detects structural issues, when running preview, then warnings indicate which settings may be problematic. + +## Requirements + +### Functional Requirements +- **FR-001**: Change `restore` value from `'preview-only'` to `'enabled'` for `endpointSecurityPolicy` in config +- **FR-002**: Add template existence validation in restore preview +- **FR-003**: Ensure settings instance validation against template structure +- **FR-004**: Update Graph contract for `endpointSecurityPolicy` if needed (may already exist) +- **FR-005**: Add template ID mapping (if templates have different IDs across tenants) +- **FR-006**: Add comprehensive restore tests for common Endpoint Security policy types: + - Antivirus (Defender) + - Firewall + - Disk Encryption (BitLocker) + - Attack Surface Reduction (ASR) + - Account Protection + +### Non-Functional Requirements +- **NFR-001**: Restore preview must complete within 5 seconds for typical policy +- **NFR-002**: Template validation must not significantly slow down preview +- **NFR-003**: All common Endpoint Security policy types must be covered by tests + +### Graph API Details +- **Endpoint**: `https://graph.microsoft.com/beta/deviceManagement/configurationPolicies` +- **Filter** (if needed): `templateReference/templateFamily eq 'endpointSecurity...'` +- **Template Families**: + - `endpointSecurityAntivirus` + - `endpointSecurityFirewall` + - `endpointSecurityDiskEncryption` + - `endpointSecurityAttackSurfaceReduction` + - `endpointSecurityAccountProtection` + - etc. +- **Required Permissions**: `DeviceManagementConfiguration.ReadWrite.All` + +### Known Considerations +- **Template Versioning**: Templates can evolve; settings structure may change +- **Platform Differences**: Some templates are Windows 10 only, others support Windows 11+ +- **Settings Validation**: Graph API will reject invalid settings; catch this in preview + +## Success Criteria +- **SC-001**: Config change applied: `endpointSecurityPolicy` has `restore => 'enabled'` +- **SC-002**: Restore preview shows accurate change summary for Endpoint Security policies +- **SC-003**: Restore executes successfully for common policy types (Firewall, Antivirus, BitLocker) +- **SC-004**: Template existence validation catches missing templates before execution +- **SC-005**: Settings instance validation prevents invalid payloads +- **SC-006**: No regressions in sync or backup for this policy type +- **SC-007**: Feature tests cover restore success and failure scenarios diff --git a/specs/023-endpoint-security-restore/tasks.md b/specs/023-endpoint-security-restore/tasks.md new file mode 100644 index 0000000..5142e65 --- /dev/null +++ b/specs/023-endpoint-security-restore/tasks.md @@ -0,0 +1,37 @@ +# Tasks: Endpoint Security Policy Restore (023) + +**Branch**: `feat/023-endpoint-security-restore` +**Date**: 2026-01-03 +**Input**: [spec.md](./spec.md), [plan.md](./plan.md) + +## Phase 1: Setup +- [x] T001 Create spec/plan/tasks and checklist. + +## Phase 2: Inventory & Design +- [x] T002 Confirm current restore mode + code paths for `endpointSecurityPolicy` (`config/tenantpilot.php`, restore services). +- [x] T003 Decide template resolution strategy (ID vs family/display name) and required Graph calls. +- [x] T004 Define settings instance validation rules (warning vs block) for restore preview/execution. + +## Phase 3: Tests (TDD) +- [x] T005 Add feature tests for restore execution create/update for `endpointSecurityPolicy`. +- [x] T006 Add feature tests for preview warnings when template is missing. +- [x] T007 Add feature tests asserting restore execution fails gracefully when template is missing. +- [x] T008 Add tests for settings validation failure paths (invalid/unknown settings instances). +- [x] T009 Add feature tests asserting assignments are applied for endpoint security policies. + +## Phase 4: Implementation +- [x] T010 Enable restore for `endpointSecurityPolicy` in `config/tenantpilot.php`. +- [x] T011 Implement template existence validation in restore preview and execution gating. +- [x] T012 Implement settings instance validation against resolved template definitions. +- [x] T013 Implement template mapping (if required) and ensure restore payload uses mapped template reference. +- [x] T014 Ensure restore applies assignments for endpoint security policies using existing mapping logic. + +## Phase 5: Verification +- [x] T015 Run targeted tests. +- [x] T016 Run Pint (`./vendor/bin/pint --dirty`). + +## Phase 6: Hardening (Incident-driven) +- [x] T017 Default unknown policy types to `preview-only` to avoid invalid Graph endpoints. +- [x] T018 Harden endpoint resolution fallback for configuration policy types (avoid `deviceManagement/{policyType}`). +- [x] T019 Surface Graph method/path in RestoreRun Results for faster debugging. +- [x] T020 Strip non-patchable fields for `endpointSecurityIntent` PATCH (`isAssigned`, `templateId`, `isMigratingToConfigurationPolicy`). diff --git a/specs/024-terms-and-conditions/checklists/requirements.md b/specs/024-terms-and-conditions/checklists/requirements.md new file mode 100644 index 0000000..64fb84d --- /dev/null +++ b/specs/024-terms-and-conditions/checklists/requirements.md @@ -0,0 +1,15 @@ +# Requirements Checklist (024) + +**Created**: 2026-01-04 +**Feature**: [spec.md](../spec.md) + +- [ ] `termsAndConditions` exists in `config/tenantpilot.php` with correct category/risk/restore mode. +- [ ] Graph contract exists in `config/graph_contracts.php` (resource, type family, assignments CRUD paths). +- [ ] Sync lists and stores T&C in inventory. +- [ ] Snapshot capture stores full payload + assignments. +- [ ] Restore preview shows correct mode and warnings. +- [ ] Restore execution applies only patchable properties and writes audit logs. +- [ ] Normalized settings view is readable for admins. +- [ ] Pest tests cover sync + snapshot + restore preview + execution. +- [ ] Pint run (`./vendor/bin/pint --dirty`) on touched files. + diff --git a/specs/024-terms-and-conditions/plan.md b/specs/024-terms-and-conditions/plan.md new file mode 100644 index 0000000..df1a6fc --- /dev/null +++ b/specs/024-terms-and-conditions/plan.md @@ -0,0 +1,23 @@ +# Plan: Terms & Conditions (Enrollment Experience) (024) + +**Branch**: `feat/024-terms-and-conditions` +**Date**: 2026-01-04 +**Input**: [spec.md](./spec.md) + +## Approach +1. Confirm Graph contract details for Terms & Conditions: + - resource path: `deviceManagement/termsAndConditions` + - `@odata.type` values and patchable fields + - assignments endpoints: `/deviceManagement/termsAndConditions/{id}/assignments` (CRUD) +2. Add `termsAndConditions` to `config/tenantpilot.php` (category “Enrollment Experience”, risk, restore mode). +3. Add contract entry to `config/graph_contracts.php`: + - resource, type family, create/update methods + - assignments list/create/update/delete paths (no `/assign` action here) +4. Ensure policy sync, snapshot capture, and restore use the config/contract-driven paths (minimal special casing). +5. Add a normalizer for readable UI output and ensure diff output is stable. +6. Add targeted Pest coverage (sync + snapshot + preview + execution). + +## Decisions / Notes +- **Restore mode**: default `enabled` (risk: medium-high) with strict preview/confirmation and audit logging. +- **Assignments**: use assignment CRUD paths (POST to `/assignments`) rather than `/assign`. + diff --git a/specs/024-terms-and-conditions/spec.md b/specs/024-terms-and-conditions/spec.md new file mode 100644 index 0000000..a1de713 --- /dev/null +++ b/specs/024-terms-and-conditions/spec.md @@ -0,0 +1,51 @@ +# Feature Specification: Terms & Conditions (Enrollment Experience) (024) + +**Feature Branch**: `feat/024-terms-and-conditions` +**Created**: 2026-01-04 +**Status**: Draft +**Priority**: P1 + +## Context +Terms & Conditions (T&C) are part of the **Enrollment Experience**. During tenant rebuilds / recovery they are frequently missed, but can be required for compliant onboarding. + +## User Scenarios & Testing + +### User Story 1 — Inventory + readable view (Priority: P1) +As an admin, I can see Terms & Conditions policies in the Policies inventory and view their configuration in a readable way. + +**Acceptance Scenarios** +1. Given a tenant with T&C configured, when I sync policies, then T&C items appear with type `termsAndConditions`. +2. Given a T&C policy, when I open its detail page, then I see a normalized settings view (not only raw JSON). + +### User Story 2 — Snapshot capture + versioning (Priority: P1) +As an admin, I can capture versions and backups of Terms & Conditions so I can diff and roll back safely. + +**Acceptance Scenarios** +1. Given a T&C policy, when I capture a snapshot, then the full Graph payload is stored immutably (JSONB). +2. Given two versions, when I view a diff, then changes are human-readable and structured. + +### User Story 3 — Restore preview + execution (Priority: P2) +As an admin, I can restore Terms & Conditions (with assignments) from a snapshot with a safe preview, audit logging, and defensive checks. + +**Acceptance Scenarios** +1. Given a backup item of type `termsAndConditions`, when I run restore preview, then it shows create/update + restore mode and warnings. +2. Given restore execution, when Graph rejects non-patchable fields, then TenantPilot strips them (contract-driven) and retries safely. + +## Requirements + +### Functional Requirements +- **FR-001**: Add policy type `termsAndConditions` backed by Graph `deviceManagement/termsAndConditions`. +- **FR-002**: Capture full payload snapshots and include assignments. +- **FR-003**: Restore supports create/update (contract-driven sanitization) and assignment apply. +- **FR-004**: Normalized settings view exists for key fields (displayName, description, title, body, acceptance statement, etc.). +- **FR-005**: Add Pest tests for sync + snapshot + restore preview + restore execution. + +### Non-Functional Requirements +- **NFR-001**: All writes require explicit confirmation and create audit logs. +- **NFR-002**: Tenant isolation applies end-to-end (no cross-tenant leakage). + +## Success Criteria +- **SC-001**: T&C appears in inventory and backups. +- **SC-002**: Restore preview is actionable and safe. +- **SC-003**: Restore execution works with assignments (where Graph allows). + diff --git a/specs/024-terms-and-conditions/tasks.md b/specs/024-terms-and-conditions/tasks.md new file mode 100644 index 0000000..8e6b0c2 --- /dev/null +++ b/specs/024-terms-and-conditions/tasks.md @@ -0,0 +1,33 @@ +# Tasks: Terms & Conditions (Enrollment Experience) (024) + +**Branch**: `feat/024-terms-and-conditions` +**Date**: 2026-01-04 +**Input**: [spec.md](./spec.md), [plan.md](./plan.md) + +## Phase 1: Setup +- [x] T001 Create spec/plan/tasks and checklist. + +## Phase 2: Research & Design +- [ ] T002 Confirm Graph resource, `@odata.type`, patchable vs read-only fields. +- [ ] T003 Confirm assignments endpoints and payload shapes for create/update/delete. +- [ ] T004 Decide restore mode (`enabled` vs `preview-only`) and risk classification. + +## Phase 3: Tests (TDD) +- [ ] T005 Add sync test for `termsAndConditions`. +- [ ] T006 Add snapshot capture test (payload + assignments). +- [ ] T007 Add restore preview test (restore_mode + action). +- [ ] T008 Add restore execution test (sanitization + assignment apply). +- [ ] T009 Add normalized display test for key fields. + +## Phase 4: Implementation +- [ ] T010 Add `termsAndConditions` to `config/tenantpilot.php`. +- [ ] T011 Add Graph contract entry in `config/graph_contracts.php` (resource + assignment CRUD paths). +- [ ] T012 Ensure `PolicySyncService` imports these policies correctly. +- [ ] T013 Ensure `PolicySnapshotService` captures full payload and assignments. +- [ ] T014 Ensure `RestoreService` applies create/update and assignments (contract-driven). +- [ ] T015 Add `TermsAndConditionsNormalizer` and register it. + +## Phase 5: Verification +- [ ] T016 Run targeted tests. +- [ ] T017 Run Pint (`./vendor/bin/pint --dirty`). + diff --git a/specs/025-policy-sets/checklists/requirements.md b/specs/025-policy-sets/checklists/requirements.md new file mode 100644 index 0000000..727bc41 --- /dev/null +++ b/specs/025-policy-sets/checklists/requirements.md @@ -0,0 +1,13 @@ +# Requirements Checklist (025) + +**Created**: 2026-01-04 +**Feature**: [spec.md](../spec.md) + +- [ ] `policySet` exists in `config/tenantpilot.php` (category, endpoint, restore mode, risk). +- [ ] Graph contract exists in `config/graph_contracts.php` (resource, items hydration, assignments paths). +- [ ] Sync lists and stores Policy Sets in inventory. +- [ ] Snapshot capture includes Policy Set items and assignments. +- [ ] Restore preview produces a linking report and blocks unsafe execution. +- [ ] Normalized settings view is readable (items + assignments). +- [ ] Pest tests cover sync + snapshot + preview. + diff --git a/specs/025-policy-sets/plan.md b/specs/025-policy-sets/plan.md new file mode 100644 index 0000000..f7ec684 --- /dev/null +++ b/specs/025-policy-sets/plan.md @@ -0,0 +1,27 @@ +# Plan: Policy Sets (Intune native bundling) (025) + +**Branch**: `feat/025-policy-sets` +**Date**: 2026-01-04 +**Input**: [spec.md](./spec.md) + +## Approach +1. Confirm Graph API surface: + - resource: `deviceAppManagement/policySets` + - item model + subresource path (`/policySets/{id}/items`) + - assignments subresource (`/policySets/{id}/assignments`) +2. Add `policySet` to `config/tenantpilot.php` (category “Apps/MAM”, risk, restore mode). +3. Add contract entry in `config/graph_contracts.php`: + - resource + type family + - member hydration strategy for items (subresource) + - assignments CRUD paths (if supported) +4. Extend snapshot capture to hydrate `items` (and assignments). +5. Implement restore preview “linking report”: + - identify referenced object IDs inside items + - attempt mapping by (type, displayName, externalId) where possible + - surface missing dependencies and block execution by default +6. Add targeted Pest tests for sync + snapshot hydration + preview report. + +## Decisions / Notes +- **Restore mode**: default `preview-only` until a robust cross-tenant linking/mapping strategy exists. +- Policy Sets are not “settings restore”; they are primarily a **relationship/linking** restore step. + diff --git a/specs/025-policy-sets/spec.md b/specs/025-policy-sets/spec.md new file mode 100644 index 0000000..39050f6 --- /dev/null +++ b/specs/025-policy-sets/spec.md @@ -0,0 +1,51 @@ +# Feature Specification: Policy Sets (Intune native bundling) (025) + +**Feature Branch**: `feat/025-policy-sets` +**Created**: 2026-01-04 +**Status**: Draft +**Priority**: P1 + +## Context +Policy Sets are an Intune-native way to bundle multiple policies/apps into a deployable set. For tenants that rely on Policy Sets, “Tenant-as-Code” is incomplete without at least inventory + backup and a restore preview that highlights missing links. + +## User Scenarios & Testing + +### User Story 1 — Inventory + view Policy Sets (Priority: P1) +As an admin, I can see Policy Sets and inspect their composition (items) and assignments. + +**Acceptance Scenarios** +1. Given a tenant uses Policy Sets, when I sync policies, then Policy Sets appear as type `policySet`. +2. Given a Policy Set, when I view details, then I see a readable list of included items and assignments. + +### User Story 2 — Backup + version history (Priority: P1) +As an admin, I can capture immutable snapshots of Policy Sets (including items) and diff versions. + +**Acceptance Scenarios** +1. Given a Policy Set, when I add it to a backup set, then the snapshot includes items and assignments (as supported by Graph). +2. Given two versions, diffs highlight changed items and assignment targets. + +### User Story 3 — Restore preview (linking) (Priority: P1) +As an admin, I can run a restore preview that explains which Policy Set items can be linked in the target tenant and which are missing. + +**Acceptance Scenarios** +1. Given a Policy Set snapshot referencing policies/apps by ID, when I run preview, then TenantPilot reports missing vs resolvable items. +2. Given missing referenced objects, preview warns and blocks execution unless resolved. + +## Requirements + +### Functional Requirements +- **FR-001**: Add policy type `policySet` backed by Graph `deviceAppManagement/policySets`. +- **FR-002**: Capture Policy Set payload + `items` subresource (and assignments if applicable). +- **FR-003**: Restore preview MUST validate referenced IDs and provide a linking report. +- **FR-004**: Restore execution is allowed only when all referenced items can be mapped safely (or stays preview-only initially). +- **FR-005**: Add Pest tests for sync + snapshot + preview linking report. + +### Non-Functional Requirements +- **NFR-001**: No destructive writes without explicit confirmation and audit logs. +- **NFR-002**: Linking errors must be actionable (show which item is missing and why). + +## Success Criteria +- **SC-001**: Policy Sets are visible and backed up. +- **SC-002**: Preview makes missing dependencies obvious. +- **SC-003**: If enabled, execution links only safe, mapped items. + diff --git a/specs/025-policy-sets/tasks.md b/specs/025-policy-sets/tasks.md new file mode 100644 index 0000000..3331266 --- /dev/null +++ b/specs/025-policy-sets/tasks.md @@ -0,0 +1,31 @@ +# Tasks: Policy Sets (Intune native bundling) (025) + +**Branch**: `feat/025-policy-sets` +**Date**: 2026-01-04 +**Input**: [spec.md](./spec.md), [plan.md](./plan.md) + +## Phase 1: Setup +- [x] T001 Create spec/plan/tasks and checklist. + +## Phase 2: Research & Design +- [ ] T002 Confirm Graph resource + `@odata.type` for Policy Sets. +- [ ] T003 Confirm item subresource shape (`/items`) and how referenced objects are represented. +- [ ] T004 Confirm assignment endpoints (`/assignments`) and payload shape. +- [ ] T005 Define restore preview “linking report” rules and execution gating. + +## Phase 3: Tests (TDD) +- [ ] T006 Add sync test importing Policy Sets. +- [ ] T007 Add snapshot test capturing items (and assignments). +- [ ] T008 Add restore preview test showing linking report (missing vs resolvable). + +## Phase 4: Implementation +- [ ] T009 Add `policySet` to `config/tenantpilot.php`. +- [ ] T010 Add contract entry in `config/graph_contracts.php` (resource + item hydration + assignments). +- [ ] T011 Implement snapshot hydration for `items` and assignment capture. +- [ ] T012 Implement restore preview linking report and safe gating. +- [ ] T013 Add a normalizer for readable UI output (items summary + assignment summary). + +## Phase 5: Verification +- [ ] T014 Run targeted tests. +- [ ] T015 Run Pint (`./vendor/bin/pint --dirty`). + diff --git a/specs/026-custom-compliance-scripts/checklists/requirements.md b/specs/026-custom-compliance-scripts/checklists/requirements.md new file mode 100644 index 0000000..6689320 --- /dev/null +++ b/specs/026-custom-compliance-scripts/checklists/requirements.md @@ -0,0 +1,14 @@ +# Requirements Checklist (026) + +**Created**: 2026-01-04 +**Feature**: [spec.md](../spec.md) + +- [ ] `deviceComplianceScript` exists in `config/tenantpilot.php` (category, endpoint, restore mode, risk). +- [ ] Graph contract exists in `config/graph_contracts.php` (resource, type family, assignments paths). +- [ ] Sync lists and stores compliance scripts in inventory. +- [ ] Snapshot capture stores full payload + assignments. +- [ ] Restore preview is available and respects restore mode. +- [ ] Restore execution applies only patchable fields and re-encodes script content correctly. +- [ ] Normalized settings view is readable and safe. +- [ ] Pest tests cover sync + snapshot + preview + execution. + diff --git a/specs/026-custom-compliance-scripts/plan.md b/specs/026-custom-compliance-scripts/plan.md new file mode 100644 index 0000000..5e8dde0 --- /dev/null +++ b/specs/026-custom-compliance-scripts/plan.md @@ -0,0 +1,25 @@ +# Plan: Custom Compliance Scripts (Windows) (026) + +**Branch**: `feat/026-custom-compliance-scripts` +**Date**: 2026-01-04 +**Input**: [spec.md](./spec.md) + +## Approach +1. Confirm Graph contract details: + - resource: `deviceManagement/deviceComplianceScripts` (beta) + - patchable fields vs read-only fields + - assignment pattern: `/deviceComplianceScripts/{id}/assign` and `/assignments` +2. Add `deviceComplianceScript` to `config/tenantpilot.php` (category “Compliance”, risk, restore mode). +3. Add contract entry to `config/graph_contracts.php` (resource + assignment endpoints + scope tags support). +4. Implement snapshot capture: + - ensure `detectionScriptContent` is preserved and treated like other scripts (safe display, encode/decode where needed) +5. Implement restore: + - sanitize payload via contract + - ensure `detectionScriptContent` is encoded as expected by Graph + - apply assignments via assign action +6. Add normalizer and targeted tests. + +## Decisions / Notes +- **Restore mode**: default `enabled` (risk: medium-high) because tenant recovery often depends on these scripts. +- Use the existing script content display rules (`TENANTPILOT_SHOW_SCRIPT_CONTENT`, max chars). + diff --git a/specs/026-custom-compliance-scripts/spec.md b/specs/026-custom-compliance-scripts/spec.md new file mode 100644 index 0000000..fd7f940 --- /dev/null +++ b/specs/026-custom-compliance-scripts/spec.md @@ -0,0 +1,52 @@ +# Feature Specification: Custom Compliance Scripts (Windows) (026) + +**Feature Branch**: `feat/026-custom-compliance-scripts` +**Created**: 2026-01-04 +**Status**: Draft +**Priority**: P1 + +## Context +Windows Custom Compliance is widely used. Without `deviceComplianceScripts`, backup/restore for compliance posture is incomplete. Restore must include assignments. + +## User Scenarios & Testing + +### User Story 1 — Inventory + view compliance scripts (Priority: P1) +As an admin, I can see Custom Compliance Scripts in inventory and view their script/config in a readable way. + +**Acceptance Scenarios** +1. Given device compliance scripts exist, sync shows them as type `deviceComplianceScript`. +2. Detail view shows key settings (runAsAccount, enforceSignatureCheck, runAs32Bit) and script content (safe display rules). + +### User Story 2 — Backup + versioning (Priority: P1) +As an admin, I can capture versions/backups of compliance scripts so I can diff changes. + +**Acceptance Scenarios** +1. Snapshot capture stores the full payload including `detectionScriptContent`. +2. Diff highlights script changes and operational flags. + +### User Story 3 — Restore preview + execution (Priority: P1) +As an admin, I can restore a compliance script and its assignments defensively. + +**Acceptance Scenarios** +1. Preview shows create/update + restore mode and warnings. +2. Execution strips read-only fields and re-encodes script content correctly. +3. Assignments are applied via Graph assign action. + +## Requirements + +### Functional Requirements +- **FR-001**: Add policy type `deviceComplianceScript` backed by Graph `deviceManagement/deviceComplianceScripts` (beta). +- **FR-002**: Snapshot stores full payload (including `detectionScriptContent`) and assignments. +- **FR-003**: Restore supports create/update with contract-driven sanitization. +- **FR-004**: Restore applies assignments (`/assign`) and records audit logs. +- **FR-005**: Add normalized display support for key fields and script content (with safety limits). +- **FR-006**: Add Pest tests for sync + snapshot + preview + execution. + +### Non-Functional Requirements +- **NFR-001**: Script content must never be logged; UI display must be bounded (config-driven). +- **NFR-002**: Preview-only fallback when Graph returns unexpected shapes or missing contracts. + +## Success Criteria +- **SC-001**: Custom compliance scripts appear in inventory and backups. +- **SC-002**: Restore execution works and assignments are applied. + diff --git a/specs/026-custom-compliance-scripts/tasks.md b/specs/026-custom-compliance-scripts/tasks.md new file mode 100644 index 0000000..0b4e46b --- /dev/null +++ b/specs/026-custom-compliance-scripts/tasks.md @@ -0,0 +1,33 @@ +# Tasks: Custom Compliance Scripts (Windows) (026) + +**Branch**: `feat/026-custom-compliance-scripts` +**Date**: 2026-01-04 +**Input**: [spec.md](./spec.md), [plan.md](./plan.md) + +## Phase 1: Setup +- [x] T001 Create spec/plan/tasks and checklist. + +## Phase 2: Research & Design +- [ ] T002 Confirm Graph resource + `@odata.type` and required permissions. +- [ ] T003 Confirm patchable fields and define `update_strip_keys` / `update_whitelist`. +- [ ] T004 Confirm assignments endpoints (`/assignments`, `/assign`) and body shape. +- [ ] T005 Decide restore mode + risk classification. + +## Phase 3: Tests (TDD) +- [ ] T006 Add sync test for `deviceComplianceScript`. +- [ ] T007 Add snapshot/version capture test (incl. `detectionScriptContent`). +- [ ] T008 Add restore preview test (restore_mode + action). +- [ ] T009 Add restore execution test (sanitization + assignment apply). +- [ ] T010 Add normalized display test for key fields. + +## Phase 4: Implementation +- [ ] T011 Add `deviceComplianceScript` to `config/tenantpilot.php`. +- [ ] T012 Add Graph contract entry in `config/graph_contracts.php`. +- [ ] T013 Implement snapshot capture handling (script content preservation rules). +- [ ] T014 Implement restore apply support (contract-driven sanitization + assignments). +- [ ] T015 Add `DeviceComplianceScriptNormalizer` and register it. + +## Phase 5: Verification +- [ ] T016 Run targeted tests. +- [ ] T017 Run Pint (`./vendor/bin/pint --dirty`). + diff --git a/specs/027-enrollment-config-subtypes/checklists/requirements.md b/specs/027-enrollment-config-subtypes/checklists/requirements.md new file mode 100644 index 0000000..6b9891d --- /dev/null +++ b/specs/027-enrollment-config-subtypes/checklists/requirements.md @@ -0,0 +1,13 @@ +# Requirements Checklist (027) + +**Created**: 2026-01-04 +**Feature**: [spec.md](../spec.md) + +- [ ] New enrollment config subtypes exist in `config/tenantpilot.php`. +- [ ] Graph contracts exist with correct type families. +- [ ] Sync classifies each subtype correctly (no collapsing into `enrollmentRestriction`). +- [ ] Snapshot capture stores full payloads. +- [ ] Restore preview works and defaults to preview-only. +- [ ] Normalized view is readable for admins. +- [ ] Pest tests cover sync + snapshot + preview. + diff --git a/specs/027-enrollment-config-subtypes/plan.md b/specs/027-enrollment-config-subtypes/plan.md new file mode 100644 index 0000000..17a513a --- /dev/null +++ b/specs/027-enrollment-config-subtypes/plan.md @@ -0,0 +1,21 @@ +# Plan: Enrollment Configuration Subtypes (027) + +**Branch**: `feat/027-enrollment-config-subtypes` +**Date**: 2026-01-04 +**Input**: [spec.md](./spec.md) + +## Approach +1. Confirm Graph details and type-family values for each subtype (`@odata.type`). +2. Add new types to `config/tenantpilot.php` (category “Enrollment Experience”, risk, restore mode). +3. Add contracts to `config/graph_contracts.php`: + - resource `deviceManagement/deviceEnrollmentConfigurations` + - type families per subtype + - assignments endpoints (if supported) or mark as unsupported +4. Update `PolicySyncService` enrollment classification logic to route each item to the correct subtype. +5. Ensure snapshot capture can fetch these items without special casing. +6. Implement restore preview entries; keep execution preview-only until validated. +7. Add targeted Pest tests. + +## Decisions / Notes +- All enrollment configuration subtypes should default to `preview-only` restore initially due to enrollment impact risk. + diff --git a/specs/027-enrollment-config-subtypes/spec.md b/specs/027-enrollment-config-subtypes/spec.md new file mode 100644 index 0000000..1cccd8b --- /dev/null +++ b/specs/027-enrollment-config-subtypes/spec.md @@ -0,0 +1,46 @@ +# Feature Specification: Enrollment Configuration Subtypes (027) + +**Feature Branch**: `feat/027-enrollment-config-subtypes` +**Created**: 2026-01-04 +**Status**: Draft +**Priority**: P1 + +## Context +TenantPilot already covers ESP and Enrollment Restrictions, but there are additional subtypes in the same `deviceEnrollmentConfigurations` collection that are often forgotten: +- Enrollment Limit (`deviceEnrollmentLimitConfiguration`) +- Platform Restrictions (`deviceEnrollmentPlatformRestrictionsConfiguration`) +- Enrollment Notifications (`deviceEnrollmentNotificationConfiguration`, beta) + +## User Scenarios & Testing + +### User Story 1 — Inventory shows each subtype separately (Priority: P1) +As an admin, I can sync enrollment configurations and see each subtype as its own policy type. + +**Acceptance Scenarios** +1. Given enrollment limit configurations exist, sync shows type `deviceEnrollmentLimitConfiguration`. +2. Given platform restriction configurations exist, sync shows type `deviceEnrollmentPlatformRestrictionsConfiguration`. +3. Given enrollment notifications exist, sync shows type `deviceEnrollmentNotificationConfiguration`. + +### User Story 2 — Backup + restore preview (Priority: P1) +As an admin, I can back up and preview-restore these enrollment configurations safely. + +**Acceptance Scenarios** +1. Backup captures full payloads for each subtype. +2. Restore preview lists create/update actions and shows preview-only warnings for enrollment-risky configs. + +## Requirements + +### Functional Requirements +- **FR-001**: Add three new policy types backed by `deviceManagement/deviceEnrollmentConfigurations`: + - `deviceEnrollmentLimitConfiguration` + - `deviceEnrollmentPlatformRestrictionsConfiguration` + - `deviceEnrollmentNotificationConfiguration` +- **FR-002**: Update classification so these do not collapse into `enrollmentRestriction`. +- **FR-003**: Snapshot capture stores full payload and assignments (where supported). +- **FR-004**: Restore preview is supported; execution is conservative (likely preview-only initially). +- **FR-005**: Add Pest tests for sync + snapshot + preview. + +## Success Criteria +- **SC-001**: Enrollment configuration subtypes are visible and correctly classified. +- **SC-002**: Backups include these objects, and preview explains safe restore behavior. + diff --git a/specs/027-enrollment-config-subtypes/tasks.md b/specs/027-enrollment-config-subtypes/tasks.md new file mode 100644 index 0000000..7342257 --- /dev/null +++ b/specs/027-enrollment-config-subtypes/tasks.md @@ -0,0 +1,28 @@ +# Tasks: Enrollment Configuration Subtypes (027) + +**Branch**: `feat/027-enrollment-config-subtypes` +**Date**: 2026-01-04 +**Input**: [spec.md](./spec.md), [plan.md](./plan.md) + +## Phase 1: Setup +- [x] T001 Create spec/plan/tasks and checklist. + +## Phase 2: Research & Design +- [x] T002 Confirm `@odata.type` for each subtype and whether Graph supports assignments. +- [x] T003 Decide restore modes and risk levels. + +## Phase 3: Tests (TDD) +- [x] T004 Add sync tests ensuring each subtype is classified correctly. +- [x] T005 Add snapshot capture test for at least one subtype. +- [x] T006 Add restore preview test ensuring preview-only behavior. + +## Phase 4: Implementation +- [x] T007 Add new types to `config/tenantpilot.php`. +- [x] T008 Add contracts in `config/graph_contracts.php` (resource + type families). +- [x] T009 Update `PolicySyncService` enrollment classification logic. +- [x] T010 Add normalizer for readable UI output (key fields per subtype). +- [x] T013 Hydrate notification templates for enrollment notifications. + +## Phase 5: Verification +- [x] T011 Run targeted tests. +- [x] T012 Run Pint (`./vendor/bin/pint --dirty`). diff --git a/specs/028-device-categories/checklists/requirements.md b/specs/028-device-categories/checklists/requirements.md new file mode 100644 index 0000000..e74fd5f --- /dev/null +++ b/specs/028-device-categories/checklists/requirements.md @@ -0,0 +1,11 @@ +# Requirements Checklist (028) + +**Created**: 2026-01-04 +**Feature**: [spec.md](../spec.md) + +- [ ] `deviceCategory` exists in `config/tenantpilot.php` under `foundation_types`. +- [ ] Graph contract exists in `config/graph_contracts.php` for device categories. +- [ ] Backup sets include device categories as foundation items. +- [ ] Restore recreates missing categories idempotently and writes audit logs. +- [ ] Pest tests cover foundation snapshot + restore. + diff --git a/specs/028-device-categories/plan.md b/specs/028-device-categories/plan.md new file mode 100644 index 0000000..4d69a4a --- /dev/null +++ b/specs/028-device-categories/plan.md @@ -0,0 +1,21 @@ +# Plan: Device Categories (Enrollment/Organization) (028) + +**Branch**: `feat/028-device-categories` +**Date**: 2026-01-04 +**Input**: [spec.md](./spec.md) + +## Approach +1. Confirm Graph endpoints and patchable fields: + - list: `GET /deviceManagement/deviceCategories` + - create/update/delete supported +2. Add `deviceCategory` to `config/tenantpilot.php` under `foundation_types` (risk low, restore enabled). +3. Add contract entry in `config/graph_contracts.php` for foundations (resource + create/update methods). +4. Extend `FoundationSnapshotService` to fetch categories (list + per-item payload). +5. Extend `FoundationMappingService` and restore flow: + - match by `displayName` + - create missing +6. Add targeted Pest tests for foundation capture + restore. + +## Decisions / Notes +- This is modeled as a **foundation type** (captured automatically with backup sets), not a Policy inventory type. + diff --git a/specs/028-device-categories/spec.md b/specs/028-device-categories/spec.md new file mode 100644 index 0000000..e8cc656 --- /dev/null +++ b/specs/028-device-categories/spec.md @@ -0,0 +1,30 @@ +# Feature Specification: Device Categories (Enrollment/Organization) (028) + +**Feature Branch**: `feat/028-device-categories` +**Created**: 2026-01-04 +**Status**: Draft +**Priority**: P2 + +## Context +Device Categories are not a “policy”, but they are frequently needed for tenant rebuilds and enrollment flows. + +## User Scenarios & Testing + +### User Story 1 — Backup + restore Device Categories (Priority: P1) +As an admin, when I create a backup set, Device Categories are captured as a foundation object and can be restored safely. + +**Acceptance Scenarios** +1. Given device categories exist, when I create a backup, then categories are included as foundation items. +2. Given a target tenant is missing categories, when I restore, then categories are recreated (idempotent by display name). + +## Requirements + +### Functional Requirements +- **FR-001**: Add foundation type `deviceCategory` backed by `deviceManagement/deviceCategories`. +- **FR-002**: Backup captures all categories with minimal metadata. +- **FR-003**: Restore recreates categories idempotently (match by displayName) and records audit logs. +- **FR-004**: Add targeted tests for foundation snapshot + restore. + +## Success Criteria +- **SC-001**: Device Categories are present in backups and can be recreated. + diff --git a/specs/028-device-categories/tasks.md b/specs/028-device-categories/tasks.md new file mode 100644 index 0000000..81e756f --- /dev/null +++ b/specs/028-device-categories/tasks.md @@ -0,0 +1,27 @@ +# Tasks: Device Categories (Enrollment/Organization) (028) + +**Branch**: `feat/028-device-categories` +**Date**: 2026-01-04 +**Input**: [spec.md](./spec.md), [plan.md](./plan.md) + +## Phase 1: Setup +- [x] T001 Create spec/plan/tasks and checklist. + +## Phase 2: Research & Design +- [ ] T002 Confirm Graph resource + patchability for `deviceCategories`. +- [ ] T003 Decide mapping rules (by displayName) and restore idempotency behavior. + +## Phase 3: Tests (TDD) +- [ ] T004 Add foundation snapshot test for `deviceCategory`. +- [ ] T005 Add foundation restore test (create missing + idempotent behavior). + +## Phase 4: Implementation +- [ ] T006 Add `deviceCategory` to `config/tenantpilot.php` foundation types. +- [ ] T007 Add contract entry in `config/graph_contracts.php`. +- [ ] T008 Implement foundation snapshot fetch for device categories. +- [ ] T009 Implement foundation restore mapping + apply. + +## Phase 5: Verification +- [ ] T010 Run targeted tests. +- [ ] T011 Run Pint (`./vendor/bin/pint --dirty`). + diff --git a/specs/029-wip-policies/checklists/requirements.md b/specs/029-wip-policies/checklists/requirements.md new file mode 100644 index 0000000..fe819c0 --- /dev/null +++ b/specs/029-wip-policies/checklists/requirements.md @@ -0,0 +1,14 @@ +# Requirements Checklist (029) + +**Created**: 2026-01-04 +**Feature**: [spec.md](../spec.md) + +- [ ] `windowsInformationProtectionPolicy` and `mdmWindowsInformationProtectionPolicy` exist in `config/tenantpilot.php`. +- [ ] Graph contracts exist with correct resources/type families/assignment endpoints. +- [ ] Sync lists and stores both WIP types separately. +- [ ] Snapshot capture stores full payload + assignments. +- [ ] Restore preview explains gating and risks. +- [ ] If enabled, restore execution uses derived endpoints and sanitizes payloads. +- [ ] Normalized view is readable for admins. +- [ ] Pest tests cover sync + snapshot + preview (and execution if enabled). + diff --git a/specs/029-wip-policies/plan.md b/specs/029-wip-policies/plan.md new file mode 100644 index 0000000..5f81ef2 --- /dev/null +++ b/specs/029-wip-policies/plan.md @@ -0,0 +1,23 @@ +# Plan: Windows Information Protection (WIP) Policies (029) + +**Branch**: `feat/029-wip-policies` +**Date**: 2026-01-04 +**Input**: [spec.md](./spec.md) + +## Approach +1. Confirm Graph behavior: + - endpoints for both WIP collections + - assignment endpoints (list + assign/create shape) + - patchable/read-only fields and required permissions +2. Add new types to `config/tenantpilot.php` (category “Apps/MAM”, platform windows, restore mode/risk). +3. Add graph contracts in `config/graph_contracts.php`: + - resource paths + - type families + - assignment endpoints +4. Ensure restore uses the derived entity set endpoint (do not PATCH generic `managedAppPolicies/{id}` when Graph requires derived resources). +5. Add a normalizer for readable UI output. +6. Add targeted Pest coverage. + +## Decisions / Notes +- **Restore mode**: default `preview-only` until endpoint + assignment behavior is confirmed with tests and real tenants. + diff --git a/specs/029-wip-policies/spec.md b/specs/029-wip-policies/spec.md new file mode 100644 index 0000000..87d884e --- /dev/null +++ b/specs/029-wip-policies/spec.md @@ -0,0 +1,41 @@ +# Feature Specification: Windows Information Protection (WIP) Policies (029) + +**Feature Branch**: `feat/029-wip-policies` +**Created**: 2026-01-04 +**Status**: Draft +**Priority**: P2 + +## Context +Some tenants rely on WIP (MAM/WIP). These policies live under `deviceAppManagement` and should be treated as first-class objects for backup/restore. + +## User Scenarios & Testing + +### User Story 1 — Inventory shows WIP policies separately (Priority: P1) +As an admin, I can see WIP policies as their own types (not mixed into generic MAM policies). + +**Acceptance Scenarios** +1. Sync lists WIP policies from Graph and stores them as `windowsInformationProtectionPolicy`. +2. Sync lists MDM WIP policies and stores them as `mdmWindowsInformationProtectionPolicy`. + +### User Story 2 — Backup + restore (Priority: P2) +As an admin, I can back up and restore WIP policies with assignments safely. + +**Acceptance Scenarios** +1. Snapshot capture stores the full policy payload and assignments. +2. Restore execution uses the correct derived entity set endpoint for create/update. + +## Requirements + +### Functional Requirements +- **FR-001**: Add policy types: + - `windowsInformationProtectionPolicy` → `deviceAppManagement/windowsInformationProtectionPolicies` + - `mdmWindowsInformationProtectionPolicy` → `deviceAppManagement/mdmWindowsInformationProtectionPolicies` +- **FR-002**: Capture full payload + assignments. +- **FR-003**: Restore supports create/update with contract-driven sanitization and assignment apply. +- **FR-004**: Add normalized display for key WIP fields (protected apps/identities, enforcement level, exemptions, etc.). +- **FR-005**: Add Pest tests for sync + snapshot + restore preview/execution. + +## Success Criteria +- **SC-001**: WIP policies appear and can be backed up. +- **SC-002**: Restore preview/execution uses correct endpoints and is auditable. + diff --git a/specs/029-wip-policies/tasks.md b/specs/029-wip-policies/tasks.md new file mode 100644 index 0000000..1f1ded8 --- /dev/null +++ b/specs/029-wip-policies/tasks.md @@ -0,0 +1,32 @@ +# Tasks: Windows Information Protection (WIP) Policies (029) + +**Branch**: `feat/029-wip-policies` +**Date**: 2026-01-04 +**Input**: [spec.md](./spec.md), [plan.md](./plan.md) + +## Phase 1: Setup +- [x] T001 Create spec/plan/tasks and checklist. + +## Phase 2: Research & Design +- [ ] T002 Confirm Graph endpoints for WIP and MDM WIP policy collections. +- [ ] T003 Confirm assignment endpoints and body shape. +- [ ] T004 Confirm patchable fields and define sanitization rules. +- [ ] T005 Decide restore mode and risk classification. + +## Phase 3: Tests (TDD) +- [ ] T006 Add sync test importing both WIP types. +- [ ] T007 Add snapshot capture test (payload + assignments). +- [ ] T008 Add restore preview test (preview-only gating). +- [ ] T009 Add restore execution test using derived endpoints (if enabled). + +## Phase 4: Implementation +- [ ] T010 Add types to `config/tenantpilot.php`. +- [ ] T011 Add contracts in `config/graph_contracts.php`. +- [ ] T012 Update sync classification so WIP types are not treated as generic appProtectionPolicy. +- [ ] T013 Update restore/apply paths if Graph requires derived resources. +- [ ] T014 Add normalizer for readable settings. + +## Phase 5: Verification +- [ ] T015 Run targeted tests. +- [ ] T016 Run Pint (`./vendor/bin/pint --dirty`). + diff --git a/specs/030-intune-rbac-backup/checklists/requirements.md b/specs/030-intune-rbac-backup/checklists/requirements.md new file mode 100644 index 0000000..c18b462 --- /dev/null +++ b/specs/030-intune-rbac-backup/checklists/requirements.md @@ -0,0 +1,12 @@ +# Requirements Checklist (030) + +**Created**: 2026-01-04 +**Feature**: [spec.md](../spec.md) + +- [ ] RBAC types are defined (policy or foundation) with `preview-only` restore. +- [ ] Graph contracts exist for role definitions/assignments. +- [ ] Inventory/backup capture works and is tenant-scoped. +- [ ] Restore preview shows dependency report and blocks unsafe execution. +- [ ] Audit logs exist for preview and any execution attempts. +- [ ] Pest tests cover inventory + backup + preview. + diff --git a/specs/030-intune-rbac-backup/plan.md b/specs/030-intune-rbac-backup/plan.md new file mode 100644 index 0000000..5534f90 --- /dev/null +++ b/specs/030-intune-rbac-backup/plan.md @@ -0,0 +1,24 @@ +# Plan: Intune RBAC Backup (Role Definitions + Assignments) (030) + +**Branch**: `feat/030-intune-rbac-backup` +**Date**: 2026-01-04 +**Input**: [spec.md](./spec.md) + +## Approach +1. Confirm Graph API details for RBAC: + - `deviceManagement/roleDefinitions` + - `deviceManagement/roleAssignments` + - required permissions, paging, and any known restrictions +2. Decide modeling: + - policy types (in Policy inventory) vs foundation types (backup-only) +3. Add config/contract entries with restore mode `preview-only`. +4. Implement snapshot capture with careful sanitization (no secrets, no tokens). +5. Implement restore preview dependency checks: + - groups referenced by assignments + - scope tags / scope members +6. Add targeted tests for inventory + backup + preview. + +## Decisions / Notes +- Default to `preview-only` for execution due to high blast radius. +- Prefer mapping by stable identifiers (roleDefinition roleKey/displayName) and treat ambiguity as a block. + diff --git a/specs/030-intune-rbac-backup/spec.md b/specs/030-intune-rbac-backup/spec.md new file mode 100644 index 0000000..d8afa30 --- /dev/null +++ b/specs/030-intune-rbac-backup/spec.md @@ -0,0 +1,51 @@ +# Feature Specification: Intune RBAC Backup (Role Definitions + Assignments) (030) + +**Feature Branch**: `feat/030-intune-rbac-backup` +**Created**: 2026-01-04 +**Status**: Draft +**Priority**: P3 (Optional) + +## Context +For a “complete tenant restore”, RBAC matters. However, RBAC restore is risky and must be **safe-by-default** (preview-only, strong warnings, explicit confirmation, audit logging). + +This feature focuses on: +- Inventory + backup/version of RBAC objects +- Restore preview and validation +- Execution only if/when safety gates and mapping are robust + +## User Scenarios & Testing + +### User Story 1 — Inventory + backup RBAC objects (Priority: P1) +As an admin, I can inventory and back up role definitions and role assignments. + +**Acceptance Scenarios** +1. Sync lists role definitions as `roleDefinition`. +2. Sync lists role assignments as `roleAssignment`. +3. Backup captures full payloads and references (scope tags, members, scopes). + +### User Story 2 — Restore preview + safety gates (Priority: P1) +As an admin, I can run a restore preview that clearly explains what would change and blocks unsafe execution. + +**Acceptance Scenarios** +1. Preview warns on built-in roles vs custom roles and blocks unsafe cases. +2. Preview validates referenced groups/scope tags and reports missing dependencies. + +## Requirements + +### Functional Requirements +- **FR-001**: Add policy (or foundation) types: + - `roleDefinition` → `deviceManagement/roleDefinitions` + - `roleAssignment` → `deviceManagement/roleAssignments` +- **FR-002**: Snapshot capture stores full payloads; assignments capture includes references. +- **FR-003**: Restore preview includes a dependency report (missing groups/tags/scopes). +- **FR-004**: Restore execution defaults to `preview-only` until safety gates are implemented. +- **FR-005**: Add targeted Pest tests for inventory + backup + preview dependency report. + +### Non-Functional Requirements +- **NFR-001**: Never auto-grant permissions/scopes; no “self-heal” background jobs. +- **NFR-002**: All operations are tenant-scoped and audited. + +## Success Criteria +- **SC-001**: RBAC objects are visible and captured in backups. +- **SC-002**: Preview makes restore risk and missing dependencies explicit. + diff --git a/specs/030-intune-rbac-backup/tasks.md b/specs/030-intune-rbac-backup/tasks.md new file mode 100644 index 0000000..5db6013 --- /dev/null +++ b/specs/030-intune-rbac-backup/tasks.md @@ -0,0 +1,29 @@ +# Tasks: Intune RBAC Backup (Role Definitions + Assignments) (030) + +**Branch**: `feat/030-intune-rbac-backup` +**Date**: 2026-01-04 +**Input**: [spec.md](./spec.md), [plan.md](./plan.md) + +## Phase 1: Setup +- [x] T001 Create spec/plan/tasks and checklist. + +## Phase 2: Research & Design +- [ ] T002 Confirm Graph endpoints, permissions, and payload shape for role definitions/assignments. +- [ ] T003 Decide whether RBAC objects are policy types or foundation types. +- [ ] T004 Define preview dependency report rules and what blocks execution. + +## Phase 3: Tests (TDD) +- [ ] T005 Add sync test importing RBAC objects (if modeled as policy types). +- [ ] T006 Add backup snapshot test for role definitions/assignments. +- [ ] T007 Add restore preview test that reports missing dependencies and blocks execution. + +## Phase 4: Implementation +- [ ] T008 Add RBAC types to `config/tenantpilot.php` (restore mode preview-only). +- [ ] T009 Add graph contracts in `config/graph_contracts.php`. +- [ ] T010 Implement snapshot capture and safe normalized display. +- [ ] T011 Implement restore preview dependency report. + +## Phase 5: Verification +- [ ] T012 Run targeted tests. +- [ ] T013 Run Pint (`./vendor/bin/pint --dirty`). + diff --git a/specs/031-tenant-portfolio-context-switch/checklists/requirements.md b/specs/031-tenant-portfolio-context-switch/checklists/requirements.md new file mode 100644 index 0000000..7834e92 --- /dev/null +++ b/specs/031-tenant-portfolio-context-switch/checklists/requirements.md @@ -0,0 +1,13 @@ +# Requirements Checklist (031) + +**Created**: 2026-01-04 +**Feature**: [spec.md](../spec.md) + +- [x] Tenant memberships/roles exist and are enforced. +- [x] Current Tenant context is per-user and always visible. +- [x] Portfolio shows only accessible tenants with environment + health/status. +- [x] “Open tenant” changes context and redirects into tenant-scoped area. +- [x] Tenant-scoped resources are filtered by context and deny unauthorized access. +- [x] Bulk “Sync selected” dispatches per-tenant jobs and is role-gated. +- [x] Restore flows show target tenant + environment and require tenant-aware confirmation. +- [x] Pest tests cover authorization + context switching + bulk actions. diff --git a/specs/031-tenant-portfolio-context-switch/plan.md b/specs/031-tenant-portfolio-context-switch/plan.md new file mode 100644 index 0000000..f32db6c --- /dev/null +++ b/specs/031-tenant-portfolio-context-switch/plan.md @@ -0,0 +1,34 @@ +# Plan: Tenant Portfolio & Context Switch (031) + +**Branch**: `feat/031-tenant-portfolio-context-switch` +**Date**: 2026-01-04 +**Input**: [spec.md](./spec.md) + +## Approach +1. Decide on the tenant context mechanism: + - Preferred: Filament tenancy (tenant in URL) + built-in tenant switcher. + - Fallback: session-based Current Tenant + visible banner (avoid global/DB state as “source of truth”). +2. Add data model pieces: + - tenant membership/role mapping + - tenant environment attribute + - optional user preferences (favorites + last used) +3. Implement a single TenantContext resolver (HTTP + console) and central authorization gate/policy: + - deny-by-default if no access + - keep `INTUNE_TENANT_ID` as console override for automation +4. Update tenant-scoped resources/services to use TenantContext instead of `Tenant::current()` and ensure base queries are tenant-scoped. +5. Extend `TenantResource` into a portfolio view: + - access-scoped query + - environment/health columns + - “Open” action + “Sync” action + - bulk “Sync selected” +6. Add restore guardrails: + - target tenant badge/header on restore pages + - type-to-confirm includes tenant/environment (e.g. `RESTORE PROD`) +7. Add targeted Pest tests for authorization, context switching, and bulk sync. +8. Run Pint + targeted tests; document rollout/migration notes. + +## Decisions / Notes +- Avoid a global `tenants.is_current` UI context (unsafe for MSP); prefer per-user context. +- Avoid storing Current Tenant in the `users` table as the source of truth (cross-tab risk); prefer route/session context, optionally persisting “last used” separately. +- Start with user-based tenant memberships; extend to organization/group principals later if needed. +- Prefer deriving portfolio stats via relationships (`withCount`, `withMax`) initially; add denormalized summary columns only if needed for performance. diff --git a/specs/031-tenant-portfolio-context-switch/spec.md b/specs/031-tenant-portfolio-context-switch/spec.md new file mode 100644 index 0000000..a538dad --- /dev/null +++ b/specs/031-tenant-portfolio-context-switch/spec.md @@ -0,0 +1,89 @@ +# Feature Specification: Tenant Portfolio & Context Switch (031) + +**Feature Branch**: `feat/031-tenant-portfolio-context-switch` +**Created**: 2026-01-04 +**Status**: Implemented (ready to merge) +**Risk**: Medium +**Priority**: P1 + +## Context +Today TenantPilot behaves like a single-tenant app: +- The “current tenant” is global (`tenants.is_current` + `Tenant::current()`), not per user. +- Most tenant-scoped screens implicitly use `Tenant::current()`. + +This is limiting and potentially unsafe for: +- Customers running multiple tenants (PROD/DEV/STAGING). +- MSPs managing many customer tenants. + +We need a tenant-agnostic **Portfolio** view plus an explicit, always-visible **Current Tenant** context for all tenant-scoped areas (Policies, Backups, Restore Runs, etc.). + +## Design Considerations (Best Practice) +- Prefer an explicit tenant context (route parameter or session) over hidden global state. +- Avoid storing Current Tenant in the `users` table as the source of truth (cross-tab risk). If persistence is needed, store **“last used”** separately and treat it as a default for new sessions. +- Keep console/automation behavior stable: `INTUNE_TENANT_ID` can remain a console override, but tenant-scoped UI must not depend on it. + +## User Scenarios & Testing + +### User Story 1 — Portfolio overview (P1) +As a user with access to multiple tenants, I can see a portfolio overview with health/status and key counts. + +**Acceptance Scenarios** +1. Tenants list shows only tenants the user can access. +2. Portfolio shows environment badge (PROD/DEV/STAGING/OTHER) and connection/health indicators. +3. Portfolio columns can be filtered by environment and connection status. + +### User Story 2 — Safe tenant context switching (P1) +As a user, I can switch the Current Tenant via a topbar switcher or by clicking “Open” in the portfolio, and all tenant-scoped screens reflect that tenant. + +**Acceptance Scenarios** +1. Switching tenant updates the visible Current Tenant badge and redirects to a default tenant-scoped landing page (e.g. Policies). +2. Policies, Backups, Restore Runs, and Policy Versions are scoped to the selected tenant. +3. Restore flows always show the target tenant and environment prominently and require tenant-aware type-to-confirm. + +### User Story 3 — Multi-tenant bulk actions (P2) +As an operator, I can select multiple tenants in the portfolio and run safe bulk actions (initially Sync). + +**Acceptance Scenarios** +1. Bulk “Sync selected” dispatches a sync job per tenant (batch) and shows progress. +2. Readonly users cannot trigger bulk sync. + +### User Story 4 — Authorization hardening (P1) +As a user, I cannot access tenants or tenant-scoped data I am not authorized for. + +**Acceptance Scenarios** +1. Attempting to open a tenant without access is denied (403) and does not change Current Tenant. +2. Direct URL access to tenant-scoped pages for an unauthorized tenant returns 403/404. + +## Requirements + +### Functional Requirements +- **FR-001**: Introduce a per-user Current Tenant context for all tenant-scoped screens. +- **FR-002**: Current Tenant context must be always visible in the UI (topbar) to reduce “wrong tenant” operations. +- **FR-003**: Add an “Open” action from the portfolio to set Current Tenant and redirect into the tenant-scoped area. +- **FR-004**: Portfolio view is tenant-agnostic and supports filtering, search, and safe bulk actions. +- **FR-005**: Tenant access is enforced centrally (single `canAccessTenant(...)` gate/policy used by UI + routes + services). +- **FR-006**: Restore remains single-tenant; restore actions must include explicit tenant/environment confirmations and never rely on hidden global context. +- **FR-007**: Bulk Sync is tenant-safe: per-tenant authorization, per-tenant job execution, and audit logs for each tenant sync trigger. + +### UX / UI Requirements +- **UX-001**: Topbar shows “Tenant: ” with an environment badge (PROD/DEV/STAGING/OTHER) and is accessible from all tenant-scoped pages. +- **UX-002**: Tenant switcher is searchable (typeahead); favorites (if enabled) appear at the top. +- **UX-003**: Portfolio table includes (at minimum): Name, Tenant ID (short/copy), Environment, Connection/App status, RBAC/Health indicator, Last Sync (time), Policies count; optional Restore runs (last 30d). +- **UX-004**: Portfolio “Open” action makes the tenant context explicit and navigates into the tenant-scoped area. +- **UX-005**: Restore screens show “Target Tenant” prominently (name + environment badge) and require tenant-aware type-to-confirm (e.g. `RESTORE PROD`). + +### Data Model Requirements +- **DM-001**: Introduce tenant access/membership mapping (user ↔ tenant) with a role (`owner|manager|operator|readonly`). +- **DM-002**: Add tenant environment classification (`prod|dev|staging|other`) as a first-class attribute (column or indexed JSONB). +- **DM-003 (Optional)**: Persist per-user tenant preferences (favorites + last used) without coupling it to cross-tab safety. +- **DM-004 (Optional)**: Support grouping tenants by customer (MSP use case) via a lightweight “customer label” or a dedicated Customer model (future). + +## Non-Goals +- No multi-tenant policy detail view in one screen. +- No multi-tenant restore; restore run always targets exactly one tenant. +- No cross-tenant diff/promotion (separate feature). + +## Success Criteria +- **SC-001**: A user can switch Current Tenant quickly and always understands which tenant they are operating on. +- **SC-002**: All tenant-scoped data is strictly filtered and authorization-safe. +- **SC-003**: Bulk Sync works across selected tenants with clear feedback and role gating. diff --git a/specs/031-tenant-portfolio-context-switch/tasks.md b/specs/031-tenant-portfolio-context-switch/tasks.md new file mode 100644 index 0000000..70acd11 --- /dev/null +++ b/specs/031-tenant-portfolio-context-switch/tasks.md @@ -0,0 +1,33 @@ +# Tasks: Tenant Portfolio & Context Switch (031) + +**Branch**: `feat/031-tenant-portfolio-context-switch` +**Date**: 2026-01-04 +**Input**: [spec.md](./spec.md), [plan.md](./plan.md) + +## Phase 1: Setup +- [x] T001 Create spec/plan/tasks and checklist. + +## Phase 2: Research & Design +- [x] T002 Review Filament tenancy support and choose the context mechanism (route vs session). +- [x] T003 Define tenant access roles and mapping (user memberships; future org/group principals). +- [x] T004 Decide how to store `environment` (column vs JSONB) and whether MSP “customer grouping” is in scope. +- [x] T005 Define context precedence rules (env override, route tenant, session/default tenant) and cross-tab safety expectations. + +## Phase 3: Tests (TDD) +- [x] T006 Authorization: user cannot access unauthorized tenant (404). +- [x] T007 Authorization: tenant-scoped resources deny cross-tenant access via URL (404). +- [x] T008 Context switching: “Open tenant” navigates into tenant-scoped pages (tenant in URL) and data filters correctly. +- [x] T009 Bulk sync: dispatches one job per selected tenant; readonly role cannot run it. +- [ ] T010 UI (optional browser tests): tenant switcher visible and environment badge shown. + +## Phase 4: Implementation +- [x] T011 Add migrations for tenant memberships/roles and environment attribute (and optional preferences). +- [x] T012 Implement `TenantContext` + authorization gate/policy (`canAccessTenant`). +- [x] T013 Integrate tenant switcher into Filament topbar and make Current Tenant always visible. +- [x] T014 Scope tenant resources (Policies/Backups/RestoreRuns/etc.) via TenantContext; replace direct `Tenant::current()` usage. +- [x] T015 Update `TenantResource` into a portfolio view: access-scoped query, columns, filters, “Open”, “Sync”, bulk “Sync selected”. +- [x] T016 Add restore guardrails (target tenant header + tenant-aware confirmations). + +## Phase 5: Verification +- [x] T017 Run targeted tests. +- [x] T018 Run Pint (`./vendor/bin/pint --dirty`). diff --git a/specs/032-backup-scheduling-mvp/checklists/requirements.md b/specs/032-backup-scheduling-mvp/checklists/requirements.md new file mode 100644 index 0000000..82de8d6 --- /dev/null +++ b/specs/032-backup-scheduling-mvp/checklists/requirements.md @@ -0,0 +1,13 @@ +# Requirements Checklist (032) +- [X] Tenant-scoped tables use `tenant_id` consistently. (Data model section in spec.md documents tenant_id on `backup_schedules` and `backup_schedule_runs`.) +- [X] 1 Run = 1 BackupSet (no rolling reuse in MVP). (Definitions + Goals in spec.md state the MVP semantics explicitly.) +- [X] Dispatcher is idempotent (unique schedule_id + scheduled_for). (Requirements FR-002 + FR-007 + plan's idempotent dispatch constraint specify unique slots.) +- [X] Concurrency lock prevents parallel runs per schedule. (FR-008 and plan note per-schedule concurrency lock; tasks T024/Run job mention locking.) +- [X] Run stores status + summary + error_code/error_message. (FR-004 and data model show these fields exist in `backup_schedule_runs`.) +- [X] UI shows schedule list + run history + link to backup set. (UX-001/UX-002 in spec, tasks T014 / relation managers + UI doc.) +- [X] Run now + Retry are permission-gated and write DB notifications. (SEC-002 + tasks T031-T034 describe Filament actions + notifications.) +- [X] Audit logs are written for dispatcher, runs, and retention (tenant-scoped; no secrets). (SEC-003 plus tasks T026/T033/T034 mention audit logging.) +- [X] Retry/backoff policy implemented (no retry for 401/403). (NFR-003 and tasks T025 mention retry/backoff rules.) +- [X] Retention keeps last N and soft-deletes older backup sets. (FR-007 + tasks T033/T034 describe retention job & soft delete.) +- [X] Tests cover due-calculation, idempotency, job success/failure, retention. (Tasks T011-T037 include Pest tests for due calculation, idempotency, job outcomes, and retention.) +- [X] Retention keeps last N and soft-deletes older backup sets. (FR-007 + tasks T033/T034 describe retention job & soft delete.) diff --git a/specs/032-backup-scheduling-mvp/contracts/backup-scheduling.openapi.yaml b/specs/032-backup-scheduling-mvp/contracts/backup-scheduling.openapi.yaml new file mode 100644 index 0000000..982f465 --- /dev/null +++ b/specs/032-backup-scheduling-mvp/contracts/backup-scheduling.openapi.yaml @@ -0,0 +1,204 @@ +openapi: 3.0.3 +info: + title: TenantPilot Backup Scheduling (Spec 032) + version: "0.1" + description: | + Conceptual contract for Backup Scheduling MVP. TenantPilot uses Filament/Livewire; + these endpoints describe behavior for review/testing and future API alignment. +servers: + - url: https://{host} + variables: + host: + default: example.local + +paths: + /tenants/{tenantId}/backup-schedules: + get: + summary: List backup schedules for a tenant + parameters: + - $ref: '#/components/parameters/TenantId' + responses: + '200': + description: OK + content: + application/json: + schema: + type: array + items: + $ref: '#/components/schemas/BackupSchedule' + post: + summary: Create a backup schedule + parameters: + - $ref: '#/components/parameters/TenantId' + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/BackupScheduleCreate' + responses: + '201': + description: Created + content: + application/json: + schema: + $ref: '#/components/schemas/BackupSchedule' + '422': + description: Validation error (e.g. unknown policy_types) + + /tenants/{tenantId}/backup-schedules/{scheduleId}: + patch: + summary: Update a backup schedule + parameters: + - $ref: '#/components/parameters/TenantId' + - $ref: '#/components/parameters/ScheduleId' + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/BackupScheduleUpdate' + responses: + '200': + description: OK + content: + application/json: + schema: + $ref: '#/components/schemas/BackupSchedule' + '422': + description: Validation error + delete: + summary: Delete (or disable) a schedule + parameters: + - $ref: '#/components/parameters/TenantId' + - $ref: '#/components/parameters/ScheduleId' + responses: + '204': + description: Deleted + + /tenants/{tenantId}/backup-schedules/{scheduleId}/run-now: + post: + summary: Trigger a run immediately + parameters: + - $ref: '#/components/parameters/TenantId' + - $ref: '#/components/parameters/ScheduleId' + responses: + '202': + description: Accepted (run created and job dispatched) + content: + application/json: + schema: + $ref: '#/components/schemas/BackupScheduleRun' + + /tenants/{tenantId}/backup-schedules/{scheduleId}/retry: + post: + summary: Create a new run as retry + parameters: + - $ref: '#/components/parameters/TenantId' + - $ref: '#/components/parameters/ScheduleId' + responses: + '202': + description: Accepted + content: + application/json: + schema: + $ref: '#/components/schemas/BackupScheduleRun' + + /tenants/{tenantId}/backup-schedules/{scheduleId}/runs: + get: + summary: List runs for a schedule + parameters: + - $ref: '#/components/parameters/TenantId' + - $ref: '#/components/parameters/ScheduleId' + responses: + '200': + description: OK + content: + application/json: + schema: + type: array + items: + $ref: '#/components/schemas/BackupScheduleRun' + +components: + parameters: + TenantId: + name: tenantId + in: path + required: true + schema: + type: integer + ScheduleId: + name: scheduleId + in: path + required: true + schema: + type: integer + + schemas: + BackupSchedule: + type: object + required: [id, tenant_id, name, is_enabled, timezone, frequency, time_of_day, policy_types, retention_keep_last] + properties: + id: { type: integer } + tenant_id: { type: integer } + name: { type: string } + is_enabled: { type: boolean } + timezone: { type: string, example: "Europe/Berlin" } + frequency: { type: string, enum: [daily, weekly] } + time_of_day: { type: string, example: "02:00:00" } + days_of_week: + type: array + nullable: true + items: { type: integer, minimum: 1, maximum: 7 } + policy_types: + type: array + items: { type: string } + description: Must be keys from config('tenantpilot.supported_policy_types'). + include_foundations: { type: boolean } + retention_keep_last: { type: integer, minimum: 1 } + last_run_at: { type: string, format: date-time, nullable: true } + last_run_status: { type: string, nullable: true } + next_run_at: { type: string, format: date-time, nullable: true } + + BackupScheduleCreate: + allOf: + - $ref: '#/components/schemas/BackupScheduleUpdate' + - type: object + required: [name, timezone, frequency, time_of_day, policy_types] + + BackupScheduleUpdate: + type: object + properties: + name: { type: string } + is_enabled: { type: boolean } + timezone: { type: string } + frequency: { type: string, enum: [daily, weekly] } + time_of_day: { type: string } + days_of_week: + type: array + nullable: true + items: { type: integer, minimum: 1, maximum: 7 } + policy_types: + type: array + items: { type: string } + include_foundations: { type: boolean } + retention_keep_last: { type: integer, minimum: 1 } + + BackupScheduleRun: + type: object + required: [id, backup_schedule_id, tenant_id, scheduled_for, status] + properties: + id: { type: integer } + backup_schedule_id: { type: integer } + tenant_id: { type: integer } + scheduled_for: { type: string, format: date-time } + started_at: { type: string, format: date-time, nullable: true } + finished_at: { type: string, format: date-time, nullable: true } + status: { type: string, enum: [running, success, partial, failed, canceled, skipped] } + summary: + type: object + additionalProperties: true + error_code: { type: string, nullable: true } + error_message: { type: string, nullable: true } + backup_set_id: { type: integer, nullable: true } diff --git a/specs/032-backup-scheduling-mvp/data-model.md b/specs/032-backup-scheduling-mvp/data-model.md new file mode 100644 index 0000000..6231f73 --- /dev/null +++ b/specs/032-backup-scheduling-mvp/data-model.md @@ -0,0 +1,98 @@ +# Data Model: Backup Scheduling MVP (032) + +**Date**: 2026-01-05 + +This document describes the entities, relationships, validation rules, and state transitions derived from the feature spec. + +## Entities + +### 1) BackupSchedule (`backup_schedules`) + +**Purpose**: Defines a tenant-scoped recurring backup plan. + +**Fields** +- `id` (bigint, PK) +- `tenant_id` (FK → `tenants.id`, required) +- `name` (string, required) +- `is_enabled` (bool, default true) +- `timezone` (string, required; default `UTC`) +- `frequency` (enum: `daily|weekly`, required) +- `time_of_day` (time, required) +- `days_of_week` (json, nullable; required when `frequency=weekly`) + - array in range 1..7 (Mon..Sun) +- `policy_types` (jsonb, required) + - array; keys MUST exist in `config('tenantpilot.supported_policy_types')` +- `include_foundations` (bool, default true) +- `retention_keep_last` (int, default 30) +- `last_run_at` (datetime, nullable) +- `last_run_status` (string, nullable) +- `next_run_at` (datetime, nullable) +- timestamps + +**Indexes** +- `(tenant_id, is_enabled)` +- optional `(next_run_at)` + +**Validation Rules (MVP)** +- `tenant_id`: required, exists +- `name`: required, max length (e.g. 255) +- `timezone`: required, valid IANA tz +- `frequency`: required, in `[daily, weekly]` +- `time_of_day`: required +- `days_of_week`: required if weekly; values 1..7; unique values +- `policy_types`: required, array, min 1; all values in supported types config +- `retention_keep_last`: required, int, min 1 + +**State** +- Enabled/disabled (`is_enabled`) + +--- + +### 2) BackupScheduleRun (`backup_schedule_runs`) + +**Purpose**: Represents one execution attempt of a schedule. + +**Fields** +- `id` (bigint, PK) +- `backup_schedule_id` (FK → `backup_schedules.id`, required) +- `tenant_id` (FK → `tenants.id`, required; denormalized) +- `scheduled_for` (datetime, required; UTC minute-slot) +- `started_at` (datetime, nullable) +- `finished_at` (datetime, nullable) +- `status` (enum: `running|success|partial|failed|canceled|skipped`, required) +- `summary` (jsonb, required) + - suggested keys: + - `policies_total` (int) + - `policies_backed_up` (int) + - `errors_count` (int) + - `type_breakdown` (object) + - `warnings` (array) + - `unknown_policy_types` (array) +- `error_code` (string, nullable) +- `error_message` (text, nullable) +- `backup_set_id` (FK → `backup_sets.id`, nullable) +- timestamps + +**Indexes** +- `(backup_schedule_id, scheduled_for)` +- `(tenant_id, created_at)` +- unique `(backup_schedule_id, scheduled_for)` (idempotency) + +**State transitions** +- `running` → `success|partial|failed|skipped|canceled` + +--- + +## Relationships + +- Tenant `hasMany` BackupSchedule +- BackupSchedule `belongsTo` Tenant +- BackupSchedule `hasMany` BackupScheduleRun +- BackupScheduleRun `belongsTo` BackupSchedule +- BackupScheduleRun `belongsTo` Tenant +- BackupScheduleRun `belongsTo` BackupSet (nullable) + +## Notes + +- `BackupSet` and `BackupItem` already support soft deletes in this repo; retention can soft-delete old backup sets. +- Unknown policy types are prevented at save-time, but runs defensively re-check to handle legacy DB data. diff --git a/specs/032-backup-scheduling-mvp/plan.md b/specs/032-backup-scheduling-mvp/plan.md new file mode 100644 index 0000000..ad64e4d --- /dev/null +++ b/specs/032-backup-scheduling-mvp/plan.md @@ -0,0 +1,85 @@ +# Implementation Plan: Backup Scheduling MVP (032) + +**Branch**: `feat/032-backup-scheduling-mvp` | **Date**: 2026-01-05 | **Spec**: specs/032-backup-scheduling-mvp/spec.md +**Input**: Feature specification from `specs/032-backup-scheduling-mvp/spec.md` + +## Summary + +Implement tenant-scoped backup schedules that dispatch idempotent runs every minute via Laravel scheduler and queue workers. Each run syncs selected policy types from Graph into the local DB (via existing `PolicySyncService`) and creates an immutable `BackupSet` snapshot (via existing `BackupService`), with strict audit logging, fail-safe handling for unknown policy types, retention (keep last N), and Filament UI for managing schedules and viewing run history. + +## Technical Context + +**Language/Version**: PHP 8.4.15 +**Primary Dependencies**: Laravel 12, Filament v4, Livewire v3 +**Storage**: PostgreSQL (Sail locally) +**Testing**: Pest v4 +**Target Platform**: Containerized (Sail local), Dokploy deploy (staging/prod) +**Project Type**: Web application (Laravel monolith + Filament admin) +**Performance Goals**: Scheduler runs every minute; per-run work is queued; avoid long locks +**Constraints**: Idempotent dispatch (unique slot), per-schedule concurrency lock, no secrets/tokens in logs, “no catch-up” policy +**Scale/Scope**: Multi-tenant MSP use; schedules per tenant; runs stored for audit/history + +## Constitution Check + +*GATE: Must pass before Phase 0 research. Re-check after Phase 1 design.* + +- Safety-First Restore: PASS (feature is backup-only; no restore scheduling) +- Auditability & Tenant Isolation: PASS (tenant_id everywhere; audit log entries for dispatch/run/retention) +- Graph Abstraction & Contracts: PASS (sync uses `GraphClientInterface` via `PolicySyncService`; unknown policy types fail-safe; no hardcoded endpoints) +- Least Privilege: PASS (authorization via TenantRole matrix; no new scopes required beyond existing backup/sync) +- Spec-First Workflow: PASS (spec/plan/tasks/checklist in `specs/032-backup-scheduling-mvp/`) +- Quality Gates: PASS (tasks include Pest coverage per constitution and Pint) + +## Project Structure + +### Documentation (this feature) + +```text +specs/032-backup-scheduling-mvp/ +├── plan.md # This file (/speckit.plan output) +├── research.md # Phase 0 output +├── data-model.md # Phase 1 output +├── quickstart.md # Phase 1 output +├── contracts/ # Phase 1 output +└── tasks.md # Phase 2 output (already present) +``` + +### Source Code (repository root) + +```text +app/ +├── Console/Commands/ +├── Filament/Resources/ +├── Jobs/ +├── Models/ +└── Services/ + +config/ +database/migrations/ +routes/console.php +tests/ +``` + +Expected additions for this feature (at implementation time): + +```text +app/Console/Commands/TenantpilotDispatchBackupSchedules.php +app/Jobs/RunBackupScheduleJob.php +app/Jobs/ApplyBackupScheduleRetentionJob.php +app/Models/BackupSchedule.php +app/Models/BackupScheduleRun.php +app/Filament/Resources/BackupScheduleResource.php +database/migrations/*_create_backup_schedules_table.php +database/migrations/*_create_backup_schedule_runs_table.php +tests/Feature/BackupScheduling/* +tests/Unit/BackupScheduling/* +``` + +**Structure Decision**: Laravel monolith (Filament admin + queued jobs). No new top-level app folders. + +## Phase Outputs + +- Phase 0 (Outline & Research): `research.md` +- Phase 1 (Design & Contracts): `data-model.md`, `contracts/*`, `quickstart.md` +- Phase 2 (Tasks): `tasks.md` already exists; will be refined later via `/speckit.tasks` if needed +- Phase 1 (Design & Contracts): `data-model.md`, `contracts/*`, `quickstart.md` diff --git a/specs/032-backup-scheduling-mvp/quickstart.md b/specs/032-backup-scheduling-mvp/quickstart.md new file mode 100644 index 0000000..c853444 --- /dev/null +++ b/specs/032-backup-scheduling-mvp/quickstart.md @@ -0,0 +1,71 @@ +# Quickstart: Backup Scheduling MVP (032) + +This is a developer/operator quickstart for running the scheduling MVP locally with Sail. + +## Prerequisites + +- Laravel Sail running +- Database migrated +- Queue worker running +- Scheduler running (or run the dispatch command manually) + +## Local setup (Sail) + +1) Start Sail + +- `./vendor/bin/sail up -d` + +2) Run migrations + +- `./vendor/bin/sail php artisan migrate` + +3) Start a queue worker + +- `./vendor/bin/sail php artisan queue:work` + +## Run the dispatcher manually (MVP) + +Once a schedule exists, you can dispatch due runs: + +- `./vendor/bin/sail php artisan tenantpilot:schedules:dispatch` + +Optional: limit dispatching to specific tenants: + +- `./vendor/bin/sail php artisan tenantpilot:schedules:dispatch --tenant=` + +## Run the Laravel scheduler + +Recommended operations model: + +- Dev/local: run `schedule:work` in a separate terminal + - `./vendor/bin/sail php artisan schedule:work` + +- Production/staging (Dokploy): cron every minute + - `* * * * * php artisan schedule:run` + +## Create a schedule (Filament) + +- Log into Filament admin +- Switch into a tenant context +- Create a Backup Schedule: + - frequency: daily/weekly + - time + timezone + - policy_types: pick from supported types + - retention_keep_last + - include_foundations + +## Verify outcomes + +- In the schedule list: check `Last Run` and `Next Run` +- In run history: verify status, duration, error_code/message +- For successful/partial runs: verify a linked `BackupSet` exists + +Retention + +- After a successful/partial run creates a `BackupSet`, the retention job runs asynchronously and soft-deletes older `BackupSet`s so only the last N (per schedule) remain. + +## Notes + +- Unknown `policy_types` cannot be saved; legacy DB values are handled fail-safe at runtime. +- Scheduled runs do not notify a user; interactive actions (Run now / Retry) should persist a DB notification for the acting user. +- Run now / Retry actions are available for `operator`+ roles. diff --git a/specs/032-backup-scheduling-mvp/research.md b/specs/032-backup-scheduling-mvp/research.md new file mode 100644 index 0000000..5e958b0 --- /dev/null +++ b/specs/032-backup-scheduling-mvp/research.md @@ -0,0 +1,77 @@ +# Research: Backup Scheduling MVP (032) + +**Date**: 2026-01-05 + +This document resolves technical decisions and clarifies implementation approach for Feature 032. + +## Decisions + +### 1) Reuse existing sync + backup services +- **Decision**: Use `App\Services\Intune\PolicySyncService::syncPoliciesWithReport(Tenant $tenant, ?array $supportedTypes = null): array` and `App\Services\Intune\BackupService::createBackupSet(...)`. +- **Rationale**: These are already tenant-aware, use `GraphClientInterface` behind the scenes (via `PolicySyncService`), and `BackupService` already writes a `backup.created` audit log entry. +- **Alternatives considered**: + - Implement new Graph calls directly in the scheduler job → rejected (violates Graph abstraction gate; duplicates logic). + +### 2) Policy type source of truth + validation +- **Decision**: + - Persist `backup_schedules.policy_types` as `array` of **type keys** present in `config('tenantpilot.supported_policy_types')`. + - **Hard validation at save-time**: unknown keys are rejected. + - **Runtime defensive check** (legacy/DB): unknown keys are skipped. + - If ≥1 valid type remains → run becomes `partial` and `error_code=UNKNOWN_POLICY_TYPE`. + - If 0 valid types remain → run becomes `skipped` and `error_code=UNKNOWN_POLICY_TYPE` (no `BackupSet` created). +- **Rationale**: Prevent silent misconfiguration and enforce fail-safe behavior at entry points, while still handling legacy data safely. +- **Alternatives considered**: + - Save unknown keys and ignore silently → rejected (silent misconfiguration). + - Fail the run for any unknown type → rejected (too brittle for legacy). + +### 3) Graph calls and contracts +- **Decision**: Do not hardcode Graph endpoints. All Graph access happens via `GraphClientInterface` (through `PolicySyncService` and `BackupService`). +- **Rationale**: Matches constitution requirements and existing code paths. +- **Alternatives considered**: + - Calling `deviceManagement/{type}` directly → rejected (explicitly forbidden by constitution; also unsafe for unknown types). + +### 4) Scheduling mechanism +- **Decision**: Add an Artisan command `tenantpilot:schedules:dispatch` and register it with Laravel scheduler to run every minute. +- **Rationale**: Fits Laravel 12 structure (no Kernel), supports Dokploy operation models (`schedule:run` cron or `schedule:work`). +- **Alternatives considered**: + - Long-running daemon polling DB directly → rejected (less idiomatic; harder ops). + +### 5) Due calculation + time semantics +- **Decision**: + - `scheduled_for` is minute-slot based and stored in UTC. + - Due calculation uses the schedule timezone. + - DST (MVP): invalid local time → skip; ambiguous local time → first occurrence. +- **Rationale**: Predictable and testable; avoids “surprise catch-up”. +- **Alternatives considered**: + - Catch-up missed slots → rejected by spec (MVP explicitly “no catch-up”). + +### 6) Idempotency + concurrency +- **Decision**: + - DB unique constraint: `(backup_schedule_id, scheduled_for)`. + - Cache lock per schedule (`lock:backup_schedule:{id}`) to prevent parallel execution. + - If lock held, do not run in parallel: mark run `skipped` with a clear error_code. +- **Rationale**: Prevents double runs and provides deterministic behavior. +- **Alternatives considered**: + - Only cache lock (no DB constraint) → rejected (less robust under crashes/restarts). + +### 7) Retry/backoff policy +- **Decision**: + - Transient/throttling failures (e.g. 429/503) → retries with backoff. + - Auth/permission failures (401/403) → no retry. + - Unknown failures → limited retries, then fail. +- **Rationale**: Avoid noisy retry loops for non-recoverable errors. + +### 8) Audit logging +- **Decision**: Use `App\Services\Intune\AuditLogger` for: + - dispatch cycle (optional aggregated) + - run start + completion + - retention applied (count deletions) +- **Rationale**: Constitution requires audit log for every operation; existing `BackupService` already writes `backup.created`. + +### 9) Notifications +- **Decision**: Only interactive actions (Run now / Retry) notify the acting user (database notifications). Scheduled runs rely on Run history. +- **Rationale**: Avoid undefined “who gets notified” without adding new ownership fields. + +## Open Items + +None blocking Phase 1 design. diff --git a/specs/032-backup-scheduling-mvp/spec.md b/specs/032-backup-scheduling-mvp/spec.md new file mode 100644 index 0000000..45bfd5e --- /dev/null +++ b/specs/032-backup-scheduling-mvp/spec.md @@ -0,0 +1,130 @@ +# Feature Specification: Backup Scheduling MVP (032) + +**Feature**: Automatisierte Backups per Zeitplan (pro Tenant) +**Created**: 2026-01-05 +**Status**: Ready for implementation (MVP) +**Risk**: Medium (Backup-only, no restore scheduling) +**Dependencies**: Tenant Portfolio + Tenant Context Switch ✅ + +## Context +TenantPilot unterstützt manuelle Backups. Kunden/MSPs benötigen regelmäßige, zuverlässige Backups pro Tenant (z. B. nightly), inkl. nachvollziehbarer Runs, Fehlercodes und Retention. + +## Goals +- Pro Tenant können 1..n Backup Schedules angelegt werden. +- Schedules laufen automatisch via Queue/Worker. +- Jeder Lauf wird als Run auditierbar gespeichert (Status, Counts, Fehler). +- Retention löscht alte Backups nach Policy. +- Filament UI: Schedules verwalten, Run-History ansehen, “Run now”, “Retry”. + +## Clarifications + +### Session 2026-01-05 +- Q: Wie sollen wir mit `policy_types` umgehen, die nicht in `config('tenantpilot.supported_policy_types')` enthalten sind? + → A: Beim Speichern hart validieren und ablehnen; zur Laufzeit defensiv re-checken (Legacy/DB), unknown types skippen und Run als `partial` markieren mit `error_code=UNKNOWN_POLICY_TYPE` und Liste betroffener Types. +- Q: Wenn zur Laufzeit alle `policy_types` unbekannt sind (0 valid types nach Skip) – welcher Status? + → A: `skipped` (fail-safe). + +## Non-Goals (MVP) +- Kein Kalender-UI als Pflicht (kann später ergänzt werden). +- Kein Cross-Tenant Bulk Scheduling (MSP-Templates später). +- Kein “drift-triggered scheduling” (kommt nach Drift-MVP). +- Kein Restore via Scheduling (nur Backup). + +## Definitions +- **Schedule**: Wiederkehrender Plan (daily/weekly, timezone). +- **Run**: Konkrete Ausführung eines Schedules (scheduled_for + status). +- **BackupSet**: Ergebniscontainer eines Runs. + +**MVP Semantik**: **1 Run = 1 neues BackupSet** (kein Rolling-Reuse im MVP). + +## Requirements + +### Functional Requirements +- **FR-001**: Schedules sind tenant-scoped via `tenant_id` (FK auf `tenants.id`). +- **FR-002**: Dispatcher erkennt “due” schedules und erstellt genau einen Run pro Zeit-Slot (idempotent). +- **FR-003**: Run nutzt bestehende Services: + - Sync Policies (nur selektierte policy types) + - Create BackupSet aus lokalen Policy-IDs (inkl. Foundations optional) +- **FR-003a**: `policy_types` sind ausschließlich Keys aus `config('tenantpilot.supported_policy_types')`. +- **FR-003b**: UI/Server-side Validation verhindert das Speichern unbekannter `policy_types`. +- **FR-003c**: Laufzeit-Validierung (defensiv): Unbekannte `policy_types` werden geskippt; wenn mindestens ein gültiger Type verarbeitet wurde, wird der Run als `partial` markiert und `error_code=UNKNOWN_POLICY_TYPE` gesetzt (inkl. Liste der betroffenen Types in `summary`). +- **FR-003d**: Wenn zur Laufzeit nach dem Skip **0 gültige Types** verbleiben, wird **kein BackupSet** erzeugt und der Run als `skipped` markiert (mit `error_code=UNKNOWN_POLICY_TYPE` und Liste der betroffenen Types in `summary`). +- **FR-004**: Run schreibt `backup_schedule_runs` mit Status + Summary + Error-Codes. +- **FR-005**: “Run now” erzeugt sofort einen Run (scheduled_for=now) und dispatcht Job. +- **FR-006**: “Retry” erzeugt einen neuen Run für denselben Schedule. +- **FR-007**: Retention hält nur die letzten N BackupSets pro Schedule (soft delete BackupSets). +- **FR-008**: Concurrency: Pro Schedule darf nur ein Run gleichzeitig laufen. Wenn bereits ein Run läuft, wird ein neuer Run nicht parallel gestartet und stattdessen als `skipped` markiert (mit Fehlercode). + +### UX Requirements (Filament) +- **UX-001**: Schedule-Liste zeigt Enabled, Frequency, Time+Timezone, Policy Types Summary, Retention, Last Run, Next Run. +- **UX-002**: Run-History pro Schedule zeigt scheduled_for, status, duration, counts, error_code/message, Link zum BackupSet. +- **UX-003**: “Run now” und “Retry” sind nur mit passenden Rechten verfügbar. + +### Security / Authorization +- **SEC-001**: Tenant Isolation: User sieht/managt nur Schedules des aktuellen Tenants. +- **SEC-002 (MVP)**: Authorization erfolgt über TenantRole (wie Tenant Portfolio): + - `readonly`: Schedules ansehen + Runs ansehen + - `operator`: zusätzlich “Run now” / “Retry” + - `manager` / `owner`: zusätzlich Schedules verwalten (CRUD) +- **SEC-003**: Dispatcher, Run-Execution und Retention schreiben tenant-scoped Audit Logs (keine Secrets/Tokens), inkl. Run-Start/Run-Ende und Retention-Ergebnis (z. B. Anzahl gelöschter BackupSets). + +### Reliability / Non-Functional Requirements +- **NFR-001**: Idempotency durch Unique Slot-Constraint (`backup_schedule_id` + `scheduled_for`). +- **NFR-002**: Klare Fehlercodes (z. B. TOKEN_EXPIRED, PERMISSION_MISSING, GRAPH_THROTTLE, UNKNOWN). +- **NFR-003**: Retries: Throttling (z. B. 429/503) → Backoff; 401/403 → kein Retry; Unknown → begrenzte Retries und danach failed. +- **NFR-004**: Missed runs policy (MVP): **No catch-up** — wenn offline, wird nicht nachgeholt, nur nächster Slot. + +### Scheduling Semantics +- `scheduled_for` ist **minute-basiert** (Slot), in UTC gespeichert. Due-Berechnung erfolgt in der Schedule-Timezone. +- DST (MVP): Bei ungültiger lokaler Zeit wird der Slot übersprungen (Run `skipped`). Bei ambiger lokaler Zeit wird die erste Occurrence verwendet. + +## Data Model + +### backup_schedules +- `id` bigint +- `tenant_id` FK tenants.id +- `name` string +- `is_enabled` bool default true +- `timezone` string default 'UTC' +- `frequency` string enum: daily|weekly +- `time_of_day` time +- `days_of_week` json nullable (array, weekly only; 1=Mon..7=Sun) +- `policy_types` jsonb (array) +- `include_foundations` bool default true +- `retention_keep_last` int default 30 +- `last_run_at` datetime nullable +- `last_run_status` string nullable +- `next_run_at` datetime nullable +- timestamps + +Indexes: +- (tenant_id, is_enabled) +- (next_run_at) optional + +### backup_schedule_runs +- `id` bigint +- `backup_schedule_id` FK +- `tenant_id` FK (denormalisiert) +- `scheduled_for` datetime +- `started_at` datetime nullable +- `finished_at` datetime nullable +- `status` string enum: running|success|partial|failed|canceled|skipped +- `summary` jsonb (policies_total, policies_backed_up, errors_count, type_breakdown, warnings) +- `error_code` string nullable +- `error_message` text nullable +- `backup_set_id` FK nullable +- timestamps + +Indexes: +- (backup_schedule_id, scheduled_for) +- (tenant_id, created_at) +- **Unique**: (backup_schedule_id, scheduled_for) + +## Acceptance Criteria +- User kann pro Tenant einen Schedule anlegen (daily/weekly, time, timezone, policy types, retention). +- Dispatcher erstellt Runs zur geplanten Zeit (Queue Worker vorausgesetzt). +- UI zeigt Last Run + Next Run + Run-History. +- Run now startet sofort. +- Fehlerfälle (Token/Permission/Throttle) werden als failed/partial markiert mit error_code. +- Unbekannte `policy_types` können nicht gespeichert werden; falls Legacy-Daten vorkommen, werden sie zur Laufzeit geskippt: mit valid types → `partial`, ohne valid types → `skipped` (jeweils `error_code=UNKNOWN_POLICY_TYPE`). +- Retention hält nur die letzten N BackupSets pro Schedule. diff --git a/specs/032-backup-scheduling-mvp/tasks.md b/specs/032-backup-scheduling-mvp/tasks.md new file mode 100644 index 0000000..ee1571c --- /dev/null +++ b/specs/032-backup-scheduling-mvp/tasks.md @@ -0,0 +1,115 @@ +--- +description: "Task list for feature implementation" +--- + +# Tasks: Backup Scheduling MVP (032) + +**Input**: Design documents from `specs/032-backup-scheduling-mvp/` +**Prerequisites**: plan.md (required), spec.md (required for user stories), research.md, data-model.md, contracts/, quickstart.md + +**Tests**: Required by constitution quality gate (Pest) even for MVP. + +## Format: `[ID] [P?] [Story] Description` + +- **[P]**: Can run in parallel (different files, no dependencies) +- **[Story]**: Which user story this task belongs to (US1/US2/US3) +- Every task description includes at least one explicit file path + +## User Stories + +- **US1 (P1)**: Manage tenant-scoped backup schedules (CRUD, validation). +- **US2 (P1)**: Dispatch + execute runs idempotently (queue/scheduler), write runs + audit logs. +- **US3 (P2)**: View run history, run-now/retry actions, retention keep-last-N. + +--- + +## Phase 1: Setup (Shared Infrastructure) + +- [X] T001 [P] [US2] Review existing sync + backup APIs in app/Services/Intune/PolicySyncService.php and app/Services/Intune/BackupService.php +- [X] T002 [P] [US1] Confirm supported policy types config key/shape in config/tenantpilot.php (source of truth for `policy_types`) +- [X] T003 [P] [US2] Confirm audit logging primitives in app/Services/Intune/AuditLogger.php and app/Models/AuditLog.php + +--- + +## Phase 2: Foundational (Blocking Prerequisites) + +- [X] T004 [US1] Add migrations for schedules/runs in database/migrations/*_create_backup_schedules_table.php and database/migrations/*_create_backup_schedule_runs_table.php (tenant FKs, jsonb, indexes, unique (backup_schedule_id, scheduled_for)) +- [X] T005 [P] [US1] Add model app/Models/BackupSchedule.php (casts, relationships) +- [X] T006 [P] [US2] Add model app/Models/BackupScheduleRun.php (casts, relationships, status + error fields) +- [X] T007 [P] [US1] Add tenant relationships in app/Models/Tenant.php (backupSchedules(), backupScheduleRuns()) +- [X] T008 [P] [US1] Add TenantRole helpers in app/Support/TenantRole.php for SEC-002 (canManageBackupSchedules(), canRunBackupSchedules()) +- [X] T009 [US2] Implement next-run + slot calculation in app/Services/BackupScheduling/ScheduleTimeService.php (UTC minute-slot, DST rules, no catch-up) +- [X] T010 [US2] Implement policy type validation/filtering in app/Services/BackupScheduling/PolicyTypeResolver.php (validate vs config/tenantpilot.php; runtime filtering for legacy DB) + +--- + +## Phase 3: User Story 1 - Manage Schedules (Priority: P1) + +### Tests (Pest) + +- [X] T011 [P] [US1] Add feature test tests/Feature/BackupScheduling/BackupScheduleCrudTest.php (tenant scoping + manager/owner CRUD) +- [X] T012 [P] [US1] Add feature test tests/Feature/BackupScheduling/BackupScheduleValidationTest.php (weekly days_of_week rules; `policy_types` hard validation) +- [X] T013 [P] [US1] Add unit test tests/Unit/BackupScheduling/ScheduleTimeServiceTest.php (next_run_at + DST invalid/ambiguous behavior) + +### Implementation + +- [X] T014 [US1] Implement resource app/Filament/Resources/BackupScheduleResource.php (list columns per UX-001; create/edit form fields) +- [X] T015 [US1] Enforce authorization in app/Filament/Resources/BackupScheduleResource.php using app/Support/TenantRole.php (SEC-002) +- [X] T016 [US1] Persist next_run_at updates via app/Services/BackupScheduling/ScheduleTimeService.php (on create/update) +- [X] T017 [US1] Validate `policy_types` via app/Services/BackupScheduling/PolicyTypeResolver.php (reject unknown keys at save-time) + +--- + +## Phase 4: User Story 2 - Dispatch & Execute Runs (Priority: P1) + +### Tests (Pest) + +- [X] T018 [P] [US2] Add feature test tests/Feature/BackupScheduling/DispatchIdempotencyTest.php (same slot dispatch twice → one run) +- [X] T019 [P] [US2] Add feature test tests/Feature/BackupScheduling/RunBackupScheduleJobTest.php (success/partial/skipped outcomes + backup_set linkage) +- [X] T020 [P] [US2] Add feature test tests/Feature/BackupScheduling/RunErrorMappingTest.php (retry/backoff vs no-retry mapping) + +### Implementation + +- [X] T021 [P] [US2] Add command app/Console/Commands/TenantpilotDispatchBackupSchedules.php (tenantpilot:schedules:dispatch) +- [X] T022 [US2] Register scheduler entry in routes/console.php (every minute) +- [X] T023 [US2] Implement dispatcher service app/Services/BackupScheduling/BackupScheduleDispatcher.php (find due schedules, create run, dispatch job) +- [X] T024 [US2] Implement job app/Jobs/RunBackupScheduleJob.php (lock per schedule, sync via app/Services/Intune/PolicySyncService.php, create backup set via app/Services/Intune/BackupService.php, update run + schedule fields) +- [X] T025 [US2] Implement retry/backoff in app/Jobs/RunBackupScheduleJob.php (429/503 backoff; 401/403 no retry; unknown limited retries) +- [X] T026 [US2] Write audit logs (dispatch/run start/run end) using app/Services/Intune/AuditLogger.php from app/Services/BackupScheduling/BackupScheduleDispatcher.php and app/Jobs/RunBackupScheduleJob.php +- [X] T027 [US2] Implement unknown policy types runtime behavior in app/Jobs/RunBackupScheduleJob.php using app/Services/BackupScheduling/PolicyTypeResolver.php (≥1 valid → partial; 0 valid → skipped; error_code UNKNOWN_POLICY_TYPE + list in run summary) + +--- + +## Phase 5: User Story 3 - Run History, Actions, Retention (Priority: P2) + +### Tests (Pest) + +- [X] T028 [P] [US3] Add feature test tests/Feature/BackupScheduling/RunNowRetryActionsTest.php (operator+ allowed; DB notification persisted) +- [X] T029 [P] [US3] Add feature test tests/Feature/BackupScheduling/ApplyRetentionJobTest.php (keeps last N backup_sets; soft-deletes older) +- [X] T038 [P] [US1] Add feature test tests/Feature/BackupScheduling/BackupScheduleBulkDeleteTest.php (bulk delete action regression) +- [X] T039 [P] [US1] Extend tests/Feature/BackupScheduling/BackupScheduleBulkDeleteTest.php (operator cannot bulk delete) +- [X] T041 [P] [US3] Make manual dispatch actions idempotent under concurrency in app/Filament/Resources/BackupScheduleResource.php (avoid unique constraint 500); add regression in tests/Feature/BackupScheduling/RunNowRetryActionsTest.php +- [X] T042 [P] [US2] Harden dispatcher idempotency in app/Services/BackupScheduling/BackupScheduleDispatcher.php (catch unique constraint only; treat as already dispatched, no side effects) and extend tests/Feature/BackupScheduling/DispatchIdempotencyTest.php + +### Implementation + +- [X] T030 [US3] Add runs RelationManager app/Filament/Resources/BackupScheduleResource/RelationManagers/BackupScheduleRunsRelationManager.php (UX-002 fields + tenant scoping) +- [X] T031 [US3] Add Run now / Retry actions in app/Filament/Resources/BackupScheduleResource.php (SEC-002 gating via app/Support/TenantRole.php) +- [X] T032 [US3] Persist database notifications for interactive actions in app/Filament/Resources/BackupScheduleResource.php +- [X] T033 [US3] Implement retention job app/Jobs/ApplyBackupScheduleRetentionJob.php (soft-delete old backup sets; write audit log) +- [X] T034 [US3] Dispatch retention job from app/Jobs/RunBackupScheduleJob.php after completion + +--- + +## Phase 6: Polish & Cross-Cutting Concerns + +- [X] T035 [P] [US2] Validate operational steps in specs/032-backup-scheduling-mvp/quickstart.md (queue + scheduler + manual dispatch) +- [X] T036 [P] [US2] Run formatter on touched files using vendor/bin/pint --dirty +- [X] T037 [P] [US2] Run targeted tests using vendor/bin/sail php artisan test tests/Feature/BackupScheduling tests/Unit/BackupScheduling + +--- + +## Dependencies & Execution Order + +Setup (Phase 1) → Foundational (Phase 2) → US1 (P1) → US2 (P1) → US3 (P2) → Polish +Setup (Phase 1) → Foundational (Phase 2) → US1 (P1) → US2 (P1) → US3 (P2) → Polish diff --git a/tests/Feature/BackupScheduling/ApplyRetentionJobTest.php b/tests/Feature/BackupScheduling/ApplyRetentionJobTest.php new file mode 100644 index 0000000..a5b1720 --- /dev/null +++ b/tests/Feature/BackupScheduling/ApplyRetentionJobTest.php @@ -0,0 +1,67 @@ +create([ + 'tenant_id' => $tenant->id, + 'name' => 'Nightly', + 'is_enabled' => true, + 'timezone' => 'UTC', + 'frequency' => 'daily', + 'time_of_day' => '01:00:00', + 'days_of_week' => null, + 'policy_types' => ['deviceConfiguration'], + 'include_foundations' => true, + 'retention_keep_last' => 2, + ]); + + $this->actingAs($user); + Filament::setTenant($tenant, true); + + $sets = collect(range(1, 5))->map(function (int $i) use ($tenant): BackupSet { + return BackupSet::query()->create([ + 'tenant_id' => $tenant->id, + 'name' => 'Set '.$i, + 'status' => 'completed', + 'item_count' => 0, + 'completed_at' => now()->subMinutes(10 - $i), + ]); + }); + + // Oldest → newest + $scheduledFor = now('UTC')->startOfMinute()->subMinutes(10); + foreach ($sets as $set) { + BackupScheduleRun::query()->create([ + 'backup_schedule_id' => $schedule->id, + 'tenant_id' => $tenant->id, + 'scheduled_for' => $scheduledFor, + 'status' => BackupScheduleRun::STATUS_SUCCESS, + 'summary' => ['policies_total' => 0, 'policies_backed_up' => 0, 'errors_count' => 0], + 'backup_set_id' => $set->id, + ]); + $scheduledFor = $scheduledFor->addMinute(); + } + + ApplyBackupScheduleRetentionJob::dispatchSync($schedule->id); + + $kept = $sets->take(-2); + $deleted = $sets->take(3); + + foreach ($kept as $set) { + $this->assertDatabaseHas('backup_sets', [ + 'id' => $set->id, + 'deleted_at' => null, + ]); + } + + foreach ($deleted as $set) { + $this->assertSoftDeleted('backup_sets', ['id' => $set->id]); + } +}); diff --git a/tests/Feature/BackupScheduling/BackupScheduleBulkDeleteTest.php b/tests/Feature/BackupScheduling/BackupScheduleBulkDeleteTest.php new file mode 100644 index 0000000..f3817d8 --- /dev/null +++ b/tests/Feature/BackupScheduling/BackupScheduleBulkDeleteTest.php @@ -0,0 +1,89 @@ +create([ + 'tenant_id' => $tenant->id, + 'name' => 'Delete A', + 'is_enabled' => true, + 'timezone' => 'UTC', + 'frequency' => 'daily', + 'time_of_day' => '01:00:00', + 'days_of_week' => null, + 'policy_types' => ['deviceConfiguration'], + 'include_foundations' => true, + 'retention_keep_last' => 30, + ]); + + $scheduleB = BackupSchedule::query()->create([ + 'tenant_id' => $tenant->id, + 'name' => 'Delete B', + 'is_enabled' => true, + 'timezone' => 'UTC', + 'frequency' => 'daily', + 'time_of_day' => '02:00:00', + 'days_of_week' => null, + 'policy_types' => ['deviceConfiguration'], + 'include_foundations' => true, + 'retention_keep_last' => 30, + ]); + + $this->actingAs($user); + Filament::setTenant($tenant, true); + + Livewire::test(ListBackupSchedules::class) + ->callTableBulkAction('bulk_delete', collect([$scheduleA, $scheduleB])) + ->assertHasNoTableBulkActionErrors(); + + expect(BackupSchedule::query()->where('tenant_id', $tenant->id)->count()) + ->toBe(0); +}); + +test('operator cannot bulk delete backup schedules', function () { + [$user, $tenant] = createUserWithTenant(role: 'operator'); + + $scheduleA = BackupSchedule::query()->create([ + 'tenant_id' => $tenant->id, + 'name' => 'Keep A', + 'is_enabled' => true, + 'timezone' => 'UTC', + 'frequency' => 'daily', + 'time_of_day' => '01:00:00', + 'days_of_week' => null, + 'policy_types' => ['deviceConfiguration'], + 'include_foundations' => true, + 'retention_keep_last' => 30, + ]); + + $scheduleB = BackupSchedule::query()->create([ + 'tenant_id' => $tenant->id, + 'name' => 'Keep B', + 'is_enabled' => true, + 'timezone' => 'UTC', + 'frequency' => 'daily', + 'time_of_day' => '02:00:00', + 'days_of_week' => null, + 'policy_types' => ['deviceConfiguration'], + 'include_foundations' => true, + 'retention_keep_last' => 30, + ]); + + $this->actingAs($user); + Filament::setTenant($tenant, true); + + try { + Livewire::test(ListBackupSchedules::class) + ->callTableBulkAction('bulk_delete', collect([$scheduleA, $scheduleB])); + } catch (\Throwable) { + // Action should be hidden/blocked for operator users. + } + + expect(BackupSchedule::query()->where('tenant_id', $tenant->id)->count()) + ->toBe(2); +}); diff --git a/tests/Feature/BackupScheduling/BackupScheduleCrudTest.php b/tests/Feature/BackupScheduling/BackupScheduleCrudTest.php new file mode 100644 index 0000000..f7c6726 --- /dev/null +++ b/tests/Feature/BackupScheduling/BackupScheduleCrudTest.php @@ -0,0 +1,123 @@ +create(); + + createUserWithTenant(tenant: $tenantB, user: $user, role: 'manager'); + + BackupSchedule::query()->create([ + 'tenant_id' => $tenantA->id, + 'name' => 'Tenant A schedule', + 'is_enabled' => true, + 'timezone' => 'UTC', + 'frequency' => 'daily', + 'time_of_day' => '01:00:00', + 'days_of_week' => null, + 'policy_types' => [ + 'deviceConfiguration', + 'groupPolicyConfiguration', + 'settingsCatalogPolicy', + ], + 'include_foundations' => true, + 'retention_keep_last' => 30, + ]); + + BackupSchedule::query()->create([ + 'tenant_id' => $tenantB->id, + 'name' => 'Tenant B schedule', + 'is_enabled' => true, + 'timezone' => 'UTC', + 'frequency' => 'daily', + 'time_of_day' => '02:00:00', + 'days_of_week' => null, + 'policy_types' => ['deviceCompliancePolicy'], + 'include_foundations' => true, + 'retention_keep_last' => 30, + ]); + + $this->actingAs($user); + + $this->get(route('filament.admin.resources.backup-schedules.index', filamentTenantRouteParams($tenantA))) + ->assertOk() + ->assertSee('Tenant A schedule') + ->assertSee('Device Configuration') + ->assertSee('more') + ->assertDontSee('Tenant B schedule'); +}); + +test('backup schedules listing shows next run in schedule timezone', function () { + [$user, $tenant] = createUserWithTenant(role: 'manager'); + + BackupSchedule::query()->create([ + 'tenant_id' => $tenant->id, + 'name' => 'Berlin schedule', + 'is_enabled' => true, + 'timezone' => 'Europe/Berlin', + 'frequency' => 'daily', + 'time_of_day' => '10:17:00', + 'days_of_week' => null, + 'policy_types' => ['deviceConfiguration'], + 'include_foundations' => true, + 'retention_keep_last' => 30, + 'next_run_at' => CarbonImmutable::create(2026, 1, 5, 9, 17, 0, 'UTC'), + ]); + + $this->actingAs($user); + + $this->get(route('filament.admin.resources.backup-schedules.index', filamentTenantRouteParams($tenant))) + ->assertOk() + ->assertSee('Jan 5, 2026 10:17:00'); +}); + +test('backup schedules pages return 404 for unauthorized tenant', function () { + [$user] = createUserWithTenant(role: 'manager'); + $unauthorizedTenant = Tenant::factory()->create(); + + $this->actingAs($user) + ->get(route('filament.admin.resources.backup-schedules.index', filamentTenantRouteParams($unauthorizedTenant))) + ->assertNotFound(); +}); + +test('manager can create and edit backup schedules via filament', function () { + [$user, $tenant] = createUserWithTenant(role: 'manager'); + + $this->actingAs($user); + Filament::setTenant($tenant, true); + + Livewire::test(CreateBackupSchedule::class) + ->fillForm([ + 'name' => 'Daily at 10', + 'is_enabled' => true, + 'timezone' => 'UTC', + 'frequency' => 'daily', + 'time_of_day' => '10:00', + 'policy_types' => ['deviceConfiguration'], + 'include_foundations' => true, + 'retention_keep_last' => 30, + ]) + ->call('create') + ->assertHasNoFormErrors(); + + $schedule = BackupSchedule::query()->where('tenant_id', $tenant->id)->first(); + expect($schedule)->not->toBeNull(); + expect($schedule->next_run_at)->not->toBeNull(); + + Livewire::test(EditBackupSchedule::class, ['record' => $schedule->getRouteKey()]) + ->fillForm([ + 'name' => 'Daily at 11', + ]) + ->call('save') + ->assertHasNoFormErrors(); + + $schedule->refresh(); + expect($schedule->name)->toBe('Daily at 11'); +}); diff --git a/tests/Feature/BackupScheduling/BackupScheduleRunViewModalTest.php b/tests/Feature/BackupScheduling/BackupScheduleRunViewModalTest.php new file mode 100644 index 0000000..ff8d995 --- /dev/null +++ b/tests/Feature/BackupScheduling/BackupScheduleRunViewModalTest.php @@ -0,0 +1,53 @@ +create([ + 'tenant_id' => $tenant->id, + 'name' => 'Nightly', + 'is_enabled' => true, + 'timezone' => 'UTC', + 'frequency' => 'daily', + 'time_of_day' => '01:00:00', + 'days_of_week' => null, + 'policy_types' => ['deviceConfiguration'], + 'include_foundations' => true, + 'retention_keep_last' => 30, + ]); + + $backupSet = BackupSet::query()->create([ + 'tenant_id' => $tenant->id, + 'name' => 'Set 174', + 'status' => 'completed', + 'item_count' => 0, + ]); + + $run = BackupScheduleRun::query()->create([ + 'backup_schedule_id' => $schedule->id, + 'tenant_id' => $tenant->id, + 'scheduled_for' => now('UTC')->startOfMinute()->toDateTimeString(), + 'status' => BackupScheduleRun::STATUS_SUCCESS, + 'summary' => [ + 'policies_total' => 7, + 'policies_backed_up' => 7, + 'errors_count' => 0, + ], + 'error_code' => null, + 'error_message' => null, + 'backup_set_id' => $backupSet->id, + ]); + + $this->actingAs($user); + + $html = view('filament.modals.backup-schedule-run-view', ['run' => $run])->render(); + + expect($html)->toContain('Scheduled for'); + expect($html)->toContain('Status'); + expect($html)->toContain('Summary'); + expect($html)->toContain((string) $backupSet->id); +}); diff --git a/tests/Feature/BackupScheduling/BackupScheduleValidationTest.php b/tests/Feature/BackupScheduling/BackupScheduleValidationTest.php new file mode 100644 index 0000000..623effa --- /dev/null +++ b/tests/Feature/BackupScheduling/BackupScheduleValidationTest.php @@ -0,0 +1,48 @@ +actingAs($user); + Filament::setTenant($tenant, true); + + Livewire::test(CreateBackupSchedule::class) + ->fillForm([ + 'name' => 'Weekly schedule', + 'is_enabled' => true, + 'timezone' => 'UTC', + 'frequency' => 'weekly', + 'time_of_day' => '10:00', + 'days_of_week' => [], + 'policy_types' => ['deviceConfiguration'], + 'include_foundations' => true, + 'retention_keep_last' => 30, + ]) + ->call('create') + ->assertHasFormErrors(['days_of_week']); +}); + +test('unknown policy types are rejected at save time', function () { + [$user, $tenant] = createUserWithTenant(role: 'manager'); + + $this->actingAs($user); + Filament::setTenant($tenant, true); + + Livewire::test(CreateBackupSchedule::class) + ->fillForm([ + 'name' => 'Invalid policy type schedule', + 'is_enabled' => true, + 'timezone' => 'UTC', + 'frequency' => 'daily', + 'time_of_day' => '10:00', + 'policy_types' => ['definitelyNotARealPolicyType'], + 'include_foundations' => true, + 'retention_keep_last' => 30, + ]) + ->call('create') + ->assertHasFormErrors(['policy_types']); +}); diff --git a/tests/Feature/BackupScheduling/DispatchIdempotencyTest.php b/tests/Feature/BackupScheduling/DispatchIdempotencyTest.php new file mode 100644 index 0000000..8df6cd6 --- /dev/null +++ b/tests/Feature/BackupScheduling/DispatchIdempotencyTest.php @@ -0,0 +1,81 @@ +actingAs($user); + + BackupSchedule::query()->create([ + 'tenant_id' => $tenant->id, + 'name' => 'Daily 10:00', + 'is_enabled' => true, + 'timezone' => 'UTC', + 'frequency' => 'daily', + 'time_of_day' => '10:00:00', + 'days_of_week' => null, + 'policy_types' => ['deviceConfiguration'], + 'include_foundations' => true, + 'retention_keep_last' => 30, + 'next_run_at' => null, + ]); + + Bus::fake(); + + $dispatcher = app(BackupScheduleDispatcher::class); + + $dispatcher->dispatchDue([$tenant->external_id]); + $dispatcher->dispatchDue([$tenant->external_id]); + + expect(BackupScheduleRun::query()->count())->toBe(1); + + Bus::assertDispatchedTimes(RunBackupScheduleJob::class, 1); +}); + +it('treats a unique constraint collision as already-dispatched and advances next_run_at', function () { + CarbonImmutable::setTestNow(CarbonImmutable::create(2026, 1, 5, 10, 0, 30, 'UTC')); + + [$user, $tenant] = createUserWithTenant(role: 'owner'); + $this->actingAs($user); + + $schedule = BackupSchedule::query()->create([ + 'tenant_id' => $tenant->id, + 'name' => 'Daily 10:00', + 'is_enabled' => true, + 'timezone' => 'UTC', + 'frequency' => 'daily', + 'time_of_day' => '10:00:00', + 'days_of_week' => null, + 'policy_types' => ['deviceConfiguration'], + 'include_foundations' => true, + 'retention_keep_last' => 30, + 'next_run_at' => null, + ]); + + BackupScheduleRun::query()->create([ + 'backup_schedule_id' => $schedule->id, + 'tenant_id' => $tenant->id, + 'scheduled_for' => CarbonImmutable::now('UTC')->startOfMinute()->toDateTimeString(), + 'status' => BackupScheduleRun::STATUS_RUNNING, + 'summary' => null, + ]); + + Bus::fake(); + + $dispatcher = app(BackupScheduleDispatcher::class); + $dispatcher->dispatchDue([$tenant->external_id]); + + expect(BackupScheduleRun::query()->count())->toBe(1); + Bus::assertNotDispatched(RunBackupScheduleJob::class); + + $schedule->refresh(); + expect($schedule->next_run_at)->not->toBeNull(); + expect($schedule->next_run_at->toDateTimeString())->toBe('2026-01-06 10:00:00'); +}); diff --git a/tests/Feature/BackupScheduling/RunBackupScheduleJobTest.php b/tests/Feature/BackupScheduling/RunBackupScheduleJobTest.php new file mode 100644 index 0000000..ffed21c --- /dev/null +++ b/tests/Feature/BackupScheduling/RunBackupScheduleJobTest.php @@ -0,0 +1,123 @@ +actingAs($user); + + $schedule = BackupSchedule::query()->create([ + 'tenant_id' => $tenant->id, + 'name' => 'Daily 10:00', + 'is_enabled' => true, + 'timezone' => 'UTC', + 'frequency' => 'daily', + 'time_of_day' => '10:00:00', + 'days_of_week' => null, + 'policy_types' => ['deviceConfiguration'], + 'include_foundations' => true, + 'retention_keep_last' => 30, + 'next_run_at' => null, + ]); + + $run = BackupScheduleRun::query()->create([ + 'backup_schedule_id' => $schedule->id, + 'tenant_id' => $tenant->id, + 'scheduled_for' => CarbonImmutable::now('UTC')->startOfMinute(), + 'status' => BackupScheduleRun::STATUS_RUNNING, + ]); + + app()->bind(PolicySyncService::class, fn () => new class extends PolicySyncService + { + public function __construct() {} + + public function syncPoliciesWithReport($tenant, ?array $supportedTypes = null): array + { + return ['synced' => [], 'failures' => []]; + } + }); + + $backupSet = BackupSet::factory()->create([ + 'tenant_id' => $tenant->id, + 'status' => 'completed', + 'item_count' => 0, + ]); + + app()->bind(BackupService::class, fn () => new class($backupSet) extends BackupService + { + public function __construct(private readonly BackupSet $backupSet) {} + + public function createBackupSet($tenant, $policyIds, ?string $actorEmail = null, ?string $actorName = null, ?string $name = null, bool $includeAssignments = false, bool $includeScopeTags = false, bool $includeFoundations = false): BackupSet + { + return $this->backupSet; + } + }); + + Cache::flush(); + + (new RunBackupScheduleJob($run->id))->handle( + app(PolicySyncService::class), + app(BackupService::class), + app(\App\Services\BackupScheduling\PolicyTypeResolver::class), + app(\App\Services\BackupScheduling\ScheduleTimeService::class), + app(\App\Services\Intune\AuditLogger::class), + app(\App\Services\BackupScheduling\RunErrorMapper::class), + ); + + $run->refresh(); + expect($run->status)->toBe(BackupScheduleRun::STATUS_SUCCESS); + expect($run->backup_set_id)->toBe($backupSet->id); +}); + +it('skips runs when all policy types are unknown', function () { + CarbonImmutable::setTestNow(CarbonImmutable::create(2026, 1, 5, 10, 0, 30, 'UTC')); + + [$user, $tenant] = createUserWithTenant(role: 'owner'); + $this->actingAs($user); + + $schedule = BackupSchedule::query()->create([ + 'tenant_id' => $tenant->id, + 'name' => 'Daily 10:00', + 'is_enabled' => true, + 'timezone' => 'UTC', + 'frequency' => 'daily', + 'time_of_day' => '10:00:00', + 'days_of_week' => null, + 'policy_types' => ['definitelyNotARealPolicyType'], + 'include_foundations' => true, + 'retention_keep_last' => 30, + 'next_run_at' => null, + ]); + + $run = BackupScheduleRun::query()->create([ + 'backup_schedule_id' => $schedule->id, + 'tenant_id' => $tenant->id, + 'scheduled_for' => CarbonImmutable::now('UTC')->startOfMinute(), + 'status' => BackupScheduleRun::STATUS_RUNNING, + ]); + + Cache::flush(); + + (new RunBackupScheduleJob($run->id))->handle( + app(PolicySyncService::class), + app(BackupService::class), + app(\App\Services\BackupScheduling\PolicyTypeResolver::class), + app(\App\Services\BackupScheduling\ScheduleTimeService::class), + app(\App\Services\Intune\AuditLogger::class), + app(\App\Services\BackupScheduling\RunErrorMapper::class), + ); + + $run->refresh(); + expect($run->status)->toBe(BackupScheduleRun::STATUS_SKIPPED); + expect($run->error_code)->toBe('UNKNOWN_POLICY_TYPE'); + expect($run->backup_set_id)->toBeNull(); +}); diff --git a/tests/Feature/BackupScheduling/RunErrorMappingTest.php b/tests/Feature/BackupScheduling/RunErrorMappingTest.php new file mode 100644 index 0000000..097f092 --- /dev/null +++ b/tests/Feature/BackupScheduling/RunErrorMappingTest.php @@ -0,0 +1,42 @@ +map(new GraphException('auth failed', 401), attempt: 1, maxAttempts: 3); + + expect($mapped['shouldRetry'])->toBeFalse(); + expect($mapped['error_code'])->toBe(RunErrorMapper::ERROR_TOKEN_EXPIRED); +}); + +it('marks 403 as permission missing without retry', function () { + $mapper = app(RunErrorMapper::class); + + $mapped = $mapper->map(new GraphException('forbidden', 403), attempt: 1, maxAttempts: 3); + + expect($mapped['shouldRetry'])->toBeFalse(); + expect($mapped['error_code'])->toBe(RunErrorMapper::ERROR_PERMISSION_MISSING); +}); + +it('retries throttling with backoff', function () { + $mapper = app(RunErrorMapper::class); + + $mapped = $mapper->map(new GraphException('throttled', 429), attempt: 1, maxAttempts: 3); + + expect($mapped['shouldRetry'])->toBeTrue(); + expect($mapped['delay'])->toBe(60); + expect($mapped['error_code'])->toBe(RunErrorMapper::ERROR_GRAPH_THROTTLE); +}); + +it('retries service unavailable with backoff', function () { + $mapper = app(RunErrorMapper::class); + + $mapped = $mapper->map(new GraphException('unavailable', 503), attempt: 2, maxAttempts: 3); + + expect($mapped['shouldRetry'])->toBeTrue(); + expect($mapped['delay'])->toBe(300); + expect($mapped['error_code'])->toBe(RunErrorMapper::ERROR_GRAPH_UNAVAILABLE); +}); diff --git a/tests/Feature/BackupScheduling/RunNowRetryActionsTest.php b/tests/Feature/BackupScheduling/RunNowRetryActionsTest.php new file mode 100644 index 0000000..807fc5b --- /dev/null +++ b/tests/Feature/BackupScheduling/RunNowRetryActionsTest.php @@ -0,0 +1,331 @@ +create([ + 'tenant_id' => $tenant->id, + 'name' => 'Nightly', + 'is_enabled' => true, + 'timezone' => 'UTC', + 'frequency' => 'daily', + 'time_of_day' => '01:00:00', + 'days_of_week' => null, + 'policy_types' => ['deviceConfiguration'], + 'include_foundations' => true, + 'retention_keep_last' => 30, + ]); + + $this->actingAs($user); + Filament::setTenant($tenant, true); + + Livewire::test(ListBackupSchedules::class) + ->callTableAction('runNow', $schedule); + + expect(BackupScheduleRun::query()->where('backup_schedule_id', $schedule->id)->count()) + ->toBe(1); + + $run = BackupScheduleRun::query()->where('backup_schedule_id', $schedule->id)->first(); + expect($run)->not->toBeNull(); + expect($run->user_id)->toBe($user->id); + + expect(BulkOperationRun::query() + ->where('tenant_id', $tenant->id) + ->where('user_id', $user->id) + ->where('resource', 'backup_schedule') + ->where('action', 'run') + ->count()) + ->toBe(1); + + Queue::assertPushed(RunBackupScheduleJob::class); + + $this->assertDatabaseCount('notifications', 1); + $this->assertDatabaseHas('notifications', [ + 'notifiable_id' => $user->id, + 'notifiable_type' => User::class, + 'data->format' => 'filament', + 'data->title' => 'Run dispatched', + ]); +}); + +test('operator can retry and it persists a database notification', function () { + Queue::fake(); + + [$user, $tenant] = createUserWithTenant(role: 'operator'); + + $schedule = BackupSchedule::query()->create([ + 'tenant_id' => $tenant->id, + 'name' => 'Nightly', + 'is_enabled' => true, + 'timezone' => 'UTC', + 'frequency' => 'daily', + 'time_of_day' => '01:00:00', + 'days_of_week' => null, + 'policy_types' => ['deviceConfiguration'], + 'include_foundations' => true, + 'retention_keep_last' => 30, + ]); + + $this->actingAs($user); + Filament::setTenant($tenant, true); + + Livewire::test(ListBackupSchedules::class) + ->callTableAction('retry', $schedule); + + expect(BackupScheduleRun::query()->where('backup_schedule_id', $schedule->id)->count()) + ->toBe(1); + + $run = BackupScheduleRun::query()->where('backup_schedule_id', $schedule->id)->first(); + expect($run)->not->toBeNull(); + expect($run->user_id)->toBe($user->id); + + expect(BulkOperationRun::query() + ->where('tenant_id', $tenant->id) + ->where('user_id', $user->id) + ->where('resource', 'backup_schedule') + ->where('action', 'retry') + ->count()) + ->toBe(1); + + Queue::assertPushed(RunBackupScheduleJob::class); + $this->assertDatabaseCount('notifications', 1); + $this->assertDatabaseHas('notifications', [ + 'notifiable_id' => $user->id, + 'data->format' => 'filament', + 'data->title' => 'Retry dispatched', + ]); +}); + +test('readonly cannot dispatch run now or retry', function () { + [$user, $tenant] = createUserWithTenant(role: 'readonly'); + + $schedule = BackupSchedule::query()->create([ + 'tenant_id' => $tenant->id, + 'name' => 'Nightly', + 'is_enabled' => true, + 'timezone' => 'UTC', + 'frequency' => 'daily', + 'time_of_day' => '01:00:00', + 'days_of_week' => null, + 'policy_types' => ['deviceConfiguration'], + 'include_foundations' => true, + 'retention_keep_last' => 30, + ]); + + $this->actingAs($user); + Filament::setTenant($tenant, true); + + try { + Livewire::test(ListBackupSchedules::class) + ->callTableAction('runNow', $schedule); + } catch (\Throwable) { + // Action should be hidden/blocked for readonly users. + } + + try { + Livewire::test(ListBackupSchedules::class) + ->callTableAction('retry', $schedule); + } catch (\Throwable) { + // Action should be hidden/blocked for readonly users. + } + + expect(BackupScheduleRun::query()->where('backup_schedule_id', $schedule->id)->count()) + ->toBe(0); +}); + +test('operator can bulk run now and it persists a database notification', function () { + Queue::fake(); + + [$user, $tenant] = createUserWithTenant(role: 'operator'); + + $scheduleA = BackupSchedule::query()->create([ + 'tenant_id' => $tenant->id, + 'name' => 'Nightly A', + 'is_enabled' => true, + 'timezone' => 'UTC', + 'frequency' => 'daily', + 'time_of_day' => '01:00:00', + 'days_of_week' => null, + 'policy_types' => ['deviceConfiguration'], + 'include_foundations' => true, + 'retention_keep_last' => 30, + ]); + + $scheduleB = BackupSchedule::query()->create([ + 'tenant_id' => $tenant->id, + 'name' => 'Nightly B', + 'is_enabled' => true, + 'timezone' => 'UTC', + 'frequency' => 'daily', + 'time_of_day' => '02:00:00', + 'days_of_week' => null, + 'policy_types' => ['deviceConfiguration'], + 'include_foundations' => true, + 'retention_keep_last' => 30, + ]); + + $this->actingAs($user); + Filament::setTenant($tenant, true); + + Livewire::test(ListBackupSchedules::class) + ->callTableBulkAction('bulk_run_now', collect([$scheduleA, $scheduleB])); + + expect(BackupScheduleRun::query()->whereIn('backup_schedule_id', [$scheduleA->id, $scheduleB->id])->count()) + ->toBe(2); + + expect(BackupScheduleRun::query()->where('backup_schedule_id', $scheduleA->id)->value('user_id'))->toBe($user->id); + expect(BackupScheduleRun::query()->where('backup_schedule_id', $scheduleB->id)->value('user_id'))->toBe($user->id); + + expect(BulkOperationRun::query() + ->where('tenant_id', $tenant->id) + ->where('user_id', $user->id) + ->where('resource', 'backup_schedule') + ->where('action', 'run') + ->count()) + ->toBe(1); + + Queue::assertPushed(RunBackupScheduleJob::class, 2); + $this->assertDatabaseCount('notifications', 1); + $this->assertDatabaseHas('notifications', [ + 'notifiable_id' => $user->id, + 'data->format' => 'filament', + 'data->title' => 'Runs dispatched', + ]); +}); + +test('operator can bulk retry and it persists a database notification', function () { + Queue::fake(); + + [$user, $tenant] = createUserWithTenant(role: 'operator'); + + $scheduleA = BackupSchedule::query()->create([ + 'tenant_id' => $tenant->id, + 'name' => 'Nightly A', + 'is_enabled' => true, + 'timezone' => 'UTC', + 'frequency' => 'daily', + 'time_of_day' => '01:00:00', + 'days_of_week' => null, + 'policy_types' => ['deviceConfiguration'], + 'include_foundations' => true, + 'retention_keep_last' => 30, + ]); + + $scheduleB = BackupSchedule::query()->create([ + 'tenant_id' => $tenant->id, + 'name' => 'Nightly B', + 'is_enabled' => true, + 'timezone' => 'UTC', + 'frequency' => 'daily', + 'time_of_day' => '02:00:00', + 'days_of_week' => null, + 'policy_types' => ['deviceConfiguration'], + 'include_foundations' => true, + 'retention_keep_last' => 30, + ]); + + $this->actingAs($user); + Filament::setTenant($tenant, true); + + Livewire::test(ListBackupSchedules::class) + ->callTableBulkAction('bulk_retry', collect([$scheduleA, $scheduleB])); + + expect(BackupScheduleRun::query()->whereIn('backup_schedule_id', [$scheduleA->id, $scheduleB->id])->count()) + ->toBe(2); + + expect(BackupScheduleRun::query()->where('backup_schedule_id', $scheduleA->id)->value('user_id'))->toBe($user->id); + expect(BackupScheduleRun::query()->where('backup_schedule_id', $scheduleB->id)->value('user_id'))->toBe($user->id); + + expect(BulkOperationRun::query() + ->where('tenant_id', $tenant->id) + ->where('user_id', $user->id) + ->where('resource', 'backup_schedule') + ->where('action', 'retry') + ->count()) + ->toBe(1); + + Queue::assertPushed(RunBackupScheduleJob::class, 2); + $this->assertDatabaseCount('notifications', 1); + $this->assertDatabaseHas('notifications', [ + 'notifiable_id' => $user->id, + 'data->format' => 'filament', + 'data->title' => 'Retries dispatched', + ]); +}); + +test('operator can bulk retry even if a run already exists for this minute', function () { + Queue::fake(); + + [$user, $tenant] = createUserWithTenant(role: 'operator'); + + $scheduleA = BackupSchedule::query()->create([ + 'tenant_id' => $tenant->id, + 'name' => 'Nightly A', + 'is_enabled' => true, + 'timezone' => 'UTC', + 'frequency' => 'daily', + 'time_of_day' => '01:00:00', + 'days_of_week' => null, + 'policy_types' => ['deviceConfiguration'], + 'include_foundations' => true, + 'retention_keep_last' => 30, + ]); + + $scheduleB = BackupSchedule::query()->create([ + 'tenant_id' => $tenant->id, + 'name' => 'Nightly B', + 'is_enabled' => true, + 'timezone' => 'UTC', + 'frequency' => 'daily', + 'time_of_day' => '02:00:00', + 'days_of_week' => null, + 'policy_types' => ['deviceConfiguration'], + 'include_foundations' => true, + 'retention_keep_last' => 30, + ]); + + $scheduledFor = CarbonImmutable::now('UTC')->startOfMinute(); + BackupScheduleRun::query()->create([ + 'backup_schedule_id' => $scheduleA->id, + 'tenant_id' => $tenant->id, + 'scheduled_for' => $scheduledFor->toDateTimeString(), + 'status' => BackupScheduleRun::STATUS_RUNNING, + 'summary' => null, + ]); + + $this->actingAs($user); + Filament::setTenant($tenant, true); + + Livewire::test(ListBackupSchedules::class) + ->callTableBulkAction('bulk_retry', collect([$scheduleA, $scheduleB])); + + expect(BackupScheduleRun::query()->where('backup_schedule_id', $scheduleA->id)->count()) + ->toBe(2); + + $newRunA = BackupScheduleRun::query() + ->where('backup_schedule_id', $scheduleA->id) + ->orderByDesc('id') + ->first(); + + expect($newRunA)->not->toBeNull(); + expect($newRunA->scheduled_for->setTimezone('UTC')->toDateTimeString()) + ->toBe($scheduledFor->addMinute()->toDateTimeString()); + + expect(BackupScheduleRun::query()->where('backup_schedule_id', $scheduleB->id)->count()) + ->toBe(1); + + Queue::assertPushed(RunBackupScheduleJob::class, 2); +}); diff --git a/tests/Feature/BackupServiceVersionReuseTest.php b/tests/Feature/BackupServiceVersionReuseTest.php new file mode 100644 index 0000000..32f2d12 --- /dev/null +++ b/tests/Feature/BackupServiceVersionReuseTest.php @@ -0,0 +1,144 @@ +create(); + $tenant->makeCurrent(); + + $user = User::factory()->create(); + $this->actingAs($user); + + $backupSet = BackupSet::factory()->create([ + 'tenant_id' => $tenant->id, + 'status' => 'completed', + ]); + + $policy = Policy::factory()->create([ + 'tenant_id' => $tenant->id, + 'last_synced_at' => now(), + 'ignored_at' => null, + ]); + + $existingVersion = PolicyVersion::factory()->create([ + 'tenant_id' => $tenant->id, + 'policy_id' => $policy->id, + 'captured_at' => now(), + 'snapshot' => ['id' => $policy->external_id, 'name' => $policy->display_name], + 'assignments' => null, + 'scope_tags' => null, + ]); + + $this->mock(PolicyCaptureOrchestrator::class, function (MockInterface $mock) use ($existingVersion) { + $mock->shouldReceive('capture') + ->once() + ->andReturn([ + 'version' => $existingVersion, + 'captured' => [ + 'payload' => $existingVersion->snapshot, + 'assignments' => $existingVersion->assignments, + 'scope_tags' => $existingVersion->scope_tags, + 'metadata' => [], + ], + ]); + }); + + $service = app(BackupService::class); + + $service->addPoliciesToSet( + tenant: $tenant, + backupSet: $backupSet, + policyIds: [$policy->id], + actorEmail: $user->email, + actorName: $user->name, + includeAssignments: false, + includeScopeTags: false, + includeFoundations: false, + ); + + expect(PolicyVersion::query()->where('policy_id', $policy->id)->count())->toBe(1); + + $item = $backupSet->items()->first(); + expect($item)->not->toBeNull(); + expect($item->policy_version_id)->toBe($existingVersion->id); +}); + +it('captures a new policy version for backup when no suitable existing version is available', function () { + $tenant = Tenant::factory()->create(); + $tenant->makeCurrent(); + + $user = User::factory()->create(); + $this->actingAs($user); + + $backupSet = BackupSet::factory()->create([ + 'tenant_id' => $tenant->id, + 'status' => 'completed', + ]); + + $policy = Policy::factory()->create([ + 'tenant_id' => $tenant->id, + 'last_synced_at' => now(), + 'ignored_at' => null, + ]); + + $staleVersion = PolicyVersion::factory()->create([ + 'tenant_id' => $tenant->id, + 'policy_id' => $policy->id, + 'version_number' => 1, + 'captured_at' => now()->subDays(2), + 'snapshot' => ['id' => $policy->external_id, 'name' => $policy->display_name], + ]); + + $policy->update(['last_synced_at' => now()]); + + $this->mock(PolicyCaptureOrchestrator::class, function (MockInterface $mock) use ($policy, $tenant) { + $mock->shouldReceive('capture') + ->once() + ->andReturnUsing(function () use ($policy, $tenant) { + $newVersion = PolicyVersion::factory()->create([ + 'tenant_id' => $tenant->id, + 'policy_id' => $policy->id, + 'version_number' => 2, + 'captured_at' => now(), + 'snapshot' => ['id' => $policy->external_id, 'name' => $policy->display_name, 'changed' => true], + ]); + + return [ + 'version' => $newVersion, + 'captured' => [ + 'payload' => $newVersion->snapshot, + 'assignments' => null, + 'scope_tags' => null, + 'metadata' => [], + ], + ]; + }); + }); + + $service = app(BackupService::class); + + $service->addPoliciesToSet( + tenant: $tenant, + backupSet: $backupSet, + policyIds: [$policy->id], + actorEmail: $user->email, + actorName: $user->name, + includeAssignments: false, + includeScopeTags: false, + includeFoundations: false, + ); + + $item = $backupSet->items()->first(); + expect($item)->not->toBeNull(); + expect($item->policy_version_id)->not->toBe($staleVersion->id); +}); diff --git a/tests/Feature/BulkDeleteBackupSetsTest.php b/tests/Feature/BulkDeleteBackupSetsTest.php index 260b458..b3d81bd 100644 --- a/tests/Feature/BulkDeleteBackupSetsTest.php +++ b/tests/Feature/BulkDeleteBackupSetsTest.php @@ -7,6 +7,7 @@ use App\Models\RestoreRun; use App\Models\Tenant; use App\Models\User; +use Filament\Facades\Filament; use Illuminate\Foundation\Testing\RefreshDatabase; use Livewire\Livewire; @@ -14,8 +15,12 @@ test('backup sets table bulk archive creates a run and archives selected sets', function () { $tenant = Tenant::factory()->create(); - $tenant->makeCurrent(); $user = User::factory()->create(); + $user->tenants()->syncWithoutDetaching([ + $tenant->getKey() => ['role' => 'owner'], + ]); + + Filament::setTenant($tenant, true); $sets = collect(range(1, 3))->map(function (int $i) use ($tenant) { return BackupSet::create([ @@ -58,8 +63,12 @@ test('backup sets can be archived even when referenced by restore runs', function () { $tenant = Tenant::factory()->create(); - $tenant->makeCurrent(); $user = User::factory()->create(); + $user->tenants()->syncWithoutDetaching([ + $tenant->getKey() => ['role' => 'owner'], + ]); + + Filament::setTenant($tenant, true); $set = BackupSet::create([ 'tenant_id' => $tenant->id, @@ -87,8 +96,12 @@ test('backup sets table bulk archive requires type-to-confirm for 10+ sets', function () { $tenant = Tenant::factory()->create(); - $tenant->makeCurrent(); $user = User::factory()->create(); + $user->tenants()->syncWithoutDetaching([ + $tenant->getKey() => ['role' => 'owner'], + ]); + + Filament::setTenant($tenant, true); $sets = collect(range(1, 10))->map(function (int $i) use ($tenant) { return BackupSet::create([ diff --git a/tests/Feature/BulkDeleteMixedStatusTest.php b/tests/Feature/BulkDeleteMixedStatusTest.php index 17dad2b..1a35e66 100644 --- a/tests/Feature/BulkDeleteMixedStatusTest.php +++ b/tests/Feature/BulkDeleteMixedStatusTest.php @@ -6,6 +6,7 @@ use App\Models\RestoreRun; use App\Models\Tenant; use App\Models\User; +use Filament\Facades\Filament; use Illuminate\Foundation\Testing\RefreshDatabase; use Livewire\Livewire; @@ -13,8 +14,12 @@ test('bulk delete restore runs skips running items', function () { $tenant = Tenant::factory()->create(); - $tenant->makeCurrent(); $user = User::factory()->create(); + $user->tenants()->syncWithoutDetaching([ + $tenant->getKey() => ['role' => 'owner'], + ]); + + Filament::setTenant($tenant, true); $backupSet = BackupSet::create([ 'tenant_id' => $tenant->id, diff --git a/tests/Feature/BulkDeleteRestoreRunsTest.php b/tests/Feature/BulkDeleteRestoreRunsTest.php index 9e41b4b..3ad115c 100644 --- a/tests/Feature/BulkDeleteRestoreRunsTest.php +++ b/tests/Feature/BulkDeleteRestoreRunsTest.php @@ -6,6 +6,7 @@ use App\Models\RestoreRun; use App\Models\Tenant; use App\Models\User; +use Filament\Facades\Filament; use Illuminate\Foundation\Testing\RefreshDatabase; use Livewire\Livewire; @@ -13,8 +14,12 @@ test('bulk delete restore runs soft deletes selected runs', function () { $tenant = Tenant::factory()->create(); - $tenant->makeCurrent(); $user = User::factory()->create(); + $user->tenants()->syncWithoutDetaching([ + $tenant->getKey() => ['role' => 'owner'], + ]); + + Filament::setTenant($tenant, true); $backupSet = BackupSet::create([ 'tenant_id' => $tenant->id, diff --git a/tests/Feature/BulkForceDeleteBackupSetsTest.php b/tests/Feature/BulkForceDeleteBackupSetsTest.php index 7cb8aec..abc6316 100644 --- a/tests/Feature/BulkForceDeleteBackupSetsTest.php +++ b/tests/Feature/BulkForceDeleteBackupSetsTest.php @@ -6,6 +6,7 @@ use App\Models\BulkOperationRun; use App\Models\Tenant; use App\Models\User; +use Filament\Facades\Filament; use Illuminate\Foundation\Testing\RefreshDatabase; use Livewire\Livewire; @@ -13,8 +14,12 @@ test('backup sets table bulk force delete permanently deletes archived sets and their items', function () { $tenant = Tenant::factory()->create(); - $tenant->makeCurrent(); $user = User::factory()->create(); + $user->tenants()->syncWithoutDetaching([ + $tenant->getKey() => ['role' => 'owner'], + ]); + + Filament::setTenant($tenant, true); $set = BackupSet::create([ 'tenant_id' => $tenant->id, diff --git a/tests/Feature/BulkForceDeletePolicyVersionsTest.php b/tests/Feature/BulkForceDeletePolicyVersionsTest.php index 106239b..701bd44 100644 --- a/tests/Feature/BulkForceDeletePolicyVersionsTest.php +++ b/tests/Feature/BulkForceDeletePolicyVersionsTest.php @@ -6,6 +6,7 @@ use App\Models\PolicyVersion; use App\Models\Tenant; use App\Models\User; +use Filament\Facades\Filament; use Illuminate\Foundation\Testing\RefreshDatabase; use Livewire\Livewire; @@ -14,6 +15,11 @@ test('policy versions table bulk force delete creates a run and skips non-archived records', function () { $tenant = Tenant::factory()->create(['is_current' => true]); $user = User::factory()->create(); + $user->tenants()->syncWithoutDetaching([ + $tenant->getKey() => ['role' => 'owner'], + ]); + + Filament::setTenant($tenant, true); $policy = Policy::factory()->create(['tenant_id' => $tenant->id]); $version = PolicyVersion::factory()->create([ diff --git a/tests/Feature/BulkForceDeleteRestoreRunsTest.php b/tests/Feature/BulkForceDeleteRestoreRunsTest.php index d527954..cd383d8 100644 --- a/tests/Feature/BulkForceDeleteRestoreRunsTest.php +++ b/tests/Feature/BulkForceDeleteRestoreRunsTest.php @@ -6,6 +6,7 @@ use App\Models\RestoreRun; use App\Models\Tenant; use App\Models\User; +use Filament\Facades\Filament; use Illuminate\Foundation\Testing\RefreshDatabase; use Livewire\Livewire; @@ -13,8 +14,12 @@ test('bulk force delete restore runs permanently deletes archived runs', function () { $tenant = Tenant::factory()->create(); - $tenant->makeCurrent(); $user = User::factory()->create(); + $user->tenants()->syncWithoutDetaching([ + $tenant->getKey() => ['role' => 'owner'], + ]); + + Filament::setTenant($tenant, true); $backupSet = BackupSet::create([ 'tenant_id' => $tenant->id, diff --git a/tests/Feature/BulkProgressNotificationTest.php b/tests/Feature/BulkProgressNotificationTest.php index d01960e..0f345ec 100644 --- a/tests/Feature/BulkProgressNotificationTest.php +++ b/tests/Feature/BulkProgressNotificationTest.php @@ -1,6 +1,8 @@ create(); + $tenant->makeCurrent(); $user = User::factory()->create(); // Own running op @@ -29,6 +32,7 @@ 'tenant_id' => $tenant->id, 'user_id' => $user->id, 'status' => 'completed', + 'updated_at' => now()->subMinutes(5), ]); // Other user's op (should not show) @@ -39,9 +43,6 @@ 'status' => 'running', ]); - // $tenant->makeCurrent(); - $tenant->forceFill(['is_current' => true])->save(); - auth()->login($user); // Login user explicitly for auth()->id() call in component Livewire::actingAs($user) @@ -49,3 +50,56 @@ ->assertSee('Delete Policy') ->assertSee('50 / 100'); }); + +test('progress widget reconciles stale pending backup schedule runs', function () { + $tenant = Tenant::factory()->create(); + $tenant->makeCurrent(); + $user = User::factory()->create(); + + $schedule = BackupSchedule::query()->create([ + 'tenant_id' => $tenant->id, + 'name' => 'Nightly', + 'is_enabled' => true, + 'timezone' => 'UTC', + 'frequency' => 'daily', + 'time_of_day' => '01:00:00', + 'days_of_week' => null, + 'policy_types' => ['deviceConfiguration'], + 'include_foundations' => true, + 'retention_keep_last' => 30, + 'next_run_at' => now()->addHour(), + ]); + + $bulkRun = BulkOperationRun::factory()->create([ + 'tenant_id' => $tenant->id, + 'user_id' => $user->id, + 'status' => 'pending', + 'resource' => 'backup_schedule', + 'action' => 'run', + 'total_items' => 1, + 'processed_items' => 0, + 'item_ids' => [(string) $schedule->id], + 'created_at' => now()->subMinutes(2), + 'updated_at' => now()->subMinutes(2), + ]); + + BackupScheduleRun::query()->create([ + 'backup_schedule_id' => $schedule->id, + 'tenant_id' => $tenant->id, + 'user_id' => $user->id, + 'scheduled_for' => now()->startOfMinute(), + 'started_at' => now()->subMinute(), + 'finished_at' => now(), + 'status' => BackupScheduleRun::STATUS_SUCCESS, + 'summary' => null, + ]); + + auth()->login($user); + + Livewire::actingAs($user) + ->test(BulkOperationProgress::class) + ->assertSee('Run Backup schedule') + ->assertSee('1 / 1'); + + expect($bulkRun->refresh()->status)->toBe('completed'); +}); diff --git a/tests/Feature/BulkPruneSkipReasonsTest.php b/tests/Feature/BulkPruneSkipReasonsTest.php index 759ff54..de35dcb 100644 --- a/tests/Feature/BulkPruneSkipReasonsTest.php +++ b/tests/Feature/BulkPruneSkipReasonsTest.php @@ -6,6 +6,7 @@ use App\Models\PolicyVersion; use App\Models\Tenant; use App\Models\User; +use Filament\Facades\Filament; use Illuminate\Foundation\Testing\RefreshDatabase; use Livewire\Livewire; @@ -14,6 +15,11 @@ test('bulk prune records skip reasons', function () { $tenant = Tenant::factory()->create(); $user = User::factory()->create(); + $user->tenants()->syncWithoutDetaching([ + $tenant->getKey() => ['role' => 'owner'], + ]); + + Filament::setTenant($tenant, true); $policyA = Policy::factory()->create(['tenant_id' => $tenant->id]); $current = PolicyVersion::factory()->create([ @@ -37,8 +43,6 @@ 'captured_at' => now()->subDays(10), ]); - $tenant->forceFill(['is_current' => true])->save(); - Livewire::actingAs($user) ->test(PolicyVersionResource\Pages\ListPolicyVersions::class) ->callTableBulkAction('bulk_prune_versions', collect([$current, $tooRecent]), data: [ diff --git a/tests/Feature/BulkPruneVersionsTest.php b/tests/Feature/BulkPruneVersionsTest.php index ec62444..d9cbe48 100644 --- a/tests/Feature/BulkPruneVersionsTest.php +++ b/tests/Feature/BulkPruneVersionsTest.php @@ -5,6 +5,7 @@ use App\Models\PolicyVersion; use App\Models\Tenant; use App\Models\User; +use Filament\Facades\Filament; use Illuminate\Foundation\Testing\RefreshDatabase; use Livewire\Livewire; @@ -13,6 +14,11 @@ test('bulk prune archives eligible policy versions', function () { $tenant = Tenant::factory()->create(); $user = User::factory()->create(); + $user->tenants()->syncWithoutDetaching([ + $tenant->getKey() => ['role' => 'owner'], + ]); + + Filament::setTenant($tenant, true); $policy = Policy::factory()->create(['tenant_id' => $tenant->id]); @@ -30,8 +36,6 @@ 'captured_at' => now()->subDays(120), ]); - $tenant->forceFill(['is_current' => true])->save(); - Livewire::actingAs($user) ->test(PolicyVersionResource\Pages\ListPolicyVersions::class) ->callTableBulkAction('bulk_prune_versions', collect([$eligible, $current]), data: [ diff --git a/tests/Feature/BulkRestoreBackupSetsTest.php b/tests/Feature/BulkRestoreBackupSetsTest.php index 3908e6d..6132119 100644 --- a/tests/Feature/BulkRestoreBackupSetsTest.php +++ b/tests/Feature/BulkRestoreBackupSetsTest.php @@ -6,6 +6,7 @@ use App\Models\BulkOperationRun; use App\Models\Tenant; use App\Models\User; +use Filament\Facades\Filament; use Illuminate\Foundation\Testing\RefreshDatabase; use Livewire\Livewire; @@ -13,8 +14,12 @@ test('backup sets table bulk restore restores archived sets and their items', function () { $tenant = Tenant::factory()->create(); - $tenant->makeCurrent(); $user = User::factory()->create(); + $user->tenants()->syncWithoutDetaching([ + $tenant->getKey() => ['role' => 'owner'], + ]); + + Filament::setTenant($tenant, true); $set = BackupSet::create([ 'tenant_id' => $tenant->id, diff --git a/tests/Feature/BulkRestorePolicyVersionsTest.php b/tests/Feature/BulkRestorePolicyVersionsTest.php index 41d6a81..8f3b429 100644 --- a/tests/Feature/BulkRestorePolicyVersionsTest.php +++ b/tests/Feature/BulkRestorePolicyVersionsTest.php @@ -6,6 +6,7 @@ use App\Models\PolicyVersion; use App\Models\Tenant; use App\Models\User; +use Filament\Facades\Filament; use Illuminate\Foundation\Testing\RefreshDatabase; use Livewire\Livewire; @@ -14,6 +15,11 @@ test('policy versions table bulk restore creates a run and restores archived records', function () { $tenant = Tenant::factory()->create(['is_current' => true]); $user = User::factory()->create(); + $user->tenants()->syncWithoutDetaching([ + $tenant->getKey() => ['role' => 'owner'], + ]); + + Filament::setTenant($tenant, true); $policy = Policy::factory()->create(['tenant_id' => $tenant->id]); $version = PolicyVersion::factory()->create([ diff --git a/tests/Feature/BulkRestoreRestoreRunsTest.php b/tests/Feature/BulkRestoreRestoreRunsTest.php index 35d2bec..a6fa5e9 100644 --- a/tests/Feature/BulkRestoreRestoreRunsTest.php +++ b/tests/Feature/BulkRestoreRestoreRunsTest.php @@ -6,6 +6,7 @@ use App\Models\RestoreRun; use App\Models\Tenant; use App\Models\User; +use Filament\Facades\Filament; use Illuminate\Foundation\Testing\RefreshDatabase; use Livewire\Livewire; @@ -13,8 +14,12 @@ test('restore runs table bulk restore creates a run and restores archived records', function () { $tenant = Tenant::factory()->create(); - $tenant->makeCurrent(); $user = User::factory()->create(); + $user->tenants()->syncWithoutDetaching([ + $tenant->getKey() => ['role' => 'owner'], + ]); + + Filament::setTenant($tenant, true); $backupSet = BackupSet::create([ 'tenant_id' => $tenant->id, diff --git a/tests/Feature/BulkTypeToConfirmTest.php b/tests/Feature/BulkTypeToConfirmTest.php index 1b7748a..43a78a9 100644 --- a/tests/Feature/BulkTypeToConfirmTest.php +++ b/tests/Feature/BulkTypeToConfirmTest.php @@ -4,6 +4,7 @@ use App\Models\Policy; use App\Models\Tenant; use App\Models\User; +use Filament\Facades\Filament; use Illuminate\Foundation\Testing\RefreshDatabase; use Livewire\Livewire; @@ -11,8 +12,12 @@ test('bulk delete requires confirmation string for large batches', function () { $tenant = Tenant::factory()->create(); - $tenant->makeCurrent(); $user = User::factory()->create(); + $user->tenants()->syncWithoutDetaching([ + $tenant->getKey() => ['role' => 'owner'], + ]); + + Filament::setTenant($tenant, true); $policies = Policy::factory()->count(20)->create(['tenant_id' => $tenant->id]); Livewire::actingAs($user) @@ -27,8 +32,12 @@ test('bulk delete fails with incorrect confirmation string', function () { $tenant = Tenant::factory()->create(); - $tenant->makeCurrent(); $user = User::factory()->create(); + $user->tenants()->syncWithoutDetaching([ + $tenant->getKey() => ['role' => 'owner'], + ]); + + Filament::setTenant($tenant, true); $policies = Policy::factory()->count(20)->create(['tenant_id' => $tenant->id]); Livewire::actingAs($user) @@ -43,8 +52,12 @@ test('bulk delete does not require confirmation string for small batches', function () { $tenant = Tenant::factory()->create(); - $tenant->makeCurrent(); $user = User::factory()->create(); + $user->tenants()->syncWithoutDetaching([ + $tenant->getKey() => ['role' => 'owner'], + ]); + + Filament::setTenant($tenant, true); $policies = Policy::factory()->count(10)->create(['tenant_id' => $tenant->id]); Livewire::actingAs($user) diff --git a/tests/Feature/Console/PurgeNonPersistentDataCommandTest.php b/tests/Feature/Console/PurgeNonPersistentDataCommandTest.php new file mode 100644 index 0000000..40192e5 --- /dev/null +++ b/tests/Feature/Console/PurgeNonPersistentDataCommandTest.php @@ -0,0 +1,143 @@ +create(['name' => 'Tenant A']); + $tenantB = Tenant::factory()->create(['name' => 'Tenant B']); + + SettingsCatalogCategory::create([ + 'category_id' => 'cat-1', + 'display_name' => 'Account Management', + 'description' => null, + ]); + + SettingsCatalogDefinition::create([ + 'definition_id' => 'def-1', + 'display_name' => 'Deletion Policy', + 'description' => null, + 'help_text' => null, + 'category_id' => 'cat-1', + 'ux_behavior' => null, + 'raw' => [], + ]); + + $user = User::factory()->create(); + + $policyA = Policy::factory()->create(['tenant_id' => $tenantA->id]); + $policyB = Policy::factory()->create(['tenant_id' => $tenantB->id]); + + PolicyVersion::factory()->create([ + 'tenant_id' => $tenantA->id, + 'policy_id' => $policyA->id, + 'version_number' => 1, + ]); + + PolicyVersion::factory()->create([ + 'tenant_id' => $tenantB->id, + 'policy_id' => $policyB->id, + 'version_number' => 1, + ]); + + $backupSetA = BackupSet::factory()->create(['tenant_id' => $tenantA->id]); + BackupItem::factory()->create([ + 'tenant_id' => $tenantA->id, + 'backup_set_id' => $backupSetA->id, + 'policy_id' => $policyA->id, + ]); + + RestoreRun::factory()->create([ + 'tenant_id' => $tenantA->id, + 'backup_set_id' => $backupSetA->id, + ]); + + AuditLog::create([ + 'tenant_id' => $tenantA->id, + 'actor_id' => null, + 'actor_email' => null, + 'actor_name' => null, + 'action' => 'test.action', + 'resource_type' => null, + 'resource_id' => null, + 'status' => 'success', + 'metadata' => null, + 'recorded_at' => now(), + ]); + + BulkOperationRun::factory()->create([ + 'tenant_id' => $tenantA->id, + 'user_id' => $user->id, + 'status' => 'completed', + ]); + + $scheduleA = BackupSchedule::create([ + 'tenant_id' => $tenantA->id, + 'name' => 'Schedule A', + 'is_enabled' => true, + 'timezone' => 'UTC', + 'frequency' => 'daily', + 'time_of_day' => '10:00:00', + 'days_of_week' => null, + 'policy_types' => ['deviceConfiguration'], + 'include_foundations' => true, + 'retention_keep_last' => 30, + 'last_run_at' => null, + 'last_run_status' => null, + 'next_run_at' => now()->addHour(), + ]); + + BackupScheduleRun::create([ + 'backup_schedule_id' => $scheduleA->id, + 'tenant_id' => $tenantA->id, + 'scheduled_for' => now()->startOfMinute(), + 'started_at' => null, + 'finished_at' => null, + 'status' => BackupScheduleRun::STATUS_SUCCESS, + 'summary' => null, + 'error_code' => null, + 'error_message' => null, + 'backup_set_id' => $backupSetA->id, + ]); + + expect(Policy::query()->where('tenant_id', $tenantA->id)->count())->toBeGreaterThan(0); + expect(BackupSet::withTrashed()->where('tenant_id', $tenantA->id)->count())->toBeGreaterThan(0); + expect(BackupScheduleRun::query()->where('tenant_id', $tenantA->id)->count())->toBeGreaterThan(0); + + $this->artisan('tenantpilot:purge-nonpersistent', [ + 'tenant' => $tenantA->id, + '--force' => true, + '--no-interaction' => true, + ])->assertSuccessful(); + + expect(Policy::query()->where('tenant_id', $tenantA->id)->count())->toBe(0); + expect(PolicyVersion::withTrashed()->where('tenant_id', $tenantA->id)->count())->toBe(0); + expect(BackupItem::withTrashed()->where('tenant_id', $tenantA->id)->count())->toBe(0); + expect(BackupSet::withTrashed()->where('tenant_id', $tenantA->id)->count())->toBe(0); + expect(RestoreRun::withTrashed()->where('tenant_id', $tenantA->id)->count())->toBe(0); + expect(AuditLog::query()->where('tenant_id', $tenantA->id)->count())->toBe(0); + expect(BulkOperationRun::query()->where('tenant_id', $tenantA->id)->count())->toBe(0); + expect(BackupScheduleRun::query()->where('tenant_id', $tenantA->id)->count())->toBe(0); + + expect(BackupSchedule::query()->where('tenant_id', $tenantA->id)->count())->toBe(0); + + expect(Policy::query()->where('tenant_id', $tenantB->id)->count())->toBe(1); + expect(PolicyVersion::withTrashed()->where('tenant_id', $tenantB->id)->count())->toBe(1); + + expect(SettingsCatalogCategory::query()->count())->toBe(1); + expect(SettingsCatalogDefinition::query()->count())->toBe(1); +}); diff --git a/tests/Feature/DeviceComplianceScriptPolicyTypeTest.php b/tests/Feature/DeviceComplianceScriptPolicyTypeTest.php new file mode 100644 index 0000000..c24b180 --- /dev/null +++ b/tests/Feature/DeviceComplianceScriptPolicyTypeTest.php @@ -0,0 +1,186 @@ + + */ + public array $requestCalls = []; + + /** + * @param array $requestResponses + */ + public function __construct( + private readonly GraphResponse $applyPolicyResponse, + private array $requestResponses = [], + ) {} + + 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, ['payload' => []]); + } + + public function getOrganization(array $options = []): GraphResponse + { + return new GraphResponse(true, []); + } + + public function applyPolicy(string $policyType, string $policyId, array $payload, array $options = []): GraphResponse + { + return $this->applyPolicyResponse; + } + + public function getServicePrincipalPermissions(array $options = []): GraphResponse + { + return new GraphResponse(true, []); + } + + public function request(string $method, string $path, array $options = []): GraphResponse + { + $this->requestCalls[] = [ + 'method' => strtoupper($method), + 'path' => $path, + 'payload' => $options['json'] ?? null, + ]; + + return array_shift($this->requestResponses) ?? new GraphResponse(true, []); + } +} + +it('includes device compliance scripts in supported policy types', function () { + $supported = collect(config('tenantpilot.supported_policy_types', [])) + ->keyBy('type') + ->all(); + + expect($supported)->toHaveKey('deviceComplianceScript'); + expect($supported['deviceComplianceScript']['endpoint'] ?? null)->toBe('deviceManagement/deviceComplianceScripts'); + expect($supported['deviceComplianceScript']['restore'] ?? null)->toBe('enabled'); +}); + +it('defines device compliance script graph contract with correct assignment payload key', function () { + $contract = config('graph_contracts.types.deviceComplianceScript'); + + expect($contract)->toBeArray(); + expect($contract['resource'] ?? null)->toBe('deviceManagement/deviceComplianceScripts'); + expect($contract['assignments_create_path'] ?? null)->toBe('/deviceManagement/deviceComplianceScripts/{id}/assign'); + expect($contract['assignments_payload_key'] ?? null)->toBe('deviceHealthScriptAssignments'); +}); + +it('restores device compliance script assignments via assign action', function () { + $client = new DeviceComplianceScriptRestoreGraphClient( + applyPolicyResponse: new GraphResponse(true, []), + requestResponses: [ + new GraphResponse(true, []), // assign action + ], + ); + app()->instance(GraphClientInterface::class, $client); + + $tenant = Tenant::factory()->create([ + 'tenant_id' => 'tenant-1', + ]); + + $policy = Policy::factory()->create([ + 'tenant_id' => $tenant->id, + 'external_id' => 'dcs-1', + 'policy_type' => 'deviceComplianceScript', + 'platform' => 'windows', + ]); + + $backupSet = BackupSet::factory()->create([ + 'tenant_id' => $tenant->id, + 'status' => 'completed', + 'item_count' => 1, + ]); + + $backupItem = BackupItem::factory()->create([ + 'tenant_id' => $tenant->id, + 'backup_set_id' => $backupSet->id, + 'policy_id' => $policy->id, + 'policy_identifier' => $policy->external_id, + 'policy_type' => $policy->policy_type, + 'platform' => $policy->platform, + 'payload' => [ + 'id' => $policy->external_id, + '@odata.type' => '#microsoft.graph.deviceComplianceScript', + ], + 'assignments' => [ + [ + 'id' => 'assignment-1', + 'intent' => 'apply', + 'target' => [ + '@odata.type' => '#microsoft.graph.groupAssignmentTarget', + 'groupId' => 'source-group-1', + ], + ], + ], + ]); + + $user = User::factory()->create(['email' => 'tester@example.com']); + $this->actingAs($user); + + $service = app(RestoreService::class); + $service->execute( + tenant: $tenant, + backupSet: $backupSet, + selectedItemIds: [$backupItem->id], + dryRun: false, + actorEmail: $user->email, + actorName: $user->name, + groupMapping: [ + 'source-group-1' => 'target-group-1', + ], + ); + + $postCalls = collect($client->requestCalls) + ->filter(fn (array $call) => $call['method'] === 'POST') + ->values(); + + expect($postCalls)->toHaveCount(1); + expect($postCalls[0]['path'])->toBe('/deviceManagement/deviceComplianceScripts/dcs-1/assign'); + + $payloadAssignments = $postCalls[0]['payload']['deviceHealthScriptAssignments'] ?? []; + $groupIds = collect($payloadAssignments)->pluck('target.groupId')->all(); + + expect($groupIds)->toBe(['target-group-1']); + expect($payloadAssignments[0])->not->toHaveKey('id'); +}); + +it('normalizes device compliance script key fields', function () { + config([ + 'tenantpilot.display.show_script_content' => false, + ]); + + $normalized = app(PolicyNormalizer::class)->normalize([ + '@odata.type' => '#microsoft.graph.deviceComplianceScript', + 'displayName' => 'My script', + 'runAsAccount' => 'system', + 'runAs32Bit' => true, + 'enforceSignatureCheck' => false, + 'detectionScriptContent' => base64_encode("Write-Host 'hello'\n"), + ], 'deviceComplianceScript', 'windows'); + + $settings = $normalized['settings'][0]['entries'] ?? []; + $byKey = collect($settings)->keyBy('key'); + + expect($byKey['Run as account']['value'] ?? null)->toBe('system'); + expect($byKey['Run as 32-bit']['value'] ?? null)->toBe('Enabled'); + expect($byKey['Enforce signature check']['value'] ?? null)->toBe('Disabled'); +}); diff --git a/tests/Feature/EndpointSecurityIntentRestoreSanitizationTest.php b/tests/Feature/EndpointSecurityIntentRestoreSanitizationTest.php new file mode 100644 index 0000000..c19edbc --- /dev/null +++ b/tests/Feature/EndpointSecurityIntentRestoreSanitizationTest.php @@ -0,0 +1,102 @@ +}> */ + public array $applyPolicyCalls = []; + + 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, ['payload' => []]); + } + + public function getOrganization(array $options = []): GraphResponse + { + return new GraphResponse(true, []); + } + + public function applyPolicy(string $policyType, string $policyId, array $payload, array $options = []): GraphResponse + { + $this->applyPolicyCalls[] = [ + 'policyType' => $policyType, + 'policyId' => $policyId, + 'payload' => $payload, + 'options' => $options, + ]; + + return new GraphResponse(true, []); + } + + public function getServicePrincipalPermissions(array $options = []): GraphResponse + { + return new GraphResponse(true, []); + } + + public function request(string $method, string $path, array $options = []): GraphResponse + { + return new GraphResponse(true, []); + } +} + +test('restore strips non-patchable fields from endpoint security intent updates', function () { + $client = new EndpointSecurityIntentRestoreGraphClient; + app()->instance(GraphClientInterface::class, $client); + + $tenant = Tenant::factory()->create(); + $backupSet = BackupSet::factory()->for($tenant)->create([ + 'status' => 'completed', + 'item_count' => 1, + ]); + + $backupItem = BackupItem::factory()->for($tenant)->for($backupSet)->create([ + 'policy_id' => null, + 'policy_identifier' => 'intent-1', + 'policy_type' => 'endpointSecurityIntent', + 'platform' => 'windows', + 'payload' => [ + 'id' => 'intent-1', + '@odata.type' => '#microsoft.graph.deviceManagementIntent', + 'displayName' => 'SPO Account Protection', + 'description' => 'Demo', + 'isAssigned' => false, + 'templateId' => '0f2b5d70-d4e9-4156-8c16-1397eb6c54a5', + 'isMigratingToConfigurationPolicy' => false, + ], + 'assignments' => null, + ]); + + $service = app(RestoreService::class); + $run = $service->execute( + tenant: $tenant, + backupSet: $backupSet, + selectedItemIds: [$backupItem->id], + dryRun: false, + ); + + expect($run->status)->toBe('completed'); + expect($client->applyPolicyCalls)->toHaveCount(1); + + $payload = $client->applyPolicyCalls[0]['payload'] ?? []; + expect($payload)->toBeArray(); + expect($payload)->toHaveKey('displayName'); + expect($payload)->toHaveKey('description'); + expect($payload)->not->toHaveKey('id'); + expect($payload)->not->toHaveKey('isAssigned'); + expect($payload)->not->toHaveKey('templateId'); + expect($payload)->not->toHaveKey('isMigratingToConfigurationPolicy'); +}); diff --git a/tests/Feature/EndpointSecurityPolicyRestore023Test.php b/tests/Feature/EndpointSecurityPolicyRestore023Test.php new file mode 100644 index 0000000..1a4821e --- /dev/null +++ b/tests/Feature/EndpointSecurityPolicyRestore023Test.php @@ -0,0 +1,265 @@ +}> */ + public array $applyPolicyCalls = []; + + /** @var array}> */ + public array $requestCalls = []; + + /** + * @param array $requestMap + */ + public function __construct( + private readonly GraphResponse $applyPolicyResponse, + private readonly array $requestMap = [], + ) {} + + 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, ['payload' => []]); + } + + public function getOrganization(array $options = []): GraphResponse + { + return new GraphResponse(true, []); + } + + public function applyPolicy(string $policyType, string $policyId, array $payload, array $options = []): GraphResponse + { + $this->applyPolicyCalls[] = [ + 'policyType' => $policyType, + 'policyId' => $policyId, + 'payload' => $payload, + 'options' => $options, + ]; + + return $this->applyPolicyResponse; + } + + public function getServicePrincipalPermissions(array $options = []): GraphResponse + { + return new GraphResponse(true, []); + } + + public function request(string $method, string $path, array $options = []): GraphResponse + { + $this->requestCalls[] = [ + 'method' => strtoupper($method), + 'path' => $path, + 'options' => $options, + ]; + + foreach ($this->requestMap as $needle => $response) { + if (is_string($needle) && $needle !== '' && str_contains($path, $needle)) { + return $response; + } + } + + return new GraphResponse(true, []); + } +} + +test('restore executes endpoint security policy settings via settings endpoint', function () { + $client = new EndpointSecurityRestoreGraphClient(new GraphResponse(true, [])); + app()->instance(GraphClientInterface::class, $client); + + $tenant = Tenant::factory()->create(); + $backupSet = BackupSet::factory()->for($tenant)->create([ + 'status' => 'completed', + 'item_count' => 1, + ]); + + $backupItem = BackupItem::factory()->for($tenant)->for($backupSet)->create([ + 'policy_id' => null, + 'policy_identifier' => 'esp-1', + 'policy_type' => 'endpointSecurityPolicy', + 'platform' => 'windows', + 'payload' => [ + 'id' => 'esp-1', + '@odata.type' => '#microsoft.graph.deviceManagementConfigurationPolicy', + 'name' => 'Endpoint Security Policy', + 'platforms' => ['windows10'], + 'technologies' => ['endpointSecurity'], + 'templateReference' => [ + 'templateId' => 'template-1', + 'templateFamily' => 'endpointSecurityFirewall', + 'templateDisplayName' => 'Windows Firewall Rules', + 'templateDisplayVersion' => 'Version 1', + ], + 'settings' => [ + [ + 'id' => 's1', + 'settingInstance' => [ + '@odata.type' => '#microsoft.graph.deviceManagementConfigurationSimpleSettingInstance', + 'settingDefinitionId' => 'device_vendor_msft_policy_config_defender_allowrealtimemonitoring', + 'simpleSettingValue' => [ + 'value' => 1, + ], + ], + ], + ], + ], + 'assignments' => null, + ]); + + $service = app(RestoreService::class); + $run = $service->execute( + tenant: $tenant, + backupSet: $backupSet, + selectedItemIds: [$backupItem->id], + dryRun: false, + ); + + expect($run->status)->toBe('completed'); + expect($client->applyPolicyCalls)->toHaveCount(1); + expect($client->applyPolicyCalls[0]['policyType'])->toBe('endpointSecurityPolicy'); + expect($client->applyPolicyCalls[0]['payload'])->not->toHaveKey('settings'); + + $settingsCalls = collect($client->requestCalls) + ->filter(fn (array $call) => $call['method'] === 'POST' && str_contains($call['path'], '/settings')) + ->values(); + + expect($settingsCalls)->toHaveCount(1); + expect($settingsCalls[0]['path'])->toContain('deviceManagement/configurationPolicies/esp-1/settings'); + + $body = $settingsCalls[0]['options']['json'] ?? null; + expect($body)->toBeArray()->not->toBeEmpty(); + expect($body[0]['settingInstance']['settingDefinitionId'] ?? null) + ->toBe('device_vendor_msft_policy_config_defender_allowrealtimemonitoring'); +}); + +test('restore fails when endpoint security template is missing', function () { + $applyNotFound = new GraphResponse(false, ['error' => ['message' => 'Not found']], 404, [], [], [ + 'error_code' => 'NotFound', + 'error_message' => 'Not found', + ]); + + $templateNotFound = new GraphResponse(false, ['error' => ['message' => 'Template missing']], 404, [], [], [ + 'error_code' => 'NotFound', + 'error_message' => 'Template missing', + ]); + + $client = new EndpointSecurityRestoreGraphClient($applyNotFound, [ + 'configurationPolicyTemplates' => $templateNotFound, + ]); + app()->instance(GraphClientInterface::class, $client); + + $tenant = Tenant::factory()->create(); + $backupSet = BackupSet::factory()->for($tenant)->create([ + 'status' => 'completed', + 'item_count' => 1, + ]); + + $backupItem = BackupItem::factory()->for($tenant)->for($backupSet)->create([ + 'policy_id' => null, + 'policy_identifier' => 'esp-missing', + 'policy_type' => 'endpointSecurityPolicy', + 'platform' => 'windows', + 'payload' => [ + 'id' => 'esp-missing', + '@odata.type' => '#microsoft.graph.deviceManagementConfigurationPolicy', + 'name' => 'Endpoint Security Policy', + 'platforms' => ['windows10'], + 'technologies' => ['endpointSecurity'], + 'templateReference' => [ + 'templateId' => 'missing-template', + ], + 'settings' => [ + [ + 'id' => 's1', + 'settingInstance' => [ + '@odata.type' => '#microsoft.graph.deviceManagementConfigurationSimpleSettingInstance', + 'settingDefinitionId' => 'device_vendor_msft_policy_config_defender_allowrealtimemonitoring', + 'simpleSettingValue' => [ + 'value' => 1, + ], + ], + ], + ], + ], + 'assignments' => null, + ]); + + $service = app(RestoreService::class); + $run = $service->execute( + tenant: $tenant, + backupSet: $backupSet, + selectedItemIds: [$backupItem->id], + dryRun: false, + ); + + expect($run->status)->toBe('failed'); + + $createCalls = collect($client->requestCalls) + ->filter(fn (array $call) => $call['method'] === 'POST' && $call['path'] === 'deviceManagement/configurationPolicies') + ->values(); + + expect($createCalls)->toHaveCount(0); +}); + +test('restore risk checks flag missing endpoint security templates as blocking', function () { + $templateNotFound = new GraphResponse(false, ['error' => ['message' => 'Template missing']], 404, [], [], [ + 'error_code' => 'NotFound', + 'error_message' => 'Template missing', + ]); + + $client = new EndpointSecurityRestoreGraphClient(new GraphResponse(true, []), [ + 'configurationPolicyTemplates' => $templateNotFound, + ]); + app()->instance(GraphClientInterface::class, $client); + + $tenant = Tenant::factory()->create(); + $backupSet = BackupSet::factory()->for($tenant)->create([ + 'status' => 'completed', + 'item_count' => 1, + ]); + + $backupItem = BackupItem::factory()->for($tenant)->for($backupSet)->create([ + 'policy_id' => null, + 'policy_identifier' => 'esp-missing', + 'policy_type' => 'endpointSecurityPolicy', + 'platform' => 'windows', + 'payload' => [ + 'id' => 'esp-missing', + '@odata.type' => '#microsoft.graph.deviceManagementConfigurationPolicy', + 'templateReference' => [ + 'templateId' => 'missing-template', + 'templateFamily' => 'endpointSecurityFirewall', + ], + 'settings' => [], + ], + 'assignments' => null, + ]); + + $checker = app(RestoreRiskChecker::class); + $result = $checker->check( + tenant: $tenant, + backupSet: $backupSet, + selectedItemIds: [$backupItem->id], + groupMapping: [], + ); + + $results = collect($result['results'] ?? []); + $templateCheck = $results->firstWhere('code', 'endpoint_security_templates'); + + expect($templateCheck)->not->toBeNull(); + expect($templateCheck['severity'] ?? null)->toBe('blocking'); +}); diff --git a/tests/Feature/Filament/AppProtectionPolicySettingsDisplayTest.php b/tests/Feature/Filament/AppProtectionPolicySettingsDisplayTest.php index c214e10..8f00a23 100644 --- a/tests/Feature/Filament/AppProtectionPolicySettingsDisplayTest.php +++ b/tests/Feature/Filament/AppProtectionPolicySettingsDisplayTest.php @@ -12,7 +12,7 @@ test('policy detail shows app protection settings in readable sections', function () { $tenant = Tenant::create([ - 'tenant_id' => env('INTUNE_TENANT_ID', 'local-tenant'), + 'tenant_id' => 'local-tenant', 'name' => 'Tenant One', 'metadata' => [], 'is_current' => true, @@ -47,9 +47,12 @@ ]); $user = User::factory()->create(); + $user->tenants()->syncWithoutDetaching([ + $tenant->getKey() => ['role' => 'owner'], + ]); $response = $this->actingAs($user) - ->get(PolicyResource::getUrl('view', ['record' => $policy])); + ->get(PolicyResource::getUrl('view', ['record' => $policy], tenant: $tenant)); $response->assertOk(); $response->assertSee('Data Protection'); diff --git a/tests/Feature/Filament/BackupCreationTest.php b/tests/Feature/Filament/BackupCreationTest.php index e619c15..a08c7d5 100644 --- a/tests/Feature/Filament/BackupCreationTest.php +++ b/tests/Feature/Filament/BackupCreationTest.php @@ -8,9 +8,9 @@ use App\Services\Graph\GraphClientInterface; use App\Services\Graph\GraphResponse; use App\Services\Graph\ScopeTagResolver; +use App\Services\Intune\BackupService; use App\Services\Intune\PolicySnapshotService; use Illuminate\Foundation\Testing\RefreshDatabase; -use Livewire\Livewire; use Mockery\MockInterface; uses(RefreshDatabase::class); @@ -73,7 +73,7 @@ public function request(string $method, string $path, array $options = []): Grap }); $tenant = Tenant::create([ - 'tenant_id' => env('INTUNE_TENANT_ID', 'local-tenant'), + 'tenant_id' => 'local-tenant', 'name' => 'Tenant One', 'metadata' => [], ]); @@ -106,14 +106,16 @@ public function request(string $method, string $path, array $options = []): Grap 'name' => 'Test backup', ]); - Livewire::test(\App\Filament\Resources\BackupSetResource\RelationManagers\BackupItemsRelationManager::class, [ - 'ownerRecord' => $backupSet, - 'pageClass' => \App\Filament\Resources\BackupSetResource\Pages\ViewBackupSet::class, - ])->callTableAction('addPolicies', data: [ - 'policy_ids' => [$policyA->id], - 'include_assignments' => false, - 'include_scope_tags' => true, - ]); + app(BackupService::class)->addPoliciesToSet( + tenant: $tenant, + backupSet: $backupSet, + policyIds: [$policyA->id], + actorEmail: $user->email, + actorName: $user->name, + includeAssignments: false, + includeScopeTags: true, + includeFoundations: true, + ); $backupSet->refresh(); diff --git a/tests/Feature/Filament/BackupItemsBulkRemoveTest.php b/tests/Feature/Filament/BackupItemsBulkRemoveTest.php new file mode 100644 index 0000000..a1a0ae6 --- /dev/null +++ b/tests/Feature/Filament/BackupItemsBulkRemoveTest.php @@ -0,0 +1,74 @@ +create(); + $tenant->makeCurrent(); + + $user = User::factory()->create(); + + $backupSet = BackupSet::factory()->create([ + 'tenant_id' => $tenant->id, + 'name' => 'Test backup', + 'item_count' => 0, + ]); + + $policyA = Policy::factory()->create([ + 'tenant_id' => $tenant->id, + 'ignored_at' => null, + 'last_synced_at' => now(), + ]); + + $policyB = Policy::factory()->create([ + 'tenant_id' => $tenant->id, + 'ignored_at' => null, + 'last_synced_at' => now(), + ]); + + $itemA = BackupItem::factory()->create([ + 'tenant_id' => $tenant->id, + 'backup_set_id' => $backupSet->id, + 'policy_id' => $policyA->id, + 'policy_identifier' => $policyA->external_id, + 'policy_type' => $policyA->policy_type, + 'platform' => $policyA->platform, + ]); + + $itemB = BackupItem::factory()->create([ + 'tenant_id' => $tenant->id, + 'backup_set_id' => $backupSet->id, + 'policy_id' => $policyB->id, + 'policy_identifier' => $policyB->external_id, + 'policy_type' => $policyB->policy_type, + 'platform' => $policyB->platform, + ]); + + $backupSet->update(['item_count' => $backupSet->items()->count()]); + expect($backupSet->refresh()->item_count)->toBe(2); + + Livewire::actingAs($user) + ->test(BackupItemsRelationManager::class, [ + 'ownerRecord' => $backupSet, + 'pageClass' => \App\Filament\Resources\BackupSetResource\Pages\ViewBackupSet::class, + ]) + ->callTableBulkAction('bulk_remove', collect([$itemA, $itemB])) + ->assertHasNoTableBulkActionErrors(); + + $backupSet->refresh(); + + expect($backupSet->items()->count())->toBe(0); + expect($backupSet->item_count)->toBe(0); + + $this->assertSoftDeleted('backup_items', ['id' => $itemA->id]); + $this->assertSoftDeleted('backup_items', ['id' => $itemB->id]); +}); diff --git a/tests/Feature/Filament/BackupSetPolicyPickerTableTest.php b/tests/Feature/Filament/BackupSetPolicyPickerTableTest.php new file mode 100644 index 0000000..a1a9143 --- /dev/null +++ b/tests/Feature/Filament/BackupSetPolicyPickerTableTest.php @@ -0,0 +1,196 @@ +create(); + $tenant->makeCurrent(); + + $user = User::factory()->create(); + + $backupSet = BackupSet::factory()->create([ + 'tenant_id' => $tenant->id, + 'name' => 'Test backup', + ]); + + $policies = Policy::factory()->count(2)->create([ + 'tenant_id' => $tenant->id, + 'ignored_at' => null, + 'last_synced_at' => now(), + ]); + + $this->mock(BackupService::class, function (MockInterface $mock) use ($tenant, $backupSet, $policies, $user) { + $mock->shouldReceive('addPoliciesToSet') + ->once() + ->withArgs(function ($tenantArg, $backupSetArg, $policyIds, $actorEmail, $actorName, $includeAssignments, $includeScopeTags, $includeFoundations) use ($tenant, $backupSet, $policies, $user) { + expect($tenantArg->id)->toBe($tenant->id); + expect($backupSetArg->id)->toBe($backupSet->id); + expect($policyIds)->toBe($policies->pluck('id')->all()); + expect($actorEmail)->toBe($user->email); + expect($actorName)->toBe($user->name); + expect($includeAssignments)->toBeTrue(); + expect($includeScopeTags)->toBeTrue(); + expect($includeFoundations)->toBeTrue(); + + return true; + }); + }); + + Livewire::actingAs($user) + ->test(BackupSetPolicyPickerTable::class, [ + 'backupSetId' => $backupSet->id, + ]) + ->callTableBulkAction('add_selected_to_backup_set', $policies) + ->assertHasNoTableBulkActionErrors(); + + $notifications = session('filament.notifications', []); + + expect($notifications)->not->toBeEmpty(); + expect(collect($notifications)->last()['title'] ?? null)->toBe('Backup items added'); + expect(collect($notifications)->last()['status'] ?? null)->toBe('success'); +}); + +test('policy picker table does not warn if failures already existed but did not increase', function () { + $tenant = Tenant::factory()->create(); + $tenant->makeCurrent(); + + $user = User::factory()->create(); + + $backupSet = BackupSet::factory()->create([ + 'tenant_id' => $tenant->id, + 'name' => 'Test backup', + 'status' => 'partial', + 'metadata' => [ + 'failures' => [ + ['policy_id' => 1, 'reason' => 'Previous failure', 'status' => 500], + ], + ], + ]); + + $policies = Policy::factory()->count(1)->create([ + 'tenant_id' => $tenant->id, + 'ignored_at' => null, + 'last_synced_at' => now(), + ]); + + $this->mock(BackupService::class, function (MockInterface $mock) use ($backupSet) { + $mock->shouldReceive('addPoliciesToSet') + ->once() + ->andReturn($backupSet); + }); + + Livewire::actingAs($user) + ->test(BackupSetPolicyPickerTable::class, [ + 'backupSetId' => $backupSet->id, + ]) + ->callTableBulkAction('add_selected_to_backup_set', $policies) + ->assertHasNoTableBulkActionErrors(); + + $notifications = session('filament.notifications', []); + + expect($notifications)->not->toBeEmpty(); + expect(collect($notifications)->last()['title'] ?? null)->toBe('Backup items added'); + expect(collect($notifications)->last()['status'] ?? null)->toBe('success'); +}); + +test('policy picker table warns when new failures were added', function () { + $tenant = Tenant::factory()->create(); + $tenant->makeCurrent(); + + $user = User::factory()->create(); + + $backupSet = BackupSet::factory()->create([ + 'tenant_id' => $tenant->id, + 'name' => 'Test backup', + 'status' => 'completed', + 'metadata' => ['failures' => []], + ]); + + $policies = Policy::factory()->count(1)->create([ + 'tenant_id' => $tenant->id, + 'ignored_at' => null, + 'last_synced_at' => now(), + ]); + + $this->mock(BackupService::class, function (MockInterface $mock) use ($backupSet) { + $mock->shouldReceive('addPoliciesToSet') + ->once() + ->andReturnUsing(function () use ($backupSet) { + $backupSet->update([ + 'status' => 'partial', + 'metadata' => [ + 'failures' => [ + ['policy_id' => 123, 'reason' => 'New failure', 'status' => 500], + ], + ], + ]); + + return $backupSet->refresh(); + }); + }); + + Livewire::actingAs($user) + ->test(BackupSetPolicyPickerTable::class, [ + 'backupSetId' => $backupSet->id, + ]) + ->callTableBulkAction('add_selected_to_backup_set', $policies) + ->assertHasNoTableBulkActionErrors(); + + $notifications = session('filament.notifications', []); + + expect($notifications)->not->toBeEmpty(); + expect(collect($notifications)->last()['title'] ?? null)->toBe('Backup items added with failures'); + expect(collect($notifications)->last()['status'] ?? null)->toBe('warning'); +}); + +test('policy picker table can filter by has versions', function () { + $tenant = Tenant::factory()->create(); + $tenant->makeCurrent(); + + $user = User::factory()->create(); + + $backupSet = BackupSet::factory()->create([ + 'tenant_id' => $tenant->id, + 'name' => 'Test backup', + ]); + + $withVersions = Policy::factory()->create([ + 'tenant_id' => $tenant->id, + 'display_name' => 'With Versions', + 'ignored_at' => null, + 'last_synced_at' => now(), + ]); + + PolicyVersion::factory()->create([ + 'tenant_id' => $tenant->id, + 'policy_id' => $withVersions->id, + 'policy_type' => $withVersions->policy_type, + 'platform' => $withVersions->platform, + ]); + + $withoutVersions = Policy::factory()->create([ + 'tenant_id' => $tenant->id, + 'display_name' => 'Without Versions', + 'ignored_at' => null, + 'last_synced_at' => now(), + ]); + + Livewire::actingAs($user) + ->test(BackupSetPolicyPickerTable::class, [ + 'backupSetId' => $backupSet->id, + ]) + ->filterTable('has_versions', '1') + ->assertSee('With Versions') + ->assertDontSee('Without Versions'); +}); diff --git a/tests/Feature/Filament/EnrollmentAutopilotSettingsDisplayTest.php b/tests/Feature/Filament/EnrollmentAutopilotSettingsDisplayTest.php new file mode 100644 index 0000000..0ed7cc6 --- /dev/null +++ b/tests/Feature/Filament/EnrollmentAutopilotSettingsDisplayTest.php @@ -0,0 +1,154 @@ + 'local-tenant', + 'name' => 'Tenant One', + 'metadata' => [], + 'is_current' => true, + ]); + + $tenant->makeCurrent(); + + $this->tenant = $tenant; + $this->user = User::factory()->create(); + $this->user->tenants()->syncWithoutDetaching([ + $tenant->getKey() => ['role' => 'owner'], + ]); +}); + +test('policy detail renders normalized settings for Autopilot profiles', function () { + $policy = Policy::create([ + 'tenant_id' => $this->tenant->id, + 'external_id' => 'autopilot-1', + 'policy_type' => 'windowsAutopilotDeploymentProfile', + 'display_name' => 'Autopilot Profile A', + 'platform' => 'windows', + ]); + + PolicyVersion::create([ + 'tenant_id' => $this->tenant->id, + 'policy_id' => $policy->id, + 'version_number' => 1, + 'policy_type' => $policy->policy_type, + 'platform' => $policy->platform, + 'created_by' => 'tester@example.com', + 'captured_at' => CarbonImmutable::now(), + 'snapshot' => [ + '@odata.type' => '#microsoft.graph.azureADWindowsAutopilotDeploymentProfile', + 'displayName' => 'Autopilot Profile A', + 'deviceNameTemplate' => 'DEV-%SERIAL%', + 'enableWhiteGlove' => true, + 'outOfBoxExperienceSettings' => [ + 'hideEULA' => true, + 'userType' => 'standard', + ], + ], + ]); + + $response = $this->actingAs($this->user) + ->get(PolicyResource::getUrl('view', ['record' => $policy], tenant: $this->tenant)); + + $response->assertOk(); + $response->assertSee('Settings'); + $response->assertSee('Autopilot profile'); + $response->assertSee('Device name template'); + $response->assertSee('DEV-%SERIAL%'); + $response->assertSee('Pre-provisioning (White Glove)'); + $response->assertSee('Enabled'); + $response->assertSee('OOBE: Hide EULA'); + $response->assertSee('OOBE: User type'); +}); + +test('policy detail renders normalized settings for Enrollment Status Page (ESP)', function () { + $policy = Policy::create([ + 'tenant_id' => $this->tenant->id, + 'external_id' => 'esp-1', + 'policy_type' => 'windowsEnrollmentStatusPage', + 'display_name' => 'ESP A', + 'platform' => 'windows', + ]); + + PolicyVersion::create([ + 'tenant_id' => $this->tenant->id, + 'policy_id' => $policy->id, + 'version_number' => 1, + 'policy_type' => $policy->policy_type, + 'platform' => $policy->platform, + 'created_by' => 'tester@example.com', + 'captured_at' => CarbonImmutable::now(), + 'snapshot' => [ + '@odata.type' => '#microsoft.graph.windowsEnrollmentStatusPageConfiguration', + 'displayName' => 'ESP A', + 'priority' => 1, + 'showInstallationProgress' => true, + 'installProgressTimeoutInMinutes' => 60, + 'selectedMobileAppIds' => ['app-1', 'app-2'], + ], + ]); + + $response = $this->actingAs($this->user) + ->get(PolicyResource::getUrl('view', ['record' => $policy], tenant: $this->tenant)); + + $response->assertOk(); + $response->assertSee('Settings'); + $response->assertSee('Enrollment Status Page (ESP)'); + $response->assertSee('Priority'); + $response->assertSee('1'); + $response->assertSee('Show installation progress'); + $response->assertSee('Enabled'); + $response->assertSee('Selected mobile app IDs'); + $response->assertSee('app-1'); + $response->assertSee('app-2'); +}); + +test('policy detail renders normalized settings for platform restrictions (enrollment)', function () { + $policy = Policy::create([ + 'tenant_id' => $this->tenant->id, + 'external_id' => 'enroll-restrict-1', + 'policy_type' => 'deviceEnrollmentPlatformRestrictionsConfiguration', + 'display_name' => 'Restriction A', + 'platform' => 'all', + ]); + + PolicyVersion::create([ + 'tenant_id' => $this->tenant->id, + 'policy_id' => $policy->id, + 'version_number' => 1, + 'policy_type' => $policy->policy_type, + 'platform' => $policy->platform, + 'created_by' => 'tester@example.com', + 'captured_at' => CarbonImmutable::now(), + 'snapshot' => [ + '@odata.type' => '#microsoft.graph.deviceEnrollmentPlatformRestrictionConfiguration', + 'displayName' => 'Restriction A', + 'deviceEnrollmentConfigurationType' => 'deviceEnrollmentPlatformRestrictionConfiguration', + 'platformRestriction' => [ + 'platformBlocked' => false, + 'personalDeviceEnrollmentBlocked' => true, + 'blockedSkus' => ['sku-1'], + ], + ], + ]); + + $response = $this->actingAs($this->user) + ->get(PolicyResource::getUrl('view', ['record' => $policy], tenant: $this->tenant)); + + $response->assertOk(); + $response->assertSee('Settings'); + $response->assertSee('Platform restrictions (enrollment)'); + $response->assertSee('Platform: Personal device enrollment blocked'); + $response->assertSee('Enabled'); + $response->assertSee('Platform: Blocked SKUs'); + $response->assertSee('sku-1'); +}); diff --git a/tests/Feature/Filament/EnrollmentRestrictionsPreviewOnlyTest.php b/tests/Feature/Filament/EnrollmentRestrictionsPreviewOnlyTest.php new file mode 100644 index 0000000..4bf6b6c --- /dev/null +++ b/tests/Feature/Filament/EnrollmentRestrictionsPreviewOnlyTest.php @@ -0,0 +1,211 @@ + []]); + } + + public function getOrganization(array $options = []): GraphResponse + { + return new GraphResponse(true, []); + } + + public function applyPolicy(string $policyType, string $policyId, array $payload, array $options = []): GraphResponse + { + $this->applyCalls++; + + return new GraphResponse(true, []); + } + + public function getServicePrincipalPermissions(array $options = []): GraphResponse + { + return new GraphResponse(true, []); + } + + public function request(string $method, string $path, array $options = []): GraphResponse + { + return new GraphResponse(true, []); + } + }; + + app()->instance(GraphClientInterface::class, $client); + + $tenant = Tenant::create([ + 'tenant_id' => 'tenant-enrollment-restriction', + 'name' => 'Tenant Enrollment Restriction', + 'metadata' => [], + ]); + + $policy = Policy::create([ + 'tenant_id' => $tenant->id, + 'external_id' => 'enrollment-restriction-1', + 'policy_type' => 'enrollmentRestriction', + 'display_name' => 'Enrollment Restriction', + 'platform' => 'all', + ]); + + $backupSet = BackupSet::create([ + 'tenant_id' => $tenant->id, + 'name' => 'Enrollment Restriction Backup', + 'status' => 'completed', + 'item_count' => 1, + ]); + + $backupItem = BackupItem::create([ + 'tenant_id' => $tenant->id, + 'backup_set_id' => $backupSet->id, + 'policy_id' => $policy->id, + 'policy_identifier' => $policy->external_id, + 'policy_type' => $policy->policy_type, + 'platform' => $policy->platform, + 'payload' => [ + '@odata.type' => '#microsoft.graph.deviceEnrollmentConfiguration', + 'id' => $policy->external_id, + 'displayName' => $policy->display_name, + ], + ]); + + $service = app(RestoreService::class); + $preview = $service->preview($tenant, $backupSet, [$backupItem->id]); + + $previewItem = collect($preview)->first(fn (array $item) => ($item['policy_type'] ?? null) === 'enrollmentRestriction'); + + expect($previewItem)->not->toBeNull() + ->and($previewItem['restore_mode'] ?? null)->toBe('preview-only'); + + $run = $service->execute( + tenant: $tenant, + backupSet: $backupSet, + selectedItemIds: [$backupItem->id], + dryRun: false, + actorEmail: 'tester@example.com', + actorName: 'Tester', + ); + + expect($run->results)->toHaveCount(1); + expect($run->results[0]['status'])->toBe('skipped'); + expect($run->results[0]['reason'])->toBe('preview_only'); + + expect($client->applyCalls)->toBe(0); +}); + +test('enrollment limit restores are preview-only and skipped on execution', function () { + $client = new class implements GraphClientInterface + { + public int $applyCalls = 0; + + 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, ['payload' => []]); + } + + public function getOrganization(array $options = []): GraphResponse + { + return new GraphResponse(true, []); + } + + public function applyPolicy(string $policyType, string $policyId, array $payload, array $options = []): GraphResponse + { + $this->applyCalls++; + + return new GraphResponse(true, []); + } + + public function getServicePrincipalPermissions(array $options = []): GraphResponse + { + return new GraphResponse(true, []); + } + + public function request(string $method, string $path, array $options = []): GraphResponse + { + return new GraphResponse(true, []); + } + }; + + app()->instance(GraphClientInterface::class, $client); + + $tenant = Tenant::create([ + 'tenant_id' => 'tenant-enrollment-limit', + 'name' => 'Tenant Enrollment Limit', + 'metadata' => [], + ]); + + $policy = Policy::create([ + 'tenant_id' => $tenant->id, + 'external_id' => 'enrollment-limit-1', + 'policy_type' => 'deviceEnrollmentLimitConfiguration', + 'display_name' => 'Enrollment Limit', + 'platform' => 'all', + ]); + + $backupSet = BackupSet::create([ + 'tenant_id' => $tenant->id, + 'name' => 'Enrollment Limit Backup', + 'status' => 'completed', + 'item_count' => 1, + ]); + + $backupItem = BackupItem::create([ + 'tenant_id' => $tenant->id, + 'backup_set_id' => $backupSet->id, + 'policy_id' => $policy->id, + 'policy_identifier' => $policy->external_id, + 'policy_type' => $policy->policy_type, + 'platform' => $policy->platform, + 'payload' => [ + '@odata.type' => '#microsoft.graph.deviceEnrollmentLimitConfiguration', + 'id' => $policy->external_id, + 'displayName' => $policy->display_name, + 'limit' => 5, + ], + ]); + + $service = app(RestoreService::class); + $preview = $service->preview($tenant, $backupSet, [$backupItem->id]); + + $previewItem = collect($preview)->first(fn (array $item) => ($item['policy_type'] ?? null) === 'deviceEnrollmentLimitConfiguration'); + + expect($previewItem)->not->toBeNull() + ->and($previewItem['restore_mode'] ?? null)->toBe('preview-only'); + + $run = $service->execute( + tenant: $tenant, + backupSet: $backupSet, + selectedItemIds: [$backupItem->id], + dryRun: false, + actorEmail: 'tester@example.com', + actorName: 'Tester', + ); + + expect($run->results)->toHaveCount(1); + expect($run->results[0]['status'])->toBe('skipped'); + expect($run->results[0]['reason'])->toBe('preview_only'); + + expect($client->applyCalls)->toBe(0); +}); diff --git a/tests/Feature/Filament/GroupPolicyConfigurationHydrationTest.php b/tests/Feature/Filament/GroupPolicyConfigurationHydrationTest.php index e7b424e..3254355 100644 --- a/tests/Feature/Filament/GroupPolicyConfigurationHydrationTest.php +++ b/tests/Feature/Filament/GroupPolicyConfigurationHydrationTest.php @@ -133,10 +133,13 @@ public function request(string $method, string $path, array $options = []): Grap ); $user = User::factory()->create(); + $user->tenants()->syncWithoutDetaching([ + $tenant->getKey() => ['role' => 'owner'], + ]); $response = $this ->actingAs($user) - ->get(route('filament.admin.resources.policies.view', ['record' => $policy])); + ->get(route('filament.admin.resources.policies.view', array_merge(filamentTenantRouteParams($tenant), ['record' => $policy]))); $response->assertOk(); $response->assertSee('Block legacy auth'); diff --git a/tests/Feature/Filament/HousekeepingTest.php b/tests/Feature/Filament/HousekeepingTest.php index 9693d8b..7c2bd74 100644 --- a/tests/Feature/Filament/HousekeepingTest.php +++ b/tests/Feature/Filament/HousekeepingTest.php @@ -12,6 +12,7 @@ use App\Models\RestoreRun; use App\Models\Tenant; use App\Models\User; +use Filament\Facades\Filament; use Illuminate\Foundation\Testing\RefreshDatabase; use Livewire\Livewire; @@ -23,6 +24,8 @@ 'name' => 'Tenant', ]); + $tenant->makeCurrent(); + $backupSet = BackupSet::create([ 'tenant_id' => $tenant->id, 'name' => 'Set 1', @@ -41,6 +44,10 @@ $user = User::factory()->create(); $this->actingAs($user); + $user->tenants()->syncWithoutDetaching([ + $tenant->getKey() => ['role' => 'owner'], + ]); + Filament::setTenant($tenant, true); Livewire::test(ListBackupSets::class) ->callTableAction('archive', $backupSet); @@ -60,6 +67,8 @@ 'name' => 'Tenant 2', ]); + $tenant->makeCurrent(); + $backupSet = BackupSet::create([ 'tenant_id' => $tenant->id, 'name' => 'Set with restore', @@ -74,6 +83,10 @@ $user = User::factory()->create(); $this->actingAs($user); + $user->tenants()->syncWithoutDetaching([ + $tenant->getKey() => ['role' => 'owner'], + ]); + Filament::setTenant($tenant, true); Livewire::test(ListBackupSets::class) ->callTableAction('archive', $backupSet); @@ -93,6 +106,8 @@ 'name' => 'Tenant Force', ]); + $tenant->makeCurrent(); + $backupSet = BackupSet::create([ 'tenant_id' => $tenant->id, 'name' => 'Set force', @@ -111,6 +126,10 @@ $user = User::factory()->create(); $this->actingAs($user); + $user->tenants()->syncWithoutDetaching([ + $tenant->getKey() => ['role' => 'owner'], + ]); + Filament::setTenant($tenant, true); Livewire::test(ListBackupSets::class) ->callTableAction('archive', $backupSet) @@ -132,6 +151,8 @@ 'name' => 'Tenant Restore Backup Set', ]); + $tenant->makeCurrent(); + $backupSet = BackupSet::create([ 'tenant_id' => $tenant->id, 'name' => 'Set restore', @@ -150,6 +171,10 @@ $user = User::factory()->create(); $this->actingAs($user); + $user->tenants()->syncWithoutDetaching([ + $tenant->getKey() => ['role' => 'owner'], + ]); + Filament::setTenant($tenant, true); Livewire::test(ListBackupSets::class) ->callTableAction('archive', $backupSet) @@ -171,6 +196,8 @@ 'name' => 'Tenant Restore Run', ]); + $tenant->makeCurrent(); + $backupSet = BackupSet::create([ 'tenant_id' => $tenant->id, 'name' => 'Set RR', @@ -187,6 +214,10 @@ $user = User::factory()->create(); $this->actingAs($user); + $user->tenants()->syncWithoutDetaching([ + $tenant->getKey() => ['role' => 'owner'], + ]); + Filament::setTenant($tenant, true); Livewire::test(ListRestoreRuns::class) ->callTableAction('archive', $restoreRun) @@ -207,6 +238,8 @@ 'name' => 'Tenant Restore Restore Run', ]); + $tenant->makeCurrent(); + $backupSet = BackupSet::create([ 'tenant_id' => $tenant->id, 'name' => 'Set for restore run restore', @@ -223,6 +256,10 @@ $user = User::factory()->create(); $this->actingAs($user); + $user->tenants()->syncWithoutDetaching([ + $tenant->getKey() => ['role' => 'owner'], + ]); + Filament::setTenant($tenant, true); Livewire::test(ListRestoreRuns::class) ->callTableAction('archive', $restoreRun) @@ -257,6 +294,10 @@ $user = User::factory()->create(); $this->actingAs($user); + $user->tenants()->syncWithoutDetaching([ + $tenant->getKey() => ['role' => 'owner'], + ]); + Filament::setTenant($tenant, true); Livewire::test(ListPolicies::class) ->callTableAction('ignore', $policy); @@ -297,6 +338,10 @@ $user = User::factory()->create(); $this->actingAs($user); + $user->tenants()->syncWithoutDetaching([ + $tenant->getKey() => ['role' => 'owner'], + ]); + Filament::setTenant($tenant, true); Livewire::test(ListPolicyVersions::class) ->callTableAction('archive', $version); @@ -334,6 +379,10 @@ $user = User::factory()->create(); $this->actingAs($user); + $user->tenants()->syncWithoutDetaching([ + $tenant->getKey() => ['role' => 'owner'], + ]); + Filament::setTenant($tenant, true); Livewire::test(ListPolicyVersions::class) ->callTableAction('archive', $version) @@ -356,6 +405,10 @@ $user = User::factory()->create(); $this->actingAs($user); + $user->tenants()->syncWithoutDetaching([ + $tenant->getKey() => ['role' => 'owner'], + ]); + Filament::setTenant($tenant, true); Livewire::test(ListTenants::class) ->callTableAction('archive', $tenant); @@ -397,6 +450,11 @@ $user = User::factory()->create(); $this->actingAs($user); + $user->tenants()->syncWithoutDetaching([ + $active->getKey() => ['role' => 'owner'], + $archived->getKey() => ['role' => 'owner'], + ]); + Filament::setTenant($active, true); $component = Livewire::test(ListTenants::class) ->assertSee($active->name) @@ -421,8 +479,18 @@ $tenant->delete(); + $contextTenant = Tenant::create([ + 'tenant_id' => 'tenant-restore-context', + 'name' => 'Restore Context Tenant', + ]); + $user = User::factory()->create(); $this->actingAs($user); + $user->tenants()->syncWithoutDetaching([ + $tenant->getKey() => ['role' => 'owner'], + $contextTenant->getKey() => ['role' => 'owner'], + ]); + Filament::setTenant($contextTenant, true); Livewire::test(ListTenants::class) ->set('tableFilters.trashed.value', 1) diff --git a/tests/Feature/Filament/MalformedSnapshotWarningTest.php b/tests/Feature/Filament/MalformedSnapshotWarningTest.php index 9b436b5..cd5abbb 100644 --- a/tests/Feature/Filament/MalformedSnapshotWarningTest.php +++ b/tests/Feature/Filament/MalformedSnapshotWarningTest.php @@ -13,7 +13,7 @@ test('malformed snapshot renders warning on policy and version detail', function () { $tenant = Tenant::create([ - 'tenant_id' => env('INTUNE_TENANT_ID', 'local-tenant'), + 'tenant_id' => 'local-tenant', 'name' => 'Tenant One', 'metadata' => [], 'is_current' => true, @@ -41,14 +41,17 @@ ]); $user = User::factory()->create(); + $user->tenants()->syncWithoutDetaching([ + $tenant->getKey() => ['role' => 'owner'], + ]); $policyResponse = $this->actingAs($user) - ->get(PolicyResource::getUrl('view', ['record' => $policy])); + ->get(PolicyResource::getUrl('view', ['record' => $policy], tenant: $tenant)); $policyResponse->assertSee('This snapshot may be incomplete or malformed'); $versionResponse = $this->actingAs($user) - ->get(PolicyVersionResource::getUrl('view', ['record' => $version])); + ->get(PolicyVersionResource::getUrl('view', ['record' => $version], tenant: $tenant)); $versionResponse->assertSee('This snapshot may be incomplete or malformed'); }); diff --git a/tests/Feature/Filament/ODataTypeMismatchTest.php b/tests/Feature/Filament/ODataTypeMismatchTest.php index 0487693..1d1d8d5 100644 --- a/tests/Feature/Filament/ODataTypeMismatchTest.php +++ b/tests/Feature/Filament/ODataTypeMismatchTest.php @@ -50,7 +50,7 @@ public function getServicePrincipalPermissions(array $options = []): GraphRespon }); $tenant = Tenant::create([ - 'tenant_id' => env('INTUNE_TENANT_ID', 'local-tenant'), + 'tenant_id' => 'local-tenant', 'name' => 'Tenant One', 'metadata' => [], 'is_current' => true, @@ -100,9 +100,12 @@ public function getServicePrincipalPermissions(array $options = []): GraphRespon ]); $user = User::factory()->create(); + $user->tenants()->syncWithoutDetaching([ + $tenant->getKey() => ['role' => 'owner'], + ]); $detailResponse = $this->actingAs($user) - ->get(PolicyResource::getUrl('view', ['record' => $policy])); + ->get(PolicyResource::getUrl('view', ['record' => $policy], tenant: $tenant)); $detailResponse->assertSee('@odata.type mismatch'); diff --git a/tests/Feature/Filament/PolicyCaptureSnapshotOptionsTest.php b/tests/Feature/Filament/PolicyCaptureSnapshotOptionsTest.php index f547b1b..6b7227c 100644 --- a/tests/Feature/Filament/PolicyCaptureSnapshotOptionsTest.php +++ b/tests/Feature/Filament/PolicyCaptureSnapshotOptionsTest.php @@ -7,6 +7,7 @@ use App\Services\Graph\AssignmentFetcher; use App\Services\Graph\ScopeTagResolver; use App\Services\Intune\PolicySnapshotService; +use Filament\Facades\Filament; use Illuminate\Foundation\Testing\RefreshDatabase; use Livewire\Livewire; use Mockery\MockInterface; @@ -22,6 +23,10 @@ $user = User::factory()->create(); $this->actingAs($user); + $user->tenants()->syncWithoutDetaching([ + $tenant->getKey() => ['role' => 'owner'], + ]); + Filament::setTenant($tenant, true); $this->mock(PolicySnapshotService::class, function (MockInterface $mock) use ($policy) { $mock->shouldReceive('fetch') diff --git a/tests/Feature/Filament/PolicyListingTest.php b/tests/Feature/Filament/PolicyListingTest.php index 6099fcb..49c2e81 100644 --- a/tests/Feature/Filament/PolicyListingTest.php +++ b/tests/Feature/Filament/PolicyListingTest.php @@ -7,13 +7,7 @@ uses(\Illuminate\Foundation\Testing\RefreshDatabase::class); test('policies are listed for the active tenant', function () { - $tenant = Tenant::create([ - 'tenant_id' => env('INTUNE_TENANT_ID', 'local-tenant'), - 'name' => 'Tenant One', - 'metadata' => [], - ]); - - $tenant->makeCurrent(); + $tenant = Tenant::factory()->create(); Policy::create([ 'tenant_id' => $tenant->id, @@ -24,11 +18,7 @@ 'last_synced_at' => now(), ]); - $otherTenant = Tenant::create([ - 'tenant_id' => 'tenant-2', - 'name' => 'Tenant Two', - 'metadata' => [], - ]); + $otherTenant = Tenant::factory()->create(); Policy::create([ 'tenant_id' => $otherTenant->id, @@ -40,9 +30,13 @@ ]); $user = User::factory()->create(); + $user->tenants()->syncWithoutDetaching([ + $tenant->getKey() => ['role' => 'owner'], + $otherTenant->getKey() => ['role' => 'owner'], + ]); $this->actingAs($user) - ->get(route('filament.admin.resources.policies.index')) + ->get(route('filament.admin.resources.policies.index', filamentTenantRouteParams($tenant))) ->assertOk() ->assertSee('Policy A') ->assertDontSee('Policy B'); diff --git a/tests/Feature/Filament/PolicySettingsDisplayTest.php b/tests/Feature/Filament/PolicySettingsDisplayTest.php index 8005eaa..1d2c934 100644 --- a/tests/Feature/Filament/PolicySettingsDisplayTest.php +++ b/tests/Feature/Filament/PolicySettingsDisplayTest.php @@ -12,13 +12,12 @@ test('policy detail shows normalized settings section', function () { $tenant = Tenant::create([ - 'tenant_id' => env('INTUNE_TENANT_ID', 'local-tenant'), + 'tenant_id' => 'local-tenant', 'name' => 'Tenant One', 'metadata' => [], 'is_current' => true, ]); - putenv('INTUNE_TENANT_ID='.$tenant->tenant_id); $tenant->makeCurrent(); $policy = Policy::create([ @@ -50,9 +49,12 @@ ]); $user = User::factory()->create(); + $user->tenants()->syncWithoutDetaching([ + $tenant->getKey() => ['role' => 'owner'], + ]); $response = $this->actingAs($user) - ->get(PolicyResource::getUrl('view', ['record' => $policy])); + ->get(PolicyResource::getUrl('view', ['record' => $policy], tenant: $tenant)); $response->assertOk(); $response->assertSee('Settings'); diff --git a/tests/Feature/Filament/PolicySettingsStandardRendersArraysTest.php b/tests/Feature/Filament/PolicySettingsStandardRendersArraysTest.php new file mode 100644 index 0000000..dcd3999 --- /dev/null +++ b/tests/Feature/Filament/PolicySettingsStandardRendersArraysTest.php @@ -0,0 +1,66 @@ + 'tenant-arrays', + 'name' => 'Tenant Arrays', + 'metadata' => [], + 'is_current' => true, + ]); + + $tenant->makeCurrent(); + + $policy = Policy::create([ + 'tenant_id' => $tenant->id, + 'external_id' => 'policy-arrays-1', + 'policy_type' => 'windowsAutopilotDeploymentProfile', + 'display_name' => 'Autopilot Policy With Arrays', + 'platform' => 'windows', + ]); + + PolicyVersion::create([ + 'tenant_id' => $tenant->id, + 'policy_id' => $policy->id, + 'version_number' => 1, + 'policy_type' => $policy->policy_type, + 'platform' => $policy->platform, + 'created_by' => 'tester@example.com', + 'captured_at' => CarbonImmutable::now(), + 'snapshot' => [ + '@odata.type' => '#microsoft.graph.windowsAutopilotDeploymentProfile', + 'displayName' => 'Autopilot Policy With Arrays', + 'roleScopeTagIds' => ['0', '1'], + 'outOfBoxExperienceSettings' => [ + 'hideEULA' => true, + 'userType' => 'standard', + ], + ], + ]); + + $user = User::factory()->create(); + $user->tenants()->syncWithoutDetaching([ + $tenant->getKey() => ['role' => 'owner'], + ]); + + $response = $this->actingAs($user) + ->get(PolicyResource::getUrl('view', ['record' => $policy], tenant: $tenant).'?tab=settings'); + + $response->assertOk(); + $response->assertSee('Settings'); + $response->assertSee('Scope tag IDs'); + $response->assertSee('0'); + $response->assertSee('1'); + $response->assertSee('OOBE: Hide EULA'); + $response->assertSee('OOBE: User type'); + $response->assertSee('standard'); +}); diff --git a/tests/Feature/Filament/PolicyVersionReadableLayoutTest.php b/tests/Feature/Filament/PolicyVersionReadableLayoutTest.php index c9181c2..516bdc8 100644 --- a/tests/Feature/Filament/PolicyVersionReadableLayoutTest.php +++ b/tests/Feature/Filament/PolicyVersionReadableLayoutTest.php @@ -12,13 +12,12 @@ test('policy version detail renders tabs and scroll-safe blocks', function () { $tenant = Tenant::create([ - 'tenant_id' => env('INTUNE_TENANT_ID', 'local-tenant'), + 'tenant_id' => 'local-tenant', 'name' => 'Tenant One', 'metadata' => [], 'is_current' => true, ]); - putenv('INTUNE_TENANT_ID='.$tenant->tenant_id); $tenant->makeCurrent(); $policy = Policy::create([ @@ -59,9 +58,12 @@ ]); $user = User::factory()->create(); + $user->tenants()->syncWithoutDetaching([ + $tenant->getKey() => ['role' => 'owner'], + ]); $response = $this->actingAs($user) - ->get(PolicyVersionResource::getUrl('view', ['record' => $version])); + ->get(PolicyVersionResource::getUrl('view', ['record' => $version], tenant: $tenant)); $response->assertOk(); $response->assertSee('Normalized settings'); diff --git a/tests/Feature/Filament/PolicyVersionRestoreViaWizardTest.php b/tests/Feature/Filament/PolicyVersionRestoreViaWizardTest.php index 7a84965..02e9efe 100644 --- a/tests/Feature/Filament/PolicyVersionRestoreViaWizardTest.php +++ b/tests/Feature/Filament/PolicyVersionRestoreViaWizardTest.php @@ -10,6 +10,7 @@ use App\Models\Tenant; use App\Models\User; use App\Services\Graph\GroupResolver; +use Filament\Facades\Filament; use Illuminate\Foundation\Testing\RefreshDatabase; use Livewire\Livewire; use Mockery\MockInterface; @@ -49,11 +50,15 @@ ]); $user = User::factory()->create(['email' => 'tester@example.com']); + $user->tenants()->syncWithoutDetaching([ + $tenant->getKey() => ['role' => 'owner'], + ]); $this->actingAs($user); + Filament::setTenant($tenant, true); Livewire::test(ListPolicyVersions::class) ->callTableAction('restore_via_wizard', $version) - ->assertRedirectContains(RestoreRunResource::getUrl('create', [], false)); + ->assertRedirectContains(RestoreRunResource::getUrl('create', [], false, tenant: $tenant)); $backupSet = BackupSet::query()->where('metadata->source', 'policy_version')->first(); expect($backupSet)->not->toBeNull(); @@ -141,7 +146,11 @@ }); $user = User::factory()->create(); + $user->tenants()->syncWithoutDetaching([ + $tenant->getKey() => ['role' => 'owner'], + ]); $this->actingAs($user); + Filament::setTenant($tenant, true); $component = Livewire::withQueryParams([ 'backup_set_id' => $backupSet->id, diff --git a/tests/Feature/Filament/PolicyVersionScopeTagsDisplayTest.php b/tests/Feature/Filament/PolicyVersionScopeTagsDisplayTest.php index 6a9d3e3..2f96209 100644 --- a/tests/Feature/Filament/PolicyVersionScopeTagsDisplayTest.php +++ b/tests/Feature/Filament/PolicyVersionScopeTagsDisplayTest.php @@ -12,13 +12,12 @@ test('policy version view shows scope tags even when assignments are missing', function () { $tenant = Tenant::create([ - 'tenant_id' => env('INTUNE_TENANT_ID', 'local-tenant'), + 'tenant_id' => 'local-tenant', 'name' => 'Tenant One', 'metadata' => [], 'is_current' => true, ]); - putenv('INTUNE_TENANT_ID='.$tenant->tenant_id); $tenant->makeCurrent(); $policy = Policy::create([ @@ -48,9 +47,12 @@ ]); $user = User::factory()->create(); + $user->tenants()->syncWithoutDetaching([ + $tenant->getKey() => ['role' => 'owner'], + ]); $response = $this->actingAs($user) - ->get(PolicyVersionResource::getUrl('view', ['record' => $version])); + ->get(PolicyVersionResource::getUrl('view', ['record' => $version], tenant: $tenant)); $response->assertOk(); $response->assertSee('Scope Tags'); diff --git a/tests/Feature/Filament/PolicyVersionSettingsTest.php b/tests/Feature/Filament/PolicyVersionSettingsTest.php index 2f25d7d..79c9d71 100644 --- a/tests/Feature/Filament/PolicyVersionSettingsTest.php +++ b/tests/Feature/Filament/PolicyVersionSettingsTest.php @@ -12,13 +12,12 @@ test('policy version detail shows raw and normalized settings', function () { $tenant = Tenant::create([ - 'tenant_id' => env('INTUNE_TENANT_ID', 'local-tenant'), + 'tenant_id' => 'local-tenant', 'name' => 'Tenant One', 'metadata' => [], 'is_current' => true, ]); - putenv('INTUNE_TENANT_ID='.$tenant->tenant_id); $tenant->makeCurrent(); $policy = Policy::create([ @@ -46,9 +45,12 @@ ]); $user = User::factory()->create(); + $user->tenants()->syncWithoutDetaching([ + $tenant->getKey() => ['role' => 'owner'], + ]); $response = $this->actingAs($user) - ->get(PolicyVersionResource::getUrl('view', ['record' => $version])); + ->get(PolicyVersionResource::getUrl('view', ['record' => $version], tenant: $tenant)); $response->assertOk(); $response->assertSee('Raw JSON'); @@ -57,3 +59,98 @@ $response->assertSee('Enable feature'); $response->assertSee('Normalized diff'); }); + +test('policy version detail shows enrollment notification template settings', function () { + $tenant = Tenant::create([ + 'tenant_id' => 'tenant-enrollment-notify', + 'name' => 'Tenant Enrollment Notify', + 'metadata' => [], + 'is_current' => true, + ]); + + $tenant->makeCurrent(); + + $policy = Policy::create([ + 'tenant_id' => $tenant->id, + 'external_id' => 'enroll-notify-1', + 'policy_type' => 'deviceEnrollmentNotificationConfiguration', + 'display_name' => 'Enrollment Notifications', + 'platform' => 'all', + ]); + + $version = PolicyVersion::create([ + 'tenant_id' => $tenant->id, + 'policy_id' => $policy->id, + 'version_number' => 1, + 'policy_type' => $policy->policy_type, + 'platform' => $policy->platform, + 'created_by' => 'tester@example.com', + 'captured_at' => CarbonImmutable::now(), + 'snapshot' => [ + '@odata.type' => '#microsoft.graph.deviceEnrollmentNotificationConfiguration', + 'displayName' => 'Enrollment Notifications', + 'priority' => 1, + 'version' => 1, + 'platformType' => 'windows', + 'notificationTemplates' => ['Email_email-template-1', 'Push_push-template-1'], + 'notificationTemplateSnapshots' => [ + [ + 'channel' => 'Email', + 'template_id' => 'email-template-1', + 'template' => [ + 'id' => 'email-template-1', + 'displayName' => 'Email Template', + 'defaultLocale' => 'en-us', + 'brandingOptions' => 'none', + ], + 'localized_notification_messages' => [ + [ + 'locale' => 'en-us', + 'subject' => 'Email Subject', + 'messageTemplate' => 'Email Body', + 'isDefault' => true, + ], + ], + ], + [ + 'channel' => 'Push', + 'template_id' => 'push-template-1', + 'template' => [ + 'id' => 'push-template-1', + 'displayName' => 'Push Template', + 'defaultLocale' => 'en-us', + 'brandingOptions' => 'none', + ], + 'localized_notification_messages' => [ + [ + 'locale' => 'en-us', + 'subject' => 'Push Subject', + 'messageTemplate' => 'Push Body', + 'isDefault' => true, + ], + ], + ], + ], + ], + ]); + + $user = User::factory()->create(); + $user->tenants()->syncWithoutDetaching([ + $tenant->getKey() => ['role' => 'owner'], + ]); + + $response = $this->actingAs($user) + ->get(PolicyVersionResource::getUrl('view', ['record' => $version], tenant: $tenant).'?tab=normalized-settings'); + + $response->assertOk(); + $response->assertSee('Enrollment notifications'); + $response->assertSee('Notification templates'); + $response->assertSee('Email (en-us) Subject'); + $response->assertSee('Email Subject'); + $response->assertSee('Email (en-us) Message'); + $response->assertSee('Email Body'); + $response->assertSee('Push (en-us) Subject'); + $response->assertSee('Push Subject'); + $response->assertSee('Push (en-us) Message'); + $response->assertSee('Push Body'); +}); diff --git a/tests/Feature/Filament/PolicyVersionTest.php b/tests/Feature/Filament/PolicyVersionTest.php index 55aed75..4a104e9 100644 --- a/tests/Feature/Filament/PolicyVersionTest.php +++ b/tests/Feature/Filament/PolicyVersionTest.php @@ -11,11 +11,13 @@ test('policy versions render with timeline data', function () { $tenant = Tenant::create([ - 'tenant_id' => env('INTUNE_TENANT_ID', 'local-tenant'), + 'tenant_id' => 'local-tenant', 'name' => 'Tenant One', 'metadata' => [], ]); + $tenant->makeCurrent(); + $policy = Policy::create([ 'tenant_id' => $tenant->id, 'external_id' => 'policy-1', @@ -29,9 +31,12 @@ $service->captureVersion($policy, ['value' => 2], 'tester'); $user = User::factory()->create(); + $user->tenants()->syncWithoutDetaching([ + $tenant->getKey() => ['role' => 'owner'], + ]); $this->actingAs($user) - ->get(route('filament.admin.resources.policy-versions.index')) + ->get(route('filament.admin.resources.policy-versions.index', filamentTenantRouteParams($tenant))) ->assertOk() ->assertSee('Policy A') ->assertSee((string) PolicyVersion::max('version_number')); diff --git a/tests/Feature/Filament/PolicyViewSettingsCatalogReadableTest.php b/tests/Feature/Filament/PolicyViewSettingsCatalogReadableTest.php index 45ec5a2..bfbebb7 100644 --- a/tests/Feature/Filament/PolicyViewSettingsCatalogReadableTest.php +++ b/tests/Feature/Filament/PolicyViewSettingsCatalogReadableTest.php @@ -13,13 +13,12 @@ it('shows Settings tab for Settings Catalog policy', function () { $tenant = Tenant::create([ - 'tenant_id' => env('INTUNE_TENANT_ID', 'local-tenant'), + 'tenant_id' => 'local-tenant', 'name' => 'Test Tenant', 'metadata' => [], 'is_current' => true, ]); - putenv('INTUNE_TENANT_ID='.$tenant->tenant_id); $tenant->makeCurrent(); $policy = Policy::create([ @@ -72,9 +71,12 @@ ]); $user = User::factory()->create(); + $user->tenants()->syncWithoutDetaching([ + $tenant->getKey() => ['role' => 'owner'], + ]); $response = $this->actingAs($user) - ->get(PolicyResource::getUrl('view', ['record' => $policy])); + ->get(PolicyResource::getUrl('view', ['record' => $policy], tenant: $tenant)); $response->assertOk(); $response->assertSee('Settings'); // Settings tab should appear for Settings Catalog @@ -86,13 +88,12 @@ it('shows display names instead of definition IDs', function () { $tenant = Tenant::create([ - 'tenant_id' => env('INTUNE_TENANT_ID', 'local-tenant'), + 'tenant_id' => 'local-tenant', 'name' => 'Test Tenant', 'metadata' => [], 'is_current' => true, ]); - putenv('INTUNE_TENANT_ID='.$tenant->tenant_id); $tenant->makeCurrent(); $policy = Policy::create([ @@ -132,9 +133,12 @@ ]); $user = User::factory()->create(); + $user->tenants()->syncWithoutDetaching([ + $tenant->getKey() => ['role' => 'owner'], + ]); $response = $this->actingAs($user) - ->get(PolicyResource::getUrl('view', ['record' => $policy])); + ->get(PolicyResource::getUrl('view', ['record' => $policy], tenant: $tenant)); $response->assertOk(); // TODO: Manual verification - check UI for display name "Allow Real-time Monitoring" @@ -143,13 +147,12 @@ it('shows fallback prettified labels when definitions not cached', function () { $tenant = Tenant::create([ - 'tenant_id' => env('INTUNE_TENANT_ID', 'local-tenant'), + 'tenant_id' => 'local-tenant', 'name' => 'Test Tenant', 'metadata' => [], 'is_current' => true, ]); - putenv('INTUNE_TENANT_ID='.$tenant->tenant_id); $tenant->makeCurrent(); $policy = Policy::create([ @@ -184,9 +187,12 @@ ]); $user = User::factory()->create(); + $user->tenants()->syncWithoutDetaching([ + $tenant->getKey() => ['role' => 'owner'], + ]); $response = $this->actingAs($user) - ->get(PolicyResource::getUrl('view', ['record' => $policy])); + ->get(PolicyResource::getUrl('view', ['record' => $policy], tenant: $tenant)); $response->assertOk(); // TODO: Manual verification - check UI shows prettified fallback label @@ -195,13 +201,12 @@ it('shows tabbed layout for non-Settings Catalog policies', function () { $tenant = Tenant::create([ - 'tenant_id' => env('INTUNE_TENANT_ID', 'local-tenant'), + 'tenant_id' => 'local-tenant', 'name' => 'Test Tenant', 'metadata' => [], 'is_current' => true, ]); - putenv('INTUNE_TENANT_ID='.$tenant->tenant_id); $tenant->makeCurrent(); $policy = Policy::create([ @@ -229,9 +234,12 @@ ]); $user = User::factory()->create(); + $user->tenants()->syncWithoutDetaching([ + $tenant->getKey() => ['role' => 'owner'], + ]); $response = $this->actingAs($user) - ->get(PolicyResource::getUrl('view', ['record' => $policy])); + ->get(PolicyResource::getUrl('view', ['record' => $policy], tenant: $tenant)); $response->assertOk(); $response->assertSee('General'); @@ -242,7 +250,7 @@ // T034: Test display names shown (not definition IDs) it('displays setting display names instead of raw definition IDs', function () { $tenant = Tenant::create([ - 'tenant_id' => env('INTUNE_TENANT_ID', 'local-tenant'), + 'tenant_id' => 'local-tenant', 'name' => 'Test Tenant', 'is_current' => true, ]); @@ -285,8 +293,11 @@ ]); $user = User::factory()->create(); + $user->tenants()->syncWithoutDetaching([ + $tenant->getKey() => ['role' => 'owner'], + ]); $response = $this->actingAs($user) - ->get(PolicyResource::getUrl('view', ['record' => $policy])); + ->get(PolicyResource::getUrl('view', ['record' => $policy], tenant: $tenant)); $response->assertOk(); // Policy view should render successfully with Settings Catalog data @@ -296,7 +307,7 @@ // T035: Test values formatted correctly it('formats setting values correctly based on type', function () { $tenant = Tenant::create([ - 'tenant_id' => env('INTUNE_TENANT_ID', 'local-tenant'), + 'tenant_id' => 'local-tenant', 'name' => 'Test Tenant', 'is_current' => true, ]); @@ -360,8 +371,11 @@ ]); $user = User::factory()->create(); + $user->tenants()->syncWithoutDetaching([ + $tenant->getKey() => ['role' => 'owner'], + ]); $response = $this->actingAs($user) - ->get(PolicyResource::getUrl('view', ['record' => $policy])); + ->get(PolicyResource::getUrl('view', ['record' => $policy], tenant: $tenant)); $response->assertOk(); // Value formatting verified by manual UI inspection @@ -370,7 +384,7 @@ // T036: Test search/filter functionality it('search filters settings in real-time', function () { $tenant = Tenant::create([ - 'tenant_id' => env('INTUNE_TENANT_ID', 'local-tenant'), + 'tenant_id' => 'local-tenant', 'name' => 'Test Tenant', 'is_current' => true, ]); @@ -423,8 +437,11 @@ ]); $user = User::factory()->create(); + $user->tenants()->syncWithoutDetaching([ + $tenant->getKey() => ['role' => 'owner'], + ]); $response = $this->actingAs($user) - ->get(PolicyResource::getUrl('view', ['record' => $policy])); + ->get(PolicyResource::getUrl('view', ['record' => $policy], tenant: $tenant)); $response->assertOk(); // Search functionality is Alpine.js client-side, requires browser testing @@ -433,7 +450,7 @@ // T037: Test graceful degradation for missing definitions it('shows prettified fallback labels when definitions are not cached', function () { $tenant = Tenant::create([ - 'tenant_id' => env('INTUNE_TENANT_ID', 'local-tenant'), + 'tenant_id' => 'local-tenant', 'name' => 'Test Tenant', 'is_current' => true, ]); @@ -469,8 +486,11 @@ ]); $user = User::factory()->create(); + $user->tenants()->syncWithoutDetaching([ + $tenant->getKey() => ['role' => 'owner'], + ]); $response = $this->actingAs($user) - ->get(PolicyResource::getUrl('view', ['record' => $policy])); + ->get(PolicyResource::getUrl('view', ['record' => $policy], tenant: $tenant)); $response->assertOk(); // Page renders without crash - actual fallback display requires UI verification diff --git a/tests/Feature/Filament/RestoreItemSelectionTest.php b/tests/Feature/Filament/RestoreItemSelectionTest.php index 7ac2911..a80766d 100644 --- a/tests/Feature/Filament/RestoreItemSelectionTest.php +++ b/tests/Feature/Filament/RestoreItemSelectionTest.php @@ -7,6 +7,7 @@ use App\Models\Policy; use App\Models\Tenant; use App\Models\User; +use Filament\Facades\Filament; use Illuminate\Foundation\Testing\RefreshDatabase; use Livewire\Livewire; @@ -99,6 +100,10 @@ $user = User::factory()->create(); $this->actingAs($user); + $user->tenants()->syncWithoutDetaching([ + $tenant->getKey() => ['role' => 'owner'], + ]); + Filament::setTenant($tenant, true); Livewire::test(CreateRestoreRun::class) ->fillForm([ diff --git a/tests/Feature/Filament/RestorePreviewTest.php b/tests/Feature/Filament/RestorePreviewTest.php index a4ccb0a..929e83e 100644 --- a/tests/Feature/Filament/RestorePreviewTest.php +++ b/tests/Feature/Filament/RestorePreviewTest.php @@ -104,6 +104,77 @@ public function request(string $method, string $path, array $options = []): Grap expect($policyPreview['action'])->toBe('update'); }); +test('restore preview shows enabled restore mode for windows driver update profiles', 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, ['payload' => []]); + } + + public function getOrganization(array $options = []): GraphResponse + { + return new GraphResponse(true, []); + } + + public function applyPolicy(string $policyType, string $policyId, array $payload, array $options = []): GraphResponse + { + return new GraphResponse(true, []); + } + + public function getServicePrincipalPermissions(array $options = []): GraphResponse + { + return new GraphResponse(true, []); + } + + public function request(string $method, string $path, array $options = []): GraphResponse + { + return new GraphResponse(true, []); + } + }); + + $tenant = Tenant::create([ + 'tenant_id' => 'tenant-driver-preview', + 'name' => 'Tenant Preview', + 'metadata' => [], + ]); + + $backupSet = BackupSet::create([ + 'tenant_id' => $tenant->id, + 'name' => 'Backup', + 'status' => 'completed', + 'item_count' => 1, + ]); + + $backupItem = BackupItem::create([ + 'tenant_id' => $tenant->id, + 'backup_set_id' => $backupSet->id, + 'policy_id' => null, + 'policy_identifier' => 'wdp-1', + 'policy_type' => 'windowsDriverUpdateProfile', + 'platform' => 'windows', + 'payload' => [ + '@odata.type' => '#microsoft.graph.windowsDriverUpdateProfile', + 'displayName' => 'Driver Updates A', + ], + ]); + + $service = app(RestoreService::class); + $preview = $service->preview($tenant, $backupSet, [$backupItem->id]); + + expect($preview)->toHaveCount(1); + + $policyPreview = $preview[0] ?? []; + expect($policyPreview['policy_type'] ?? null)->toBe('windowsDriverUpdateProfile'); + expect($policyPreview['action'] ?? null)->toBe('create'); + expect($policyPreview['restore_mode'] ?? null)->toBe('enabled'); +}); + test('restore preview warns about missing compliance notification templates', function () { app()->bind(GraphClientInterface::class, fn () => new class implements GraphClientInterface { diff --git a/tests/Feature/Filament/ScriptPoliciesNormalizedDisplayTest.php b/tests/Feature/Filament/ScriptPoliciesNormalizedDisplayTest.php new file mode 100644 index 0000000..9b0e106 --- /dev/null +++ b/tests/Feature/Filament/ScriptPoliciesNormalizedDisplayTest.php @@ -0,0 +1,207 @@ +create(); + $user = User::factory()->create(); + $user->tenants()->syncWithoutDetaching([ + $tenant->getKey() => ['role' => 'owner'], + ]); + $this->actingAs($user); + putenv('INTUNE_TENANT_ID='.$tenant->tenant_id); + $tenant->makeCurrent(); + + $policy = Policy::factory()->create([ + 'tenant_id' => $tenant->id, + 'policy_type' => $policyType, + 'platform' => 'all', + 'display_name' => 'Script policy', + 'external_id' => 'policy-1', + ]); + + config([ + 'tenantpilot.display.show_script_content' => true, + ]); + + $scriptContent = str_repeat('X', 20); + if ($policyType === 'deviceShellScript') { + $scriptContent = "#!/bin/zsh\n".str_repeat('X', 20); + } + + $contentKey = in_array($policyType, ['deviceHealthScript', 'deviceComplianceScript'], true) + ? 'detectionScriptContent' + : 'scriptContent'; + + $version = PolicyVersion::factory()->create([ + 'policy_id' => $policy->id, + 'tenant_id' => $tenant->id, + 'policy_type' => $policyType, + 'snapshot' => [ + '@odata.type' => $odataType, + 'displayName' => 'Script policy', + 'description' => 'desc', + $contentKey => $contentKey === 'scriptContent' ? $scriptContent : base64_encode($scriptContent), + ], + ]); + + $this->get(\App\Filament\Resources\PolicyVersionResource::getUrl('index', tenant: $tenant)) + ->assertSuccessful(); + + $this->get(\App\Filament\Resources\PolicyVersionResource::getUrl('view', ['record' => $version], tenant: $tenant).'?tab=normalized-settings') + ->assertSuccessful(); + + $originalEnv !== false + ? putenv("INTUNE_TENANT_ID={$originalEnv}") + : putenv('INTUNE_TENANT_ID'); +})->with([ + ['deviceManagementScript', '#microsoft.graph.deviceManagementScript'], + ['deviceShellScript', '#microsoft.graph.deviceShellScript'], + ['deviceHealthScript', '#microsoft.graph.deviceHealthScript'], + ['deviceComplianceScript', '#microsoft.graph.deviceComplianceScript'], +]); + +it('renders diff tab with highlighted script content for script policies', function () { + $originalEnv = getenv('INTUNE_TENANT_ID'); + putenv('INTUNE_TENANT_ID='); + + config([ + 'tenantpilot.display.show_script_content' => true, + 'tenantpilot.display.max_script_content_chars' => 5000, + ]); + + $tenant = Tenant::factory()->create(); + $user = User::factory()->create(); + $user->tenants()->syncWithoutDetaching([ + $tenant->getKey() => ['role' => 'owner'], + ]); + $this->actingAs($user); + putenv('INTUNE_TENANT_ID='.$tenant->tenant_id); + $tenant->makeCurrent(); + + $policy = \App\Models\Policy::factory()->create([ + 'tenant_id' => $tenant->getKey(), + 'policy_type' => 'deviceManagementScript', + 'platform' => 'windows', + ]); + + $scriptOne = "# test\n".str_repeat("Write-Host 'one'\n", 40); + $scriptTwo = "# test\n".str_repeat("Write-Host 'two'\n", 40); + + $v1 = \App\Models\PolicyVersion::factory()->create([ + 'policy_id' => $policy->getKey(), + 'tenant_id' => $tenant->getKey(), + 'version_number' => 1, + 'policy_type' => 'deviceManagementScript', + 'platform' => 'windows', + 'snapshot' => [ + '@odata.type' => '#microsoft.graph.deviceManagementScript', + 'displayName' => 'My script', + 'scriptContent' => base64_encode($scriptOne), + ], + ]); + + $v2 = \App\Models\PolicyVersion::factory()->create([ + 'policy_id' => $policy->getKey(), + 'tenant_id' => $tenant->getKey(), + 'version_number' => 2, + 'policy_type' => 'deviceManagementScript', + 'platform' => 'windows', + 'snapshot' => [ + '@odata.type' => '#microsoft.graph.deviceManagementScript', + 'displayName' => 'My script', + 'scriptContent' => base64_encode($scriptTwo), + ], + ]); + + $url = \App\Filament\Resources\PolicyVersionResource::getUrl('view', ['record' => $v2], tenant: $tenant); + + $this->get($url.'?tab=diff') + ->assertSuccessful() + ->assertSeeText('Fullscreen') + ->assertSeeText("- Write-Host 'one'") + ->assertSeeText("+ Write-Host 'two'") + ->assertSee('bg-danger-50', false) + ->assertSee('bg-success-50', false); + + $originalEnv !== false + ? putenv("INTUNE_TENANT_ID={$originalEnv}") + : putenv('INTUNE_TENANT_ID'); +}); + +it('renders diff tab with highlighted script content for device compliance scripts', function () { + $originalEnv = getenv('INTUNE_TENANT_ID'); + putenv('INTUNE_TENANT_ID='); + + config([ + 'tenantpilot.display.show_script_content' => true, + 'tenantpilot.display.max_script_content_chars' => 5000, + ]); + + $tenant = Tenant::factory()->create(); + $user = User::factory()->create(); + $user->tenants()->syncWithoutDetaching([ + $tenant->getKey() => ['role' => 'owner'], + ]); + $this->actingAs($user); + putenv('INTUNE_TENANT_ID='.$tenant->tenant_id); + $tenant->makeCurrent(); + + $policy = Policy::factory()->create([ + 'tenant_id' => $tenant->getKey(), + 'policy_type' => 'deviceComplianceScript', + 'platform' => 'windows', + ]); + + $scriptOne = "# test\n".str_repeat("Write-Host 'one'\n", 40); + $scriptTwo = "# test\n".str_repeat("Write-Host 'two'\n", 40); + + $v1 = PolicyVersion::factory()->create([ + 'policy_id' => $policy->getKey(), + 'tenant_id' => $tenant->getKey(), + 'version_number' => 1, + 'policy_type' => 'deviceComplianceScript', + 'platform' => 'windows', + 'snapshot' => [ + '@odata.type' => '#microsoft.graph.deviceComplianceScript', + 'displayName' => 'My compliance script', + 'detectionScriptContent' => base64_encode($scriptOne), + ], + ]); + + $v2 = PolicyVersion::factory()->create([ + 'policy_id' => $policy->getKey(), + 'tenant_id' => $tenant->getKey(), + 'version_number' => 2, + 'policy_type' => 'deviceComplianceScript', + 'platform' => 'windows', + 'snapshot' => [ + '@odata.type' => '#microsoft.graph.deviceComplianceScript', + 'displayName' => 'My compliance script', + 'detectionScriptContent' => base64_encode($scriptTwo), + ], + ]); + + $url = \App\Filament\Resources\PolicyVersionResource::getUrl('view', ['record' => $v2], tenant: $tenant); + + $this->get($url.'?tab=diff') + ->assertSuccessful() + ->assertSeeText('Fullscreen') + ->assertSeeText("- Write-Host 'one'") + ->assertSeeText("+ Write-Host 'two'") + ->assertSee('bg-danger-50', false) + ->assertSee('bg-success-50', false); + + $originalEnv !== false + ? putenv("INTUNE_TENANT_ID={$originalEnv}") + : putenv('INTUNE_TENANT_ID'); +}); diff --git a/tests/Feature/Filament/SettingsCatalogPolicyHydrationTest.php b/tests/Feature/Filament/SettingsCatalogPolicyHydrationTest.php index 67c0793..0f6f0cf 100644 --- a/tests/Feature/Filament/SettingsCatalogPolicyHydrationTest.php +++ b/tests/Feature/Filament/SettingsCatalogPolicyHydrationTest.php @@ -105,10 +105,13 @@ public function request(string $method, string $path, array $options = []): Grap ); $user = User::factory()->create(); + $user->tenants()->syncWithoutDetaching([ + $tenant->getKey() => ['role' => 'owner'], + ]); $response = $this ->actingAs($user) - ->get(route('filament.admin.resources.policies.view', ['record' => $policy])); + ->get(route('filament.admin.resources.policies.view', array_merge(filamentTenantRouteParams($tenant), ['record' => $policy]))); $response->assertOk(); $response->assertSee('Setting A'); @@ -145,10 +148,13 @@ public function request(string $method, string $path, array $options = []): Grap $versions->captureFromGraph($tenant, $policy, createdBy: 'tester@example.com'); $user = User::factory()->create(); + $user->tenants()->syncWithoutDetaching([ + $tenant->getKey() => ['role' => 'owner'], + ]); $response = $this ->actingAs($user) - ->get(route('filament.admin.resources.policies.view', ['record' => $policy])); + ->get(route('filament.admin.resources.policies.view', array_merge(filamentTenantRouteParams($tenant), ['record' => $policy]))); $response->assertOk(); $response->assertSee('Setting A'); diff --git a/tests/Feature/Filament/SettingsCatalogPolicyNormalizedDisplayTest.php b/tests/Feature/Filament/SettingsCatalogPolicyNormalizedDisplayTest.php index fe896c0..0c1dadf 100644 --- a/tests/Feature/Filament/SettingsCatalogPolicyNormalizedDisplayTest.php +++ b/tests/Feature/Filament/SettingsCatalogPolicyNormalizedDisplayTest.php @@ -11,21 +11,20 @@ uses(RefreshDatabase::class); -test('settings catalog policies render a normalized settings table', function () { +test('configuration policy types render a normalized settings table', function (string $policyType) { $tenant = Tenant::create([ - 'tenant_id' => env('INTUNE_TENANT_ID', 'local-tenant'), + 'tenant_id' => 'local-tenant', 'name' => 'Tenant One', 'metadata' => [], 'is_current' => true, ]); - putenv('INTUNE_TENANT_ID='.$tenant->tenant_id); $tenant->makeCurrent(); $policy = Policy::create([ 'tenant_id' => $tenant->id, 'external_id' => 'scp-policy-1', - 'policy_type' => 'settingsCatalogPolicy', + 'policy_type' => $policyType, 'display_name' => 'Settings Catalog Policy', 'platform' => 'windows', ]); @@ -34,7 +33,7 @@ 'tenant_id' => $tenant->id, 'policy_id' => $policy->id, 'version_number' => 1, - 'policy_type' => $policy->policy_type, + 'policy_type' => $policyType, 'platform' => $policy->platform, 'created_by' => 'tester@example.com', 'captured_at' => CarbonImmutable::now(), @@ -91,9 +90,12 @@ ]); $user = User::factory()->create(); + $user->tenants()->syncWithoutDetaching([ + $tenant->getKey() => ['role' => 'owner'], + ]); $policyResponse = $this->actingAs($user) - ->get(PolicyResource::getUrl('view', ['record' => $policy])); + ->get(PolicyResource::getUrl('view', ['record' => $policy], tenant: $tenant)); $policyResponse->assertOk(); $policyResponse->assertSee('Definition'); @@ -105,7 +107,7 @@ $policyResponse->assertSee('tp-policy-general-card'); $versionResponse = $this->actingAs($user) - ->get(PolicyVersionResource::getUrl('view', ['record' => $version])); + ->get(PolicyVersionResource::getUrl('view', ['record' => $version], tenant: $tenant)); $versionResponse->assertOk(); $versionResponse->assertSee('Normalized settings'); @@ -117,4 +119,8 @@ preg_match('/]*data-block="general"[^>]*>.*?<\/section>/is', $versionResponse->getContent(), $versionGeneralSection); expect($versionGeneralSection)->not->toBeEmpty(); expect($versionGeneralSection[0])->toContain('x-cloak'); -}); +})->with([ + 'settingsCatalogPolicy', + 'endpointSecurityPolicy', + 'securityBaselinePolicy', +]); diff --git a/tests/Feature/Filament/SettingsCatalogPolicySyncTest.php b/tests/Feature/Filament/SettingsCatalogPolicySyncTest.php index 7f38369..c8dca6f 100644 --- a/tests/Feature/Filament/SettingsCatalogPolicySyncTest.php +++ b/tests/Feature/Filament/SettingsCatalogPolicySyncTest.php @@ -75,16 +75,11 @@ public function request(string $method, string $path, array $options = []): Grap app()->instance(GraphClientInterface::class, new SettingsCatalogFakeGraphClient($responses)); $tenant = Tenant::create([ - 'tenant_id' => env('INTUNE_TENANT_ID', 'local-tenant'), + 'tenant_id' => 'local-tenant', 'name' => 'Tenant One', 'metadata' => [], 'is_current' => true, ]); - - putenv('INTUNE_TENANT_ID='.$tenant->tenant_id); - $_ENV['INTUNE_TENANT_ID'] = $tenant->tenant_id; - $_SERVER['INTUNE_TENANT_ID'] = $tenant->tenant_id; - $tenant->makeCurrent(); expect(Tenant::current()->id)->toBe($tenant->id); @@ -116,10 +111,13 @@ public function request(string $method, string $path, array $options = []): Grap ]); $user = User::factory()->create(); + $user->tenants()->syncWithoutDetaching([ + $tenant->getKey() => ['role' => 'owner'], + ]); $response = $this ->actingAs($user) - ->get(route('filament.admin.resources.policies.index')); + ->get(route('filament.admin.resources.policies.index', filamentTenantRouteParams($tenant))); $response->assertOk(); $response->assertSee('Settings Catalog Policy'); diff --git a/tests/Feature/Filament/SettingsCatalogRestoreApplySettingsPatchTest.php b/tests/Feature/Filament/SettingsCatalogRestoreApplySettingsPatchTest.php index ec69858..68e7dcd 100644 --- a/tests/Feature/Filament/SettingsCatalogRestoreApplySettingsPatchTest.php +++ b/tests/Feature/Filament/SettingsCatalogRestoreApplySettingsPatchTest.php @@ -147,6 +147,9 @@ public function request(string $method, string $path, array $options = []): Grap ]); $user = User::factory()->create(); + $user->tenants()->syncWithoutDetaching([ + $tenant->getKey() => ['role' => 'owner'], + ]); $this->actingAs($user); $service = app(RestoreService::class); @@ -185,7 +188,7 @@ public function request(string $method, string $path, array $options = []): Grap $run->update(['results' => $results]); - $response = $this->get(route('filament.admin.resources.restore-runs.view', ['record' => $run])); + $response = $this->get(route('filament.admin.resources.restore-runs.view', array_merge(filamentTenantRouteParams($tenant), ['record' => $run]))); $response->assertOk(); $response->assertSee('Graph bulk apply failed'); $response->assertSee('Setting missing'); diff --git a/tests/Feature/Filament/SettingsCatalogRestoreTest.php b/tests/Feature/Filament/SettingsCatalogRestoreTest.php index 0570dad..5b37350 100644 --- a/tests/Feature/Filament/SettingsCatalogRestoreTest.php +++ b/tests/Feature/Filament/SettingsCatalogRestoreTest.php @@ -162,6 +162,9 @@ public function request(string $method, string $path, array $options = []): Grap ]); $user = User::factory()->create(); + $user->tenants()->syncWithoutDetaching([ + $tenant->getKey() => ['role' => 'owner'], + ]); $this->actingAs($user); $service = app(RestoreService::class); @@ -201,7 +204,7 @@ public function request(string $method, string $path, array $options = []): Grap ->toBe('#microsoft.graph.deviceManagementConfigurationSimpleSettingInstance'); $response = $this - ->get(route('filament.admin.resources.restore-runs.view', ['record' => $run])); + ->get(route('filament.admin.resources.restore-runs.view', array_merge(filamentTenantRouteParams($tenant), ['record' => $run]))); $response->assertOk(); $response->assertSee('settings are read-only'); diff --git a/tests/Feature/Filament/SettingsCatalogSettingsTableRenderTest.php b/tests/Feature/Filament/SettingsCatalogSettingsTableRenderTest.php index 9ee2573..0bab050 100644 --- a/tests/Feature/Filament/SettingsCatalogSettingsTableRenderTest.php +++ b/tests/Feature/Filament/SettingsCatalogSettingsTableRenderTest.php @@ -13,13 +13,12 @@ test('settings catalog settings render as a filament table with details action', function () { $tenant = Tenant::create([ - 'tenant_id' => env('INTUNE_TENANT_ID', 'local-tenant'), + 'tenant_id' => 'local-tenant', 'name' => 'Tenant One', 'metadata' => [], 'is_current' => true, ]); - putenv('INTUNE_TENANT_ID='.$tenant->tenant_id); $tenant->makeCurrent(); $policy = Policy::create([ @@ -57,9 +56,12 @@ ]); $user = User::factory()->create(); + $user->tenants()->syncWithoutDetaching([ + $tenant->getKey() => ['role' => 'owner'], + ]); $policyResponse = $this->actingAs($user) - ->get(PolicyResource::getUrl('view', ['record' => $policy]).'?tab=settings'); + ->get(PolicyResource::getUrl('view', ['record' => $policy], tenant: $tenant).'?tab=settings'); $policyResponse->assertOk(); $policyResponse->assertSee('fi-width-full'); @@ -70,7 +72,7 @@ $policyResponse->assertSee('fi-ta-table'); $versionResponse = $this->actingAs($user) - ->get(PolicyVersionResource::getUrl('view', ['record' => $version])); + ->get(PolicyVersionResource::getUrl('view', ['record' => $version], tenant: $tenant)); $versionResponse->assertOk(); $versionResponse->assertSee('fi-width-full'); diff --git a/tests/Feature/Filament/TenantMakeCurrentTest.php b/tests/Feature/Filament/TenantMakeCurrentTest.php index 8da5bbc..d9e000f 100644 --- a/tests/Feature/Filament/TenantMakeCurrentTest.php +++ b/tests/Feature/Filament/TenantMakeCurrentTest.php @@ -3,6 +3,7 @@ use App\Filament\Resources\TenantResource\Pages\ListTenants; use App\Models\Tenant; use App\Models\User; +use Filament\Facades\Filament; use Illuminate\Foundation\Testing\RefreshDatabase; use Livewire\Livewire; @@ -26,6 +27,11 @@ $user = User::factory()->create(); $this->actingAs($user); + $user->tenants()->syncWithoutDetaching([ + $first->getKey() => ['role' => 'owner'], + $second->getKey() => ['role' => 'owner'], + ]); + Filament::setTenant($first, true); Livewire::test(ListTenants::class) ->callTableAction('makeCurrent', $second); diff --git a/tests/Feature/Filament/TenantPortfolioContextSwitchTest.php b/tests/Feature/Filament/TenantPortfolioContextSwitchTest.php new file mode 100644 index 0000000..c95165c --- /dev/null +++ b/tests/Feature/Filament/TenantPortfolioContextSwitchTest.php @@ -0,0 +1,104 @@ +create(); + + $this->actingAs($user) + ->get(route('filament.admin.resources.policies.index', filamentTenantRouteParams($unauthorizedTenant))) + ->assertNotFound(); +}); + +test('tenant portfolio lists only tenants the user can access', function () { + $user = User::factory()->create(); + $this->actingAs($user); + + $authorizedTenant = Tenant::factory()->create([ + 'tenant_id' => 'tenant-portfolio-authorized', + 'name' => 'Authorized Tenant', + ]); + + $unauthorizedTenant = Tenant::factory()->create([ + 'tenant_id' => 'tenant-portfolio-unauthorized', + 'name' => 'Unauthorized Tenant', + ]); + + $user->tenants()->syncWithoutDetaching([ + $authorizedTenant->getKey() => ['role' => 'owner'], + ]); + + $this->get(route('filament.admin.resources.tenants.index', filamentTenantRouteParams($authorizedTenant))) + ->assertOk() + ->assertSee($authorizedTenant->name) + ->assertDontSee($unauthorizedTenant->name); +}); + +test('tenant portfolio bulk sync dispatches one job per eligible tenant', function () { + Bus::fake(); + + $user = User::factory()->create(); + $this->actingAs($user); + + $tenantA = Tenant::factory()->create(['tenant_id' => 'tenant-bulk-a']); + $tenantB = Tenant::factory()->create(['tenant_id' => 'tenant-bulk-b']); + + $user->tenants()->syncWithoutDetaching([ + $tenantA->getKey() => ['role' => 'owner'], + $tenantB->getKey() => ['role' => 'operator'], + ]); + + Filament::setTenant($tenantA, true); + + Livewire::test(ListTenants::class) + ->assertTableBulkActionVisible('syncSelected') + ->callTableBulkAction('syncSelected', collect([$tenantA, $tenantB])); + + Bus::assertDispatchedTimes(BulkTenantSyncJob::class, 1); + + $this->assertDatabaseHas('bulk_operation_runs', [ + 'tenant_id' => $tenantA->id, + 'user_id' => $user->id, + 'resource' => 'tenant', + 'action' => 'sync', + 'total_items' => 2, + ]); +}); + +test('tenant portfolio bulk sync is hidden for readonly users', function () { + $user = User::factory()->create(); + $this->actingAs($user); + + $tenant = Tenant::factory()->create(['tenant_id' => 'tenant-bulk-readonly']); + + $user->tenants()->syncWithoutDetaching([ + $tenant->getKey() => ['role' => 'readonly'], + ]); + + Filament::setTenant($tenant, true); + + Livewire::test(ListTenants::class) + ->assertTableBulkActionHidden('syncSelected'); +}); + +test('tenant set event updates user tenant preference last used timestamp', function () { + [$user, $tenant] = createUserWithTenant(); + + TenantSet::dispatch($tenant, $user); + + $this->assertDatabaseHas('user_tenant_preferences', [ + 'user_id' => $user->id, + 'tenant_id' => $tenant->id, + ]); +}); diff --git a/tests/Feature/Filament/TenantRbacWizardTest.php b/tests/Feature/Filament/TenantRbacWizardTest.php index dd55e28..d9cd858 100644 --- a/tests/Feature/Filament/TenantRbacWizardTest.php +++ b/tests/Feature/Filament/TenantRbacWizardTest.php @@ -6,6 +6,7 @@ use App\Models\User; use App\Services\Graph\GraphClientInterface; use App\Services\Graph\GraphResponse; +use Filament\Facades\Filament; use Illuminate\Foundation\Testing\RefreshDatabase; use Illuminate\Support\Facades\Cache; use Livewire\Livewire; @@ -32,6 +33,10 @@ function tenantWithApp(): Tenant $tenant = tenantWithApp(); $user = User::factory()->create(); $this->actingAs($user); + $user->tenants()->syncWithoutDetaching([ + $tenant->getKey() => ['role' => 'owner'], + ]); + Filament::setTenant($tenant, true); Livewire::test(ViewTenant::class, ['record' => $tenant->getRouteKey()]) ->mountAction('setup_rbac') @@ -51,6 +56,10 @@ function tenantWithApp(): Tenant $tenant = tenantWithApp(); $user = User::factory()->create(); $this->actingAs($user); + $user->tenants()->syncWithoutDetaching([ + $tenant->getKey() => ['role' => 'owner'], + ]); + Filament::setTenant($tenant, true); $cacheKey = RbacDelegatedAuthController::cacheKey($tenant, $user->id, null); Cache::put($cacheKey, 'delegated-token', now()->addMinutes(5)); @@ -155,6 +164,10 @@ public function request(string $method, string $path, array $options = []): Grap $tenant = tenantWithApp(); $user = User::factory()->create(); $this->actingAs($user); + $user->tenants()->syncWithoutDetaching([ + $tenant->getKey() => ['role' => 'owner'], + ]); + Filament::setTenant($tenant, true); $cacheKey = RbacDelegatedAuthController::cacheKey($tenant, $user->id, null); Cache::put($cacheKey, 'delegated-token', now()->addMinutes(5)); @@ -265,6 +278,10 @@ public function request(string $method, string $path, array $options = []): Grap $tenant = tenantWithApp(); $user = User::factory()->create(); $this->actingAs($user); + $user->tenants()->syncWithoutDetaching([ + $tenant->getKey() => ['role' => 'owner'], + ]); + Filament::setTenant($tenant, true); $cacheKey = RbacDelegatedAuthController::cacheKey($tenant, $user->id, null); Cache::put($cacheKey, 'delegated-token', now()->addMinutes(5)); @@ -365,6 +382,10 @@ public function request(string $method, string $path, array $options = []): Grap $tenant = tenantWithApp(); $user = User::factory()->create(); $this->actingAs($user); + $user->tenants()->syncWithoutDetaching([ + $tenant->getKey() => ['role' => 'owner'], + ]); + Filament::setTenant($tenant, true); Livewire::test(ViewTenant::class, ['record' => $tenant->getRouteKey()]) ->mountAction('setup_rbac') @@ -380,6 +401,10 @@ public function request(string $method, string $path, array $options = []): Grap $tenant = tenantWithApp(); $user = User::factory()->create(); $this->actingAs($user); + $user->tenants()->syncWithoutDetaching([ + $tenant->getKey() => ['role' => 'owner'], + ]); + Filament::setTenant($tenant, true); Livewire::test(ViewTenant::class, ['record' => $tenant->getRouteKey()]) ->mountAction('setup_rbac') @@ -394,6 +419,10 @@ public function request(string $method, string $path, array $options = []): Grap $tenant = tenantWithApp(); $user = User::factory()->create(); $this->actingAs($user); + $user->tenants()->syncWithoutDetaching([ + $tenant->getKey() => ['role' => 'owner'], + ]); + Filament::setTenant($tenant, true); $cacheKey = RbacDelegatedAuthController::cacheKey($tenant, $user->id, null); Cache::put($cacheKey, 'delegated-token', now()->addMinutes(5)); @@ -505,6 +534,10 @@ public function request(string $method, string $path, array $options = []): Grap $tenant = tenantWithApp(); $user = User::factory()->create(); $this->actingAs($user); + $user->tenants()->syncWithoutDetaching([ + $tenant->getKey() => ['role' => 'owner'], + ]); + Filament::setTenant($tenant, true); $cacheKey = RbacDelegatedAuthController::cacheKey($tenant, $user->id, null); Cache::put($cacheKey, 'delegated-token', now()->addMinutes(5)); diff --git a/tests/Feature/Filament/TenantSetupTest.php b/tests/Feature/Filament/TenantSetupTest.php index 2d0d171..b950406 100644 --- a/tests/Feature/Filament/TenantSetupTest.php +++ b/tests/Feature/Filament/TenantSetupTest.php @@ -7,6 +7,7 @@ use App\Models\User; use App\Services\Graph\GraphClientInterface; use App\Services\Graph\GraphResponse; +use Filament\Facades\Filament; use Illuminate\Foundation\Testing\RefreshDatabase; use Livewire\Livewire; @@ -54,9 +55,19 @@ public function request(string $method, string $path, array $options = []): Grap $user = User::factory()->create(); $this->actingAs($user); + $contextTenant = Tenant::create([ + 'tenant_id' => 'tenant-context', + 'name' => 'Context Tenant', + ]); + $user->tenants()->syncWithoutDetaching([ + $contextTenant->getKey() => ['role' => 'owner'], + ]); + Filament::setTenant($contextTenant, true); + Livewire::test(CreateTenant::class) ->fillForm([ 'name' => 'Contoso', + 'environment' => 'other', 'tenant_id' => 'tenant-guid', 'domain' => 'contoso.com', 'app_client_id' => 'client-123', @@ -65,7 +76,7 @@ public function request(string $method, string $path, array $options = []): Grap ->call('create') ->assertHasNoFormErrors(); - $tenant = Tenant::first(); + $tenant = Tenant::query()->where('tenant_id', 'tenant-guid')->first(); expect($tenant)->not->toBeNull(); Livewire::test(ViewTenant::class, ['record' => $tenant->getRouteKey()]) @@ -129,6 +140,11 @@ public function request(string $method, string $path, array $options = []): Grap 'tenant_id' => 'tenant-error', 'name' => 'Error Tenant', ]); + $user->tenants()->syncWithoutDetaching([ + $tenant->getKey() => ['role' => 'owner'], + ]); + + Filament::setTenant($tenant, true); Livewire::test(ViewTenant::class, ['record' => $tenant->getRouteKey()]) ->callAction('verify'); @@ -157,6 +173,9 @@ public function request(string $method, string $path, array $options = []): Grap 'tenant_id' => 'tenant-ui', 'name' => 'UI Tenant', ]); + $user->tenants()->syncWithoutDetaching([ + $tenant->getKey() => ['role' => 'owner'], + ]); config(['intune_permissions.granted_stub' => []]); @@ -169,10 +188,51 @@ public function request(string $method, string $path, array $options = []): Grap 'status' => 'ok', ]); - $response = $this->get(route('filament.admin.resources.tenants.view', $tenant)); + $response = $this->get(route('filament.admin.resources.tenants.view', array_merge(filamentTenantRouteParams($tenant), ['record' => $tenant]))); $response->assertOk(); + $response->assertSee('Actions'); $response->assertSee($firstKey); $response->assertSee('ok'); $response->assertSee('missing'); }); + +test('tenant list shows Open in Entra action', function () { + $user = User::factory()->create(); + $this->actingAs($user); + + $tenant = Tenant::create([ + 'tenant_id' => 'tenant-ui-list', + 'name' => 'UI Tenant List', + 'app_client_id' => 'client-123', + ]); + + $user->tenants()->syncWithoutDetaching([ + $tenant->getKey() => ['role' => 'owner'], + ]); + + $response = $this->get(route('filament.admin.resources.tenants.index', filamentTenantRouteParams($tenant))); + + $response->assertOk(); + $response->assertSee('Open in Entra'); +}); + +test('tenant can be deactivated from the tenant detail action menu', function () { + $user = User::factory()->create(); + $this->actingAs($user); + + $tenant = Tenant::create([ + 'tenant_id' => 'tenant-ui-deactivate', + 'name' => 'UI Tenant Deactivate', + ]); + $user->tenants()->syncWithoutDetaching([ + $tenant->getKey() => ['role' => 'owner'], + ]); + + Filament::setTenant($tenant, true); + + Livewire::test(ViewTenant::class, ['record' => $tenant->getRouteKey()]) + ->callAction('archive'); + + $this->assertSoftDeleted('tenants', ['id' => $tenant->id]); +}); diff --git a/tests/Feature/Filament/WindowsUpdateProfilesRestoreTest.php b/tests/Feature/Filament/WindowsUpdateProfilesRestoreTest.php new file mode 100644 index 0000000..4ef31ec --- /dev/null +++ b/tests/Feature/Filament/WindowsUpdateProfilesRestoreTest.php @@ -0,0 +1,298 @@ + + */ + public array $applyPolicyCalls = []; + + 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, ['payload' => []]); + } + + public function getOrganization(array $options = []): GraphResponse + { + return new GraphResponse(true, []); + } + + public function applyPolicy(string $policyType, string $policyId, array $payload, array $options = []): GraphResponse + { + $this->applyPolicyCalls[] = [ + 'policyType' => $policyType, + 'policyId' => $policyId, + 'payload' => $payload, + 'options' => $options, + ]; + + return new GraphResponse(true, []); + } + + public function request(string $method, string $path, array $options = []): GraphResponse + { + return new GraphResponse(true, []); + } + + public function getServicePrincipalPermissions(array $options = []): GraphResponse + { + return new GraphResponse(true, []); + } +} + +test('restore execution applies windows feature update profile with sanitized payload', function () { + $client = new WindowsUpdateProfilesRestoreGraphClient; + app()->instance(GraphClientInterface::class, $client); + + $tenant = Tenant::create([ + 'tenant_id' => 'tenant-1', + 'name' => 'Tenant One', + 'metadata' => [], + ]); + + $policy = Policy::create([ + 'tenant_id' => $tenant->id, + 'external_id' => 'policy-feature', + 'policy_type' => 'windowsFeatureUpdateProfile', + 'display_name' => 'Feature Updates A', + 'platform' => 'windows', + ]); + + $backupSet = BackupSet::create([ + 'tenant_id' => $tenant->id, + 'name' => 'Backup', + 'status' => 'completed', + 'item_count' => 1, + ]); + + $backupPayload = [ + 'id' => 'policy-feature', + '@odata.type' => '#microsoft.graph.windowsFeatureUpdateProfile', + 'displayName' => 'Feature Updates A', + 'featureUpdateVersion' => 'Windows 11, version 23H2', + 'deployableContentDisplayName' => 'Some Content', + 'endOfSupportDate' => '2026-01-01T00:00:00Z', + 'roleScopeTagIds' => ['0'], + ]; + + $backupItem = BackupItem::create([ + 'tenant_id' => $tenant->id, + 'backup_set_id' => $backupSet->id, + 'policy_id' => $policy->id, + 'policy_identifier' => $policy->external_id, + 'policy_type' => $policy->policy_type, + 'platform' => $policy->platform, + 'payload' => $backupPayload, + ]); + + $user = User::factory()->create(['email' => 'tester@example.com']); + $this->actingAs($user); + + $service = app(RestoreService::class); + $run = $service->execute( + tenant: $tenant, + backupSet: $backupSet, + selectedItemIds: [$backupItem->id], + dryRun: false, + actorEmail: $user->email, + actorName: $user->name, + ); + + expect($run->status)->toBe('completed'); + expect($run->results[0]['status'])->toBe('applied'); + + expect(PolicyVersion::where('policy_id', $policy->id)->count())->toBe(1); + + expect($client->applyPolicyCalls)->toHaveCount(1); + expect($client->applyPolicyCalls[0]['policyType'])->toBe('windowsFeatureUpdateProfile'); + expect($client->applyPolicyCalls[0]['policyId'])->toBe('policy-feature'); + expect($client->applyPolicyCalls[0]['options']['method'] ?? null)->toBe('PATCH'); + + expect($client->applyPolicyCalls[0]['payload']['featureUpdateVersion'])->toBe('Windows 11, version 23H2'); + expect($client->applyPolicyCalls[0]['payload']['roleScopeTagIds'])->toBe(['0']); + + expect($client->applyPolicyCalls[0]['payload'])->not->toHaveKey('id'); + expect($client->applyPolicyCalls[0]['payload'])->not->toHaveKey('@odata.type'); + expect($client->applyPolicyCalls[0]['payload'])->not->toHaveKey('deployableContentDisplayName'); + expect($client->applyPolicyCalls[0]['payload'])->not->toHaveKey('endOfSupportDate'); +}); + +test('restore execution applies windows quality update profile with sanitized payload', function () { + $client = new WindowsUpdateProfilesRestoreGraphClient; + app()->instance(GraphClientInterface::class, $client); + + $tenant = Tenant::create([ + 'tenant_id' => 'tenant-1', + 'name' => 'Tenant One', + 'metadata' => [], + ]); + + $policy = Policy::create([ + 'tenant_id' => $tenant->id, + 'external_id' => 'policy-quality', + 'policy_type' => 'windowsQualityUpdateProfile', + 'display_name' => 'Quality Updates A', + 'platform' => 'windows', + ]); + + $backupSet = BackupSet::create([ + 'tenant_id' => $tenant->id, + 'name' => 'Backup', + 'status' => 'completed', + 'item_count' => 1, + ]); + + $backupPayload = [ + 'id' => 'policy-quality', + '@odata.type' => '#microsoft.graph.windowsQualityUpdateProfile', + 'displayName' => 'Quality Updates A', + 'qualityUpdateCveIds' => ['CVE-2025-0001'], + 'deployableContentDisplayName' => 'Some Content', + 'releaseDateDisplayName' => 'January 2026', + 'roleScopeTagIds' => ['0'], + ]; + + $backupItem = BackupItem::create([ + 'tenant_id' => $tenant->id, + 'backup_set_id' => $backupSet->id, + 'policy_id' => $policy->id, + 'policy_identifier' => $policy->external_id, + 'policy_type' => $policy->policy_type, + 'platform' => $policy->platform, + 'payload' => $backupPayload, + ]); + + $user = User::factory()->create(['email' => 'tester@example.com']); + $this->actingAs($user); + + $service = app(RestoreService::class); + $run = $service->execute( + tenant: $tenant, + backupSet: $backupSet, + selectedItemIds: [$backupItem->id], + dryRun: false, + actorEmail: $user->email, + actorName: $user->name, + ); + + expect($run->status)->toBe('completed'); + expect($run->results[0]['status'])->toBe('applied'); + + expect(PolicyVersion::where('policy_id', $policy->id)->count())->toBe(1); + + expect($client->applyPolicyCalls)->toHaveCount(1); + expect($client->applyPolicyCalls[0]['policyType'])->toBe('windowsQualityUpdateProfile'); + expect($client->applyPolicyCalls[0]['policyId'])->toBe('policy-quality'); + expect($client->applyPolicyCalls[0]['options']['method'] ?? null)->toBe('PATCH'); + + expect($client->applyPolicyCalls[0]['payload']['qualityUpdateCveIds'])->toBe(['CVE-2025-0001']); + expect($client->applyPolicyCalls[0]['payload']['roleScopeTagIds'])->toBe(['0']); + + expect($client->applyPolicyCalls[0]['payload'])->not->toHaveKey('id'); + expect($client->applyPolicyCalls[0]['payload'])->not->toHaveKey('@odata.type'); + expect($client->applyPolicyCalls[0]['payload'])->not->toHaveKey('deployableContentDisplayName'); + expect($client->applyPolicyCalls[0]['payload'])->not->toHaveKey('releaseDateDisplayName'); +}); + +test('restore execution applies windows driver update profile with sanitized payload', function () { + $client = new WindowsUpdateProfilesRestoreGraphClient; + app()->instance(GraphClientInterface::class, $client); + + $tenant = Tenant::create([ + 'tenant_id' => 'tenant-1', + 'name' => 'Tenant One', + 'metadata' => [], + ]); + + $policy = Policy::create([ + 'tenant_id' => $tenant->id, + 'external_id' => 'policy-driver', + 'policy_type' => 'windowsDriverUpdateProfile', + 'display_name' => 'Driver Updates A', + 'platform' => 'windows', + ]); + + $backupSet = BackupSet::create([ + 'tenant_id' => $tenant->id, + 'name' => 'Backup', + 'status' => 'completed', + 'item_count' => 1, + ]); + + $backupPayload = [ + 'id' => 'policy-driver', + '@odata.type' => '#microsoft.graph.windowsDriverUpdateProfile', + 'displayName' => 'Driver Updates A', + 'description' => 'Drivers rollout policy', + 'approvalType' => 'automatic', + 'deploymentDeferralInDays' => 7, + 'deviceReporting' => 12, + 'newUpdates' => 3, + 'inventorySyncStatus' => [ + 'driverInventorySyncState' => 'success', + 'lastSuccessfulSyncDateTime' => '2026-01-01T00:00:00Z', + ], + 'roleScopeTagIds' => ['0'], + ]; + + $backupItem = BackupItem::create([ + 'tenant_id' => $tenant->id, + 'backup_set_id' => $backupSet->id, + 'policy_id' => $policy->id, + 'policy_identifier' => $policy->external_id, + 'policy_type' => $policy->policy_type, + 'platform' => $policy->platform, + 'payload' => $backupPayload, + ]); + + $user = User::factory()->create(['email' => 'tester@example.com']); + $this->actingAs($user); + + $service = app(RestoreService::class); + $run = $service->execute( + tenant: $tenant, + backupSet: $backupSet, + selectedItemIds: [$backupItem->id], + dryRun: false, + actorEmail: $user->email, + actorName: $user->name, + ); + + expect($run->status)->toBe('completed'); + expect($run->results[0]['status'])->toBe('applied'); + + expect(PolicyVersion::where('policy_id', $policy->id)->count())->toBe(1); + + expect($client->applyPolicyCalls)->toHaveCount(1); + expect($client->applyPolicyCalls[0]['policyType'])->toBe('windowsDriverUpdateProfile'); + expect($client->applyPolicyCalls[0]['policyId'])->toBe('policy-driver'); + expect($client->applyPolicyCalls[0]['options']['method'] ?? null)->toBe('PATCH'); + + expect($client->applyPolicyCalls[0]['payload']['approvalType'])->toBe('automatic'); + expect($client->applyPolicyCalls[0]['payload']['deploymentDeferralInDays'])->toBe(7); + expect($client->applyPolicyCalls[0]['payload']['roleScopeTagIds'])->toBe(['0']); + + expect($client->applyPolicyCalls[0]['payload'])->not->toHaveKey('id'); + expect($client->applyPolicyCalls[0]['payload'])->not->toHaveKey('@odata.type'); + expect($client->applyPolicyCalls[0]['payload'])->not->toHaveKey('deviceReporting'); + expect($client->applyPolicyCalls[0]['payload'])->not->toHaveKey('newUpdates'); + expect($client->applyPolicyCalls[0]['payload'])->not->toHaveKey('inventorySyncStatus'); +}); diff --git a/tests/Feature/Filament/WindowsUpdateRingPolicyTest.php b/tests/Feature/Filament/WindowsUpdateRingPolicyTest.php new file mode 100644 index 0000000..bfc70b8 --- /dev/null +++ b/tests/Feature/Filament/WindowsUpdateRingPolicyTest.php @@ -0,0 +1,80 @@ + 'local-tenant', + 'name' => 'Tenant One', + 'metadata' => [], + 'is_current' => true, + ]); + + $tenant->makeCurrent(); + + $policy = Policy::create([ + 'tenant_id' => $tenant->id, + 'external_id' => 'policy-wuring', + 'policy_type' => 'windowsUpdateRing', + 'display_name' => 'Windows Update Ring A', + 'platform' => 'windows', + ]); + + PolicyVersion::create([ + 'tenant_id' => $tenant->id, + 'policy_id' => $policy->id, + 'version_number' => 1, + 'policy_type' => $policy->policy_type, + 'platform' => $policy->platform, + 'created_by' => 'tester@example.com', + 'captured_at' => CarbonImmutable::now(), + 'snapshot' => [ + '@odata.type' => '#microsoft.graph.windowsUpdateForBusinessConfiguration', + 'automaticUpdateMode' => 'autoInstallAtMaintenanceTime', + 'featureUpdatesDeferralPeriodInDays' => 14, + 'deadlineForFeatureUpdatesInDays' => 7, + 'deliveryOptimizationMode' => 'httpWithPeeringNat', + 'qualityUpdatesPaused' => false, + 'userPauseAccess' => 'allow', + ], + ]); + + $user = User::factory()->create(); + $user->tenants()->syncWithoutDetaching([ + $tenant->getKey() => ['role' => 'owner'], + ]); + + $response = $this->actingAs($user) + ->get(PolicyResource::getUrl('view', ['record' => $policy], tenant: $tenant)); + + $response->assertOk(); + + // Check for correct titles and settings from the normalizer + $response->assertSee('Update Settings'); + $response->assertSee('Automatic Update Mode'); + $response->assertSee('autoInstallAtMaintenanceTime'); + $response->assertSee('Feature Updates Deferral Period In Days'); + $response->assertSee('14'); + $response->assertSee('Quality Updates Paused'); + $response->assertSee('No'); + + $response->assertSee('User Experience'); + $response->assertSee('Deadline For Feature Updates In Days'); + $response->assertSee('7'); + $response->assertSee('User Pause Access'); + $response->assertSee('allow'); + + $response->assertSee('Advanced Options'); + $response->assertSee('Delivery Optimization Mode'); + $response->assertSee('httpWithPeeringNat'); + + // $response->assertDontSee('@odata.type'); +}); diff --git a/tests/Feature/Filament/WindowsUpdateRingRestoreTest.php b/tests/Feature/Filament/WindowsUpdateRingRestoreTest.php new file mode 100644 index 0000000..441dc24 --- /dev/null +++ b/tests/Feature/Filament/WindowsUpdateRingRestoreTest.php @@ -0,0 +1,151 @@ + []]); + } + + public function getOrganization(array $options = []): GraphResponse + { + return new GraphResponse(true, []); + } + + public function applyPolicy(string $policyType, string $policyId, array $payload, array $options = []): GraphResponse + { + $this->applied[] = [ + 'policyType' => $policyType, + 'policyId' => $policyId, + 'payload' => $payload, + 'options' => $options, + ]; + + return new GraphResponse(true, []); + } + + public function request(string $method, string $path, array $options = []): GraphResponse + { + $this->requests[] = [ + 'method' => strtoupper($method), + 'path' => $path, + 'payload' => $options['json'] ?? null, + ]; + + return new GraphResponse(true, []); + } + + public function getServicePrincipalPermissions(array $options = []): GraphResponse + { + return new GraphResponse(true, []); + } + }; + + app()->instance(GraphClientInterface::class, $client); + + $tenant = Tenant::create([ + 'tenant_id' => 'tenant-1', + 'name' => 'Tenant One', + 'metadata' => [], + ]); + + $policy = Policy::create([ + 'tenant_id' => $tenant->id, + 'external_id' => 'policy-wuring', + 'policy_type' => 'windowsUpdateRing', + 'display_name' => 'Windows Update Ring A', + 'platform' => 'windows', + ]); + + $backupSet = BackupSet::create([ + 'tenant_id' => $tenant->id, + 'name' => 'Backup', + 'status' => 'completed', + 'item_count' => 1, + ]); + + $backupPayload = [ + 'id' => 'policy-wuring', + '@odata.type' => '#microsoft.graph.windowsUpdateForBusinessConfiguration', + 'automaticUpdateMode' => 'autoInstallAtMaintenanceTime', + 'featureUpdatesDeferralPeriodInDays' => 14, + 'version' => 7, + 'qualityUpdatesPauseStartDate' => '2025-01-01T00:00:00Z', + 'featureUpdatesPauseStartDate' => '2025-01-02T00:00:00Z', + 'qualityUpdatesWillBeRolledBack' => false, + 'featureUpdatesWillBeRolledBack' => false, + 'roleScopeTagIds' => ['0'], + ]; + + $backupItem = BackupItem::create([ + 'tenant_id' => $tenant->id, + 'backup_set_id' => $backupSet->id, + 'policy_id' => $policy->id, + 'policy_identifier' => $policy->external_id, + 'policy_type' => $policy->policy_type, + 'platform' => $policy->platform, + 'payload' => $backupPayload, + ]); + + $user = User::factory()->create(['email' => 'tester@example.com']); + $this->actingAs($user); + + $service = app(RestoreService::class); + $run = $service->execute( + tenant: $tenant, + backupSet: $backupSet, + selectedItemIds: [$backupItem->id], + dryRun: false, + actorEmail: $user->email, + actorName: $user->name, + ); + + expect($run->status)->toBe('completed'); + expect($run->results[0]['status'])->toBe('applied'); + + $this->assertDatabaseHas('audit_logs', [ + 'action' => 'restore.executed', + 'resource_id' => (string) $run->id, + ]); + + expect(PolicyVersion::where('policy_id', $policy->id)->count())->toBe(1); + + expect($client->requests)->toHaveCount(1); + expect($client->requests[0]['method'])->toBe('PATCH'); + expect($client->requests[0]['path'])->toBe('deviceManagement/deviceConfigurations/policy-wuring/microsoft.graph.windowsUpdateForBusinessConfiguration'); + + expect($client->requests[0]['payload']['automaticUpdateMode'])->toBe('autoInstallAtMaintenanceTime'); + expect($client->requests[0]['payload']['featureUpdatesDeferralPeriodInDays'])->toBe(14); + expect($client->requests[0]['payload']['roleScopeTagIds'])->toBe(['0']); + + expect($client->requests[0]['payload'])->not->toHaveKey('id'); + expect($client->requests[0]['payload'])->not->toHaveKey('@odata.type'); + expect($client->requests[0]['payload'])->not->toHaveKey('version'); + expect($client->requests[0]['payload'])->not->toHaveKey('qualityUpdatesPauseStartDate'); + expect($client->requests[0]['payload'])->not->toHaveKey('featureUpdatesPauseStartDate'); + expect($client->requests[0]['payload'])->not->toHaveKey('qualityUpdatesWillBeRolledBack'); + expect($client->requests[0]['payload'])->not->toHaveKey('featureUpdatesWillBeRolledBack'); +}); diff --git a/tests/Feature/Jobs/AppProtectionPolicySyncFilteringTest.php b/tests/Feature/Jobs/AppProtectionPolicySyncFilteringTest.php index 8b4a56f..fb3dcb1 100644 --- a/tests/Feature/Jobs/AppProtectionPolicySyncFilteringTest.php +++ b/tests/Feature/Jobs/AppProtectionPolicySyncFilteringTest.php @@ -49,7 +49,7 @@ public function request(string $method, string $path, array $options = []): Grap test('sync skips managed app configurations from app protection inventory', function () { $tenant = Tenant::create([ - 'tenant_id' => env('INTUNE_TENANT_ID', 'test-tenant'), + 'tenant_id' => 'test-tenant', 'name' => 'Test Tenant', 'metadata' => [], 'is_current' => true, diff --git a/tests/Feature/Jobs/PolicySyncIgnoredRevivalTest.php b/tests/Feature/Jobs/PolicySyncIgnoredRevivalTest.php index d6a50ab..4ae8c1f 100644 --- a/tests/Feature/Jobs/PolicySyncIgnoredRevivalTest.php +++ b/tests/Feature/Jobs/PolicySyncIgnoredRevivalTest.php @@ -49,7 +49,7 @@ public function request(string $method, string $path, array $options = []): Grap test('sync revives ignored policies when they exist in Intune', function () { $tenant = Tenant::create([ - 'tenant_id' => env('INTUNE_TENANT_ID', 'test-tenant'), + 'tenant_id' => 'test-tenant', 'name' => 'Test Tenant', 'metadata' => [], 'is_current' => true, @@ -94,7 +94,7 @@ public function request(string $method, string $path, array $options = []): Grap test('sync creates new policies even if ignored ones exist with same external_id', function () { $tenant = Tenant::create([ - 'tenant_id' => env('INTUNE_TENANT_ID', 'test-tenant-2'), + 'tenant_id' => 'test-tenant-2', 'name' => 'Test Tenant 2', 'metadata' => [], 'is_current' => true, diff --git a/tests/Feature/PolicyGeneralViewTest.php b/tests/Feature/PolicyGeneralViewTest.php new file mode 100644 index 0000000..db59d92 --- /dev/null +++ b/tests/Feature/PolicyGeneralViewTest.php @@ -0,0 +1,41 @@ + fn (): array => [ + 'entries' => [ + ['key' => 'Name', 'value' => 'WindowsFirewall Endpointsecurity'], + ['key' => 'Platforms', 'value' => 'windows10'], + ['key' => 'Technologies', 'value' => 'mdm,microsoftSense'], + ['key' => 'Template Reference', 'value' => [ + 'templateId' => '19c8aa67-f286-4861-9aa0-f23541d31680_1', + 'templateFamily' => 'endpointSecurityFirewall', + 'templateDisplayName' => 'Windows Firewall Rules', + 'templateDisplayVersion' => 'Version 1', + ]], + ['key' => 'Last Modified', 'value' => '2026-01-03T00:52:32.2784312Z'], + ], + ], + ], + )->render(); + + expect($html)->toContain('Windows Firewall Rules'); + expect($html)->toContain('Endpoint Security Firewall'); + expect($html)->toContain('Version 1'); + expect($html)->toContain('19c8aa67-f286-4861-9aa0-f23541d31680_1'); + + expect($html)->toContain('mdm'); + expect($html)->toContain('microsoftSense'); + expect($html)->toContain('fi-badge'); + + expect($html)->toContain('2026-01-03 00:52:32'); + expect($html)->not->toContain('T00:52:32.2784312Z'); + + expect($html)->not->toContain('"templateId"'); +}); diff --git a/tests/Feature/PolicySettingsStandardViewTest.php b/tests/Feature/PolicySettingsStandardViewTest.php new file mode 100644 index 0000000..1350e1c --- /dev/null +++ b/tests/Feature/PolicySettingsStandardViewTest.php @@ -0,0 +1,30 @@ + fn (): array => [ + 'settings' => [ + [ + 'type' => 'table', + 'title' => 'App configuration settings', + 'rows' => [ + ['label' => 'StringEnabled', 'value' => 'Enabled'], + ['label' => 'StringDisabled', 'value' => 'Disabled'], + ], + ], + ], + 'policy_type' => 'managedDeviceAppConfiguration', + ], + ], + )->render(); + + expect($html)->toContain('Enabled') + ->and($html)->toContain('Disabled') + ->and($html)->toContain('fi-badge'); +}); diff --git a/tests/Feature/PolicySyncEnrollmentConfigurationTypeCollisionTest.php b/tests/Feature/PolicySyncEnrollmentConfigurationTypeCollisionTest.php new file mode 100644 index 0000000..a21e3b3 --- /dev/null +++ b/tests/Feature/PolicySyncEnrollmentConfigurationTypeCollisionTest.php @@ -0,0 +1,250 @@ + 'tenant-sync-collision', + 'name' => 'Tenant Sync Collision', + 'metadata' => [], + 'is_current' => true, + ]); + + $tenant->makeCurrent(); + + // Simulate an older bug: ESP row was synced under enrollmentRestriction. + $wrong = Policy::create([ + 'tenant_id' => $tenant->id, + 'external_id' => 'esp-1', + 'policy_type' => 'enrollmentRestriction', + 'display_name' => 'ESP Misclassified', + 'platform' => 'all', + ]); + + $this->mock(GraphClientInterface::class, function (MockInterface $mock) { + $espPayload = [ + 'id' => 'esp-1', + 'displayName' => 'Enrollment Status Page', + '@odata.type' => '#microsoft.graph.windows10EnrollmentCompletionPageConfiguration', + 'deviceEnrollmentConfigurationType' => 'windows10EnrollmentCompletionPageConfiguration', + ]; + + $mock->shouldReceive('listPolicies') + ->andReturnUsing(function (string $policyType) use ($espPayload) { + if ($policyType === 'enrollmentRestriction') { + // Shared endpoint can return ESP items if unfiltered. + return new GraphResponse(true, [$espPayload]); + } + + if ($policyType === 'windowsEnrollmentStatusPage') { + return new GraphResponse(true, [$espPayload]); + } + + return new GraphResponse(true, []); + }); + }); + + $service = app(PolicySyncService::class); + + $service->syncPolicies($tenant, [ + [ + 'type' => 'enrollmentRestriction', + 'platform' => 'all', + 'filter' => null, + ], + [ + 'type' => 'windowsEnrollmentStatusPage', + 'platform' => 'all', + 'filter' => null, + ], + ]); + + $wrong->refresh(); + + expect($wrong->policy_type)->toBe('windowsEnrollmentStatusPage'); +}); + +test('policy sync classifies ESP items without relying on Graph isof filter', function () { + $tenant = Tenant::create([ + 'tenant_id' => 'tenant-sync-esp-no-filter', + 'name' => 'Tenant Sync ESP No Filter', + 'metadata' => [], + 'is_current' => true, + ]); + + $tenant->makeCurrent(); + + $this->mock(GraphClientInterface::class, function (MockInterface $mock) { + $payload = [ + [ + 'id' => 'esp-1', + 'displayName' => 'Enrollment Status Page', + '@odata.type' => '#microsoft.graph.windows10EnrollmentCompletionPageConfiguration', + 'deviceEnrollmentConfigurationType' => 'windows10EnrollmentCompletionPageConfiguration', + ], + [ + 'id' => 'restriction-1', + 'displayName' => 'Default Enrollment Restriction', + '@odata.type' => '#microsoft.graph.deviceEnrollmentPlatformRestrictionConfiguration', + 'deviceEnrollmentConfigurationType' => 'deviceEnrollmentPlatformRestrictionConfiguration', + ], + [ + 'id' => 'other-1', + 'displayName' => 'Other Enrollment Config', + '@odata.type' => '#microsoft.graph.someOtherEnrollmentConfiguration', + 'deviceEnrollmentConfigurationType' => 'someOtherEnrollmentConfiguration', + ], + ]; + + $mock->shouldReceive('listPolicies') + ->andReturnUsing(function (string $policyType) use ($payload) { + if (in_array($policyType, [ + 'enrollmentRestriction', + 'windowsEnrollmentStatusPage', + 'deviceEnrollmentPlatformRestrictionsConfiguration', + ], true)) { + return new GraphResponse(true, $payload); + } + + return new GraphResponse(true, []); + }); + }); + + $service = app(PolicySyncService::class); + + $service->syncPolicies($tenant, [ + [ + 'type' => 'windowsEnrollmentStatusPage', + 'platform' => 'all', + 'filter' => null, + ], + [ + 'type' => 'deviceEnrollmentPlatformRestrictionsConfiguration', + 'platform' => 'all', + 'filter' => null, + ], + [ + 'type' => 'enrollmentRestriction', + 'platform' => 'all', + 'filter' => null, + ], + ]); + + $espIds = Policy::query() + ->where('tenant_id', $tenant->id) + ->where('policy_type', 'windowsEnrollmentStatusPage') + ->pluck('external_id') + ->all(); + + $restrictionIds = Policy::query() + ->where('tenant_id', $tenant->id) + ->where('policy_type', 'enrollmentRestriction') + ->orderBy('external_id') + ->pluck('external_id') + ->all(); + + $platformRestrictionIds = Policy::query() + ->where('tenant_id', $tenant->id) + ->where('policy_type', 'deviceEnrollmentPlatformRestrictionsConfiguration') + ->orderBy('external_id') + ->pluck('external_id') + ->all(); + + expect($espIds)->toMatchArray(['esp-1']); + expect($platformRestrictionIds)->toMatchArray(['restriction-1']); + expect($restrictionIds)->toMatchArray(['other-1']); +}); + +test('policy sync classifies enrollment configuration subtypes separately', function () { + $tenant = Tenant::create([ + 'tenant_id' => 'tenant-sync-enrollment-subtypes', + 'name' => 'Tenant Sync Enrollment Subtypes', + 'metadata' => [], + 'is_current' => true, + ]); + + $tenant->makeCurrent(); + + $this->mock(GraphClientInterface::class, function (MockInterface $mock) { + $limitPayload = [ + 'id' => 'limit-1', + 'displayName' => 'Enrollment Limit', + '@odata.type' => '#microsoft.graph.deviceEnrollmentLimitConfiguration', + 'deviceEnrollmentConfigurationType' => 'deviceEnrollmentLimitConfiguration', + 'limit' => 5, + ]; + + $platformRestrictionsPayload = [ + 'id' => 'platform-1', + 'displayName' => 'Platform Restrictions', + '@odata.type' => '#microsoft.graph.deviceEnrollmentPlatformRestrictionsConfiguration', + 'deviceEnrollmentConfigurationType' => 'deviceEnrollmentPlatformRestrictionsConfiguration', + ]; + + $notificationPayload = [ + 'id' => 'notify-1', + 'displayName' => 'Enrollment Notifications', + '@odata.type' => '#microsoft.graph.deviceEnrollmentNotificationConfiguration', + 'deviceEnrollmentConfigurationType' => 'EnrollmentNotificationsConfiguration', + ]; + + $unfilteredPayload = [ + $limitPayload, + $platformRestrictionsPayload, + $notificationPayload, + ]; + + $mock->shouldReceive('listPolicies') + ->andReturnUsing(function (string $policyType) use ($notificationPayload, $unfilteredPayload) { + if ($policyType === 'deviceEnrollmentNotificationConfiguration') { + return new GraphResponse(true, [$notificationPayload]); + } + + if (in_array($policyType, [ + 'enrollmentRestriction', + 'deviceEnrollmentLimitConfiguration', + 'deviceEnrollmentPlatformRestrictionsConfiguration', + 'windowsEnrollmentStatusPage', + ], true)) { + return new GraphResponse(true, $unfilteredPayload); + } + + return new GraphResponse(true, []); + }); + }); + + $service = app(PolicySyncService::class); + + $service->syncPolicies($tenant, [ + ['type' => 'deviceEnrollmentLimitConfiguration', 'platform' => 'all', 'filter' => null], + ['type' => 'deviceEnrollmentPlatformRestrictionsConfiguration', 'platform' => 'all', 'filter' => null], + ['type' => 'deviceEnrollmentNotificationConfiguration', 'platform' => 'all', 'filter' => null], + ['type' => 'enrollmentRestriction', 'platform' => 'all', 'filter' => null], + ]); + + expect(Policy::query() + ->where('tenant_id', $tenant->id) + ->where('policy_type', 'deviceEnrollmentLimitConfiguration') + ->pluck('external_id') + ->all())->toMatchArray(['limit-1']); + + expect(Policy::query() + ->where('tenant_id', $tenant->id) + ->where('policy_type', 'deviceEnrollmentPlatformRestrictionsConfiguration') + ->pluck('external_id') + ->all())->toMatchArray(['platform-1']); + + expect(Policy::query() + ->where('tenant_id', $tenant->id) + ->where('policy_type', 'deviceEnrollmentNotificationConfiguration') + ->pluck('external_id') + ->all())->toMatchArray(['notify-1']); +}); diff --git a/tests/Feature/PolicySyncServiceReportTest.php b/tests/Feature/PolicySyncServiceReportTest.php new file mode 100644 index 0000000..0c53c60 --- /dev/null +++ b/tests/Feature/PolicySyncServiceReportTest.php @@ -0,0 +1,61 @@ +create([ + 'status' => 'active', + ]); + + $logger = mock(GraphLogger::class); + $logger->shouldReceive('logRequest')->zeroOrMoreTimes()->andReturnNull(); + $logger->shouldReceive('logResponse')->zeroOrMoreTimes()->andReturnNull(); + + mock(GraphClientInterface::class) + ->shouldReceive('listPolicies') + ->andReturnUsing(function (string $policyType) { + return match ($policyType) { + 'endpointSecurityPolicy' => new GraphResponse( + success: false, + data: [], + status: 403, + errors: [['message' => 'Forbidden']], + meta: ['path' => '/deviceManagement/configurationPolicies'], + ), + default => new GraphResponse( + success: true, + data: [ + ['id' => 'scp-1', 'displayName' => 'Settings Catalog', 'technologies' => ['mdm']], + ], + status: 200, + ), + }; + }); + + $service = app(PolicySyncService::class); + + $result = $service->syncPoliciesWithReport($tenant, [ + ['type' => 'endpointSecurityPolicy', 'platform' => 'windows'], + ['type' => 'settingsCatalogPolicy', 'platform' => 'windows'], + ]); + + expect($result)->toHaveKeys(['synced', 'failures']); + expect($result['synced'])->toBeArray(); + expect($result['failures'])->toBeArray(); + + expect(count($result['synced']))->toBe(1); + expect(Policy::query()->where('tenant_id', $tenant->id)->count())->toBe(1); + + expect(count($result['failures']))->toBe(1); + expect($result['failures'][0]['policy_type'])->toBe('endpointSecurityPolicy'); + expect($result['failures'][0]['status'])->toBe(403); +}); diff --git a/tests/Feature/PolicySyncServiceTest.php b/tests/Feature/PolicySyncServiceTest.php new file mode 100644 index 0000000..7c056a3 --- /dev/null +++ b/tests/Feature/PolicySyncServiceTest.php @@ -0,0 +1,340 @@ +create([ + 'status' => 'active', + ]); + + $policy = Policy::factory()->create([ + 'tenant_id' => $tenant->id, + 'external_id' => 'policy-1', + 'policy_type' => 'appProtectionPolicy', + 'ignored_at' => null, + ]); + + $logger = mock(GraphLogger::class); + + $logger->shouldReceive('logRequest') + ->zeroOrMoreTimes() + ->andReturnNull(); + + $logger->shouldReceive('logResponse') + ->zeroOrMoreTimes() + ->andReturnNull(); + + mock(GraphClientInterface::class) + ->shouldReceive('listPolicies') + ->once() + ->andReturn(new GraphResponse( + success: true, + data: [ + [ + 'id' => 'policy-1', + 'displayName' => 'Ignored policy', + '@odata.type' => '#microsoft.graph.targetedManagedAppConfiguration', + ], + ], + )); + + $service = app(PolicySyncService::class); + + $synced = $service->syncPolicies($tenant, [ + ['type' => 'appProtectionPolicy'], + ]); + + $policy->refresh(); + + expect($policy->ignored_at)->not->toBeNull(); + expect($synced)->toBeArray()->toBeEmpty(); +}); + +it('uses isof filters for windows update rings and supports feature/quality update profiles', function () { + $supported = config('tenantpilot.supported_policy_types'); + $byType = collect($supported)->keyBy('type'); + + expect($byType)->toHaveKeys(['deviceConfiguration', 'windowsUpdateRing', 'windowsFeatureUpdateProfile', 'windowsQualityUpdateProfile', 'windowsDriverUpdateProfile']); + + expect($byType['deviceConfiguration']['filter'] ?? null) + ->toBe("not isof('microsoft.graph.windowsUpdateForBusinessConfiguration')"); + + expect($byType['windowsUpdateRing']['filter'] ?? null) + ->toBe("isof('microsoft.graph.windowsUpdateForBusinessConfiguration')"); + + expect($byType['windowsFeatureUpdateProfile']['endpoint'] ?? null) + ->toBe('deviceManagement/windowsFeatureUpdateProfiles'); + + expect($byType['windowsQualityUpdateProfile']['endpoint'] ?? null) + ->toBe('deviceManagement/windowsQualityUpdateProfiles'); + + expect($byType['windowsDriverUpdateProfile']['endpoint'] ?? null) + ->toBe('deviceManagement/windowsDriverUpdateProfiles'); +}); + +it('syncs windows driver update profiles from Graph', function () { + $tenant = Tenant::factory()->create([ + 'status' => 'active', + ]); + + $logger = mock(GraphLogger::class); + + $logger->shouldReceive('logRequest') + ->zeroOrMoreTimes() + ->andReturnNull(); + + $logger->shouldReceive('logResponse') + ->zeroOrMoreTimes() + ->andReturnNull(); + + mock(GraphClientInterface::class) + ->shouldReceive('listPolicies') + ->once() + ->with('windowsDriverUpdateProfile', mockery::type('array')) + ->andReturn(new GraphResponse( + success: true, + data: [ + [ + 'id' => 'wdp-1', + 'displayName' => 'Driver Updates A', + '@odata.type' => '#microsoft.graph.windowsDriverUpdateProfile', + 'approvalType' => 'automatic', + ], + ], + )); + + $service = app(PolicySyncService::class); + + $service->syncPolicies($tenant, [ + ['type' => 'windowsDriverUpdateProfile', 'platform' => 'windows'], + ]); + + expect(Policy::query()->where('tenant_id', $tenant->id)->where('policy_type', 'windowsDriverUpdateProfile')->count()) + ->toBe(1); +}); + +it('includes managed device app configurations in supported types', function () { + $supported = config('tenantpilot.supported_policy_types'); + $byType = collect($supported)->keyBy('type'); + + expect($byType)->toHaveKey('managedDeviceAppConfiguration'); + expect($byType['managedDeviceAppConfiguration']['endpoint'] ?? null) + ->toBe('deviceAppManagement/mobileAppConfigurations'); + expect($byType['managedDeviceAppConfiguration']['filter'] ?? null) + ->toBe("microsoft.graph.androidManagedStoreAppConfiguration/appSupportsOemConfig eq false or isof('microsoft.graph.androidManagedStoreAppConfiguration') eq false"); +}); + +it('syncs managed device app configurations from Graph', function () { + $tenant = Tenant::factory()->create([ + 'status' => 'active', + ]); + + $logger = mock(GraphLogger::class); + + $logger->shouldReceive('logRequest') + ->zeroOrMoreTimes() + ->andReturnNull(); + + $logger->shouldReceive('logResponse') + ->zeroOrMoreTimes() + ->andReturnNull(); + + mock(GraphClientInterface::class) + ->shouldReceive('listPolicies') + ->once() + ->with('managedDeviceAppConfiguration', mockery::type('array')) + ->andReturn(new GraphResponse( + success: true, + data: [ + [ + 'id' => 'madc-1', + 'displayName' => 'MAM Device Config', + '@odata.type' => '#microsoft.graph.managedDeviceMobileAppConfiguration', + ], + ], + )); + + $service = app(PolicySyncService::class); + + $service->syncPolicies($tenant, [ + ['type' => 'managedDeviceAppConfiguration', 'platform' => 'mobile'], + ]); + + expect(Policy::query()->where('tenant_id', $tenant->id)->where('policy_type', 'managedDeviceAppConfiguration')->count()) + ->toBe(1); +}); + +it('classifies configuration policies into settings catalog, endpoint security, and security baseline types', function () { + $tenant = Tenant::factory()->create([ + 'status' => 'active', + ]); + + $logger = mock(GraphLogger::class); + + $logger->shouldReceive('logRequest') + ->zeroOrMoreTimes() + ->andReturnNull(); + + $logger->shouldReceive('logResponse') + ->zeroOrMoreTimes() + ->andReturnNull(); + + $graphResponse = new GraphResponse( + success: true, + data: [ + [ + 'id' => 'scp-1', + 'name' => 'Settings Catalog Alpha', + '@odata.type' => '#microsoft.graph.deviceManagementConfigurationPolicy', + 'technologies' => ['mdm'], + 'templateReference' => null, + ], + [ + 'id' => 'esp-1', + 'name' => 'Endpoint Security Beta', + '@odata.type' => '#microsoft.graph.deviceManagementConfigurationPolicy', + 'technologies' => 'mdm', + 'templateReference' => [ + 'templateFamily' => 'endpointSecurityDiskEncryption', + 'templateDisplayName' => 'BitLocker', + ], + ], + [ + 'id' => 'sb-1', + 'name' => 'Security Baseline Gamma', + '@odata.type' => '#microsoft.graph.deviceManagementConfigurationPolicy', + 'technologies' => ['mdm'], + 'templateReference' => [ + 'templateFamily' => 'securityBaseline', + ], + ], + ], + ); + + $calledTypes = []; + + mock(GraphClientInterface::class) + ->shouldReceive('listPolicies') + ->times(3) + ->andReturnUsing(function (string $policyType) use (&$calledTypes, $graphResponse) { + $calledTypes[] = $policyType; + + return $graphResponse; + }); + + $service = app(PolicySyncService::class); + + $service->syncPolicies($tenant, [ + ['type' => 'settingsCatalogPolicy', 'platform' => 'windows'], + ['type' => 'endpointSecurityPolicy', 'platform' => 'windows'], + ['type' => 'securityBaselinePolicy', 'platform' => 'windows'], + ]); + + expect($calledTypes)->toMatchArray([ + 'settingsCatalogPolicy', + 'endpointSecurityPolicy', + 'securityBaselinePolicy', + ]); + + expect(Policy::query()->where('tenant_id', $tenant->id)->where('policy_type', 'settingsCatalogPolicy')->count()) + ->toBe(1); + + expect(Policy::query()->where('tenant_id', $tenant->id)->where('policy_type', 'endpointSecurityPolicy')->count()) + ->toBe(1); + + expect(Policy::query()->where('tenant_id', $tenant->id)->where('policy_type', 'securityBaselinePolicy')->count()) + ->toBe(1); +}); + +it('reclassifies configuration policies when canonical type changes', function () { + $tenant = Tenant::factory()->create([ + 'status' => 'active', + ]); + + $policy = Policy::factory()->create([ + 'tenant_id' => $tenant->id, + 'external_id' => 'esp-1', + 'policy_type' => 'settingsCatalogPolicy', + 'platform' => 'windows', + 'display_name' => 'Misclassified', + 'ignored_at' => null, + ]); + + $version = PolicyVersion::factory()->create([ + 'tenant_id' => $tenant->id, + 'policy_id' => $policy->id, + 'policy_type' => 'settingsCatalogPolicy', + 'platform' => 'windows', + ]); + + $logger = mock(GraphLogger::class); + + $logger->shouldReceive('logRequest') + ->zeroOrMoreTimes() + ->andReturnNull(); + + $logger->shouldReceive('logResponse') + ->zeroOrMoreTimes() + ->andReturnNull(); + + $graphResponse = new GraphResponse( + success: true, + data: [ + [ + 'id' => 'esp-1', + 'name' => 'Endpoint Security Beta', + '@odata.type' => '#microsoft.graph.deviceManagementConfigurationPolicy', + 'technologies' => 'mdm', + 'templateReference' => [ + 'templateFamily' => 'endpointSecurityDiskEncryption', + 'templateDisplayName' => 'BitLocker', + ], + ], + ], + ); + + mock(GraphClientInterface::class) + ->shouldReceive('listPolicies') + ->times(3) + ->andReturn($graphResponse); + + $service = app(PolicySyncService::class); + + $service->syncPolicies($tenant, [ + ['type' => 'settingsCatalogPolicy', 'platform' => 'windows'], + ['type' => 'endpointSecurityPolicy', 'platform' => 'windows'], + ['type' => 'securityBaselinePolicy', 'platform' => 'windows'], + ]); + + expect(Policy::query() + ->where('tenant_id', $tenant->id) + ->where('external_id', 'esp-1') + ->whereNull('ignored_at') + ->count())->toBe(1); + + expect(Policy::query() + ->where('tenant_id', $tenant->id) + ->where('external_id', 'esp-1') + ->where('policy_type', 'endpointSecurityPolicy') + ->whereNull('ignored_at') + ->count())->toBe(1); + + expect(Policy::query() + ->where('tenant_id', $tenant->id) + ->where('external_id', 'esp-1') + ->where('policy_type', 'settingsCatalogPolicy') + ->whereNull('ignored_at') + ->count())->toBe(0); + + $version->refresh(); + + expect($version->policy_type)->toBe('endpointSecurityPolicy'); +}); diff --git a/tests/Feature/PolicyTypes017Test.php b/tests/Feature/PolicyTypes017Test.php new file mode 100644 index 0000000..2d475d8 --- /dev/null +++ b/tests/Feature/PolicyTypes017Test.php @@ -0,0 +1,267 @@ +}> */ + public array $requests = []; + + public function listPolicies(string $policyType, array $options = []): GraphResponse + { + $this->requests[] = ['method' => 'listPolicies', 'policyType' => $policyType, 'options' => $options]; + + return new GraphResponse(success: true, data: []); + } + + public function getPolicy(string $policyType, string $policyId, array $options = []): GraphResponse + { + $this->requests[] = ['method' => 'getPolicy', 'policyType' => $policyType, 'policyId' => $policyId, 'options' => $options]; + + $payload = match ($policyType) { + 'mamAppConfiguration' => [ + 'id' => $policyId, + 'displayName' => 'MAM App Config', + '@odata.type' => '#microsoft.graph.targetedManagedAppConfiguration', + 'roleScopeTagIds' => ['0'], + ], + 'endpointSecurityPolicy' => [ + 'id' => $policyId, + 'name' => 'Endpoint Security Policy', + '@odata.type' => '#microsoft.graph.deviceManagementConfigurationPolicy', + 'technologies' => ['endpointSecurity'], + 'roleScopeTagIds' => ['0'], + ], + 'securityBaselinePolicy' => [ + 'id' => $policyId, + 'name' => 'Security Baseline Policy', + '@odata.type' => '#microsoft.graph.deviceManagementConfigurationPolicy', + 'templateReference' => ['templateFamily' => 'securityBaseline'], + 'roleScopeTagIds' => ['0'], + ], + default => [ + 'id' => $policyId, + 'name' => 'Settings Catalog Policy', + '@odata.type' => '#microsoft.graph.deviceManagementConfigurationPolicy', + 'technologies' => ['mdm'], + 'roleScopeTagIds' => ['0'], + ], + }; + + return new GraphResponse(success: true, data: ['payload' => $payload]); + } + + public function getOrganization(array $options = []): GraphResponse + { + $this->requests[] = ['method' => 'getOrganization', 'options' => $options]; + + return new GraphResponse(success: true, data: []); + } + + public function applyPolicy(string $policyType, string $policyId, array $payload, array $options = []): GraphResponse + { + $this->requests[] = ['method' => 'applyPolicy', 'policyType' => $policyType, 'policyId' => $policyId, 'options' => $options]; + + return new GraphResponse(success: true, data: []); + } + + public function getServicePrincipalPermissions(array $options = []): GraphResponse + { + $this->requests[] = ['method' => 'getServicePrincipalPermissions', 'options' => $options]; + + return new GraphResponse(success: true, data: []); + } + + public function request(string $method, string $path, array $options = []): GraphResponse + { + $this->requests[] = ['method' => 'request', 'path' => $path, 'options' => $options]; + + return new GraphResponse(success: true, data: []); + } +} + +it('creates backup items for the new 017 policy types', function () { + $tenant = Tenant::factory()->create(); + $tenant->makeCurrent(); + + $user = User::factory()->create(); + $this->actingAs($user); + + $mam = Policy::factory()->create([ + 'tenant_id' => $tenant->id, + 'external_id' => 'mam-1', + 'policy_type' => 'mamAppConfiguration', + 'platform' => 'mobile', + ]); + + $esp = Policy::factory()->create([ + 'tenant_id' => $tenant->id, + 'external_id' => 'esp-1', + 'policy_type' => 'endpointSecurityPolicy', + 'platform' => 'windows', + ]); + + $sb = Policy::factory()->create([ + 'tenant_id' => $tenant->id, + 'external_id' => 'sb-1', + 'policy_type' => 'securityBaselinePolicy', + 'platform' => 'windows', + ]); + + $this->mock(PolicyCaptureOrchestrator::class, function (MockInterface $mock) use ($tenant) { + $mock->shouldReceive('capture') + ->times(3) + ->andReturnUsing(function (Policy $policy) use ($tenant) { + $snapshot = match ($policy->policy_type) { + 'mamAppConfiguration' => [ + 'id' => $policy->external_id, + 'displayName' => 'MAM App Config', + '@odata.type' => '#microsoft.graph.targetedManagedAppConfiguration', + 'roleScopeTagIds' => ['0'], + ], + 'endpointSecurityPolicy' => [ + 'id' => $policy->external_id, + 'name' => 'Endpoint Security Policy', + '@odata.type' => '#microsoft.graph.deviceManagementConfigurationPolicy', + 'technologies' => ['endpointSecurity'], + 'roleScopeTagIds' => ['0'], + ], + 'securityBaselinePolicy' => [ + 'id' => $policy->external_id, + 'name' => 'Security Baseline Policy', + '@odata.type' => '#microsoft.graph.deviceManagementConfigurationPolicy', + 'templateReference' => ['templateFamily' => 'securityBaseline'], + 'roleScopeTagIds' => ['0'], + ], + default => [ + 'id' => $policy->external_id, + 'name' => 'Settings Catalog Policy', + '@odata.type' => '#microsoft.graph.deviceManagementConfigurationPolicy', + 'technologies' => ['mdm'], + 'roleScopeTagIds' => ['0'], + ], + }; + + $version = PolicyVersion::factory()->create([ + 'tenant_id' => $tenant->id, + 'policy_id' => $policy->id, + 'snapshot' => $snapshot, + 'assignments' => null, + 'scope_tags' => null, + ]); + + return [ + 'version' => $version, + 'captured' => [ + 'payload' => $snapshot, + 'assignments' => null, + 'scope_tags' => null, + 'metadata' => [], + 'warnings' => [], + ], + ]; + }); + }); + + $service = app(BackupService::class); + $backupSet = $service->createBackupSet( + tenant: $tenant, + policyIds: [$mam->id, $esp->id, $sb->id], + actorEmail: $user->email, + actorName: $user->name, + name: '017 backup', + includeAssignments: false, + includeScopeTags: false, + includeFoundations: false, + ); + + expect($backupSet->items)->toHaveCount(3); + + $types = $backupSet->items->pluck('policy_type')->all(); + sort($types); + + expect($types)->toBe([ + 'endpointSecurityPolicy', + 'mamAppConfiguration', + 'securityBaselinePolicy', + ]); + + expect(BackupItem::query()->where('backup_set_id', $backupSet->id)->count()) + ->toBe(3); +}); + +it('uses configured restore modes in preview for the new 017 policy types', function () { + $this->mock(GraphClientInterface::class); + + $tenant = Tenant::factory()->create(); + + $backupSet = BackupSet::factory()->create([ + 'tenant_id' => $tenant->id, + 'status' => 'completed', + 'item_count' => 3, + ]); + + BackupItem::factory()->create([ + 'tenant_id' => $tenant->id, + 'backup_set_id' => $backupSet->id, + 'policy_id' => null, + 'policy_identifier' => 'mam-1', + 'policy_type' => 'mamAppConfiguration', + 'platform' => 'mobile', + 'payload' => [ + 'id' => 'mam-1', + '@odata.type' => '#microsoft.graph.targetedManagedAppConfiguration', + ], + ]); + + BackupItem::factory()->create([ + 'tenant_id' => $tenant->id, + 'backup_set_id' => $backupSet->id, + 'policy_id' => null, + 'policy_identifier' => 'esp-1', + 'policy_type' => 'endpointSecurityPolicy', + 'platform' => 'windows', + 'payload' => [ + 'id' => 'esp-1', + '@odata.type' => '#microsoft.graph.deviceManagementConfigurationPolicy', + 'technologies' => ['endpointSecurity'], + ], + ]); + + BackupItem::factory()->create([ + 'tenant_id' => $tenant->id, + 'backup_set_id' => $backupSet->id, + 'policy_id' => null, + 'policy_identifier' => 'sb-1', + 'policy_type' => 'securityBaselinePolicy', + 'platform' => 'windows', + 'payload' => [ + 'id' => 'sb-1', + '@odata.type' => '#microsoft.graph.deviceManagementConfigurationPolicy', + 'templateReference' => ['templateFamily' => 'securityBaseline'], + ], + ]); + + $service = app(RestoreService::class); + $preview = $service->preview($tenant, $backupSet); + + $byType = collect($preview)->keyBy('policy_type'); + + expect($byType['mamAppConfiguration']['restore_mode'])->toBe('enabled'); + expect($byType['endpointSecurityPolicy']['restore_mode'])->toBe('enabled'); + expect($byType['securityBaselinePolicy']['restore_mode'])->toBe('preview-only'); +}); diff --git a/tests/Feature/PolicyVersionViewAssignmentsTest.php b/tests/Feature/PolicyVersionViewAssignmentsTest.php index ff174ad..5b6cdad 100644 --- a/tests/Feature/PolicyVersionViewAssignmentsTest.php +++ b/tests/Feature/PolicyVersionViewAssignmentsTest.php @@ -10,11 +10,13 @@ unset($_ENV['INTUNE_TENANT_ID'], $_SERVER['INTUNE_TENANT_ID']); $this->tenant = Tenant::factory()->create(); - $this->tenant->makeCurrent(); $this->policy = Policy::factory()->create([ 'tenant_id' => $this->tenant->id, ]); $this->user = User::factory()->create(); + $this->user->tenants()->syncWithoutDetaching([ + $this->tenant->getKey() => ['role' => 'owner'], + ]); }); it('displays policy version page', function () { @@ -26,7 +28,10 @@ $this->actingAs($this->user); - $response = $this->get("/admin/policy-versions/{$version->id}"); + $response = $this->get(route('filament.admin.resources.policy-versions.view', array_merge( + filamentTenantRouteParams($this->tenant), + ['record' => $version], + ))); $response->assertOk(); }); @@ -67,7 +72,10 @@ $this->actingAs($this->user); - $response = $this->get("/admin/policy-versions/{$version->id}"); + $response = $this->get(route('filament.admin.resources.policy-versions.view', array_merge( + filamentTenantRouteParams($this->tenant), + ['record' => $version], + ))); $response->assertOk(); $response->assertSeeLivewire('policy-version-assignments-widget'); @@ -87,7 +95,10 @@ $this->actingAs($this->user); - $response = $this->get("/admin/policy-versions/{$version->id}"); + $response = $this->get(route('filament.admin.resources.policy-versions.view', array_merge( + filamentTenantRouteParams($this->tenant), + ['record' => $version], + ))); $response->assertOk(); $response->assertSee('Assignments were not captured for this version'); @@ -107,7 +118,10 @@ $this->actingAs($this->user); - $response = $this->get("/admin/policy-versions/{$version->id}"); + $response = $this->get(route('filament.admin.resources.policy-versions.view', array_merge( + filamentTenantRouteParams($this->tenant), + ['record' => $version], + ))); $response->assertOk(); $response->assertSee('No assignments found for this version'); @@ -137,7 +151,10 @@ $this->actingAs($this->user); - $response = $this->get("/admin/policy-versions/{$version->id}"); + $response = $this->get(route('filament.admin.resources.policy-versions.view', array_merge( + filamentTenantRouteParams($this->tenant), + ['record' => $version], + ))); $response->assertOk(); $response->assertSee('Compliance notifications'); @@ -169,7 +186,10 @@ $this->actingAs($this->user); - $response = $this->get("/admin/policy-versions/{$version->id}"); + $response = $this->get(route('filament.admin.resources.policy-versions.view', array_merge( + filamentTenantRouteParams($this->tenant), + ['record' => $version], + ))); $response->assertOk(); $response->assertSee('Compliance notifications'); @@ -192,7 +212,10 @@ $this->actingAs($this->user); - $response = $this->get("/admin/policy-versions/{$version->id}?tab=normalized-settings"); + $response = $this->get(route('filament.admin.resources.policy-versions.view', array_merge( + filamentTenantRouteParams($this->tenant), + ['record' => $version], + )).'?tab=normalized-settings'); $response->assertOk(); $response->assertSee('Password & Access'); diff --git a/tests/Feature/ReclassifyEnrollmentConfigurationsCommandTest.php b/tests/Feature/ReclassifyEnrollmentConfigurationsCommandTest.php new file mode 100644 index 0000000..5186cdb --- /dev/null +++ b/tests/Feature/ReclassifyEnrollmentConfigurationsCommandTest.php @@ -0,0 +1,106 @@ + 'tenant-reclassify', + 'name' => 'Tenant Reclassify', + 'metadata' => [], + 'is_current' => true, + ]); + + $tenant->makeCurrent(); + + $policy = Policy::create([ + 'tenant_id' => $tenant->id, + 'external_id' => 'esp-1', + 'policy_type' => 'enrollmentRestriction', + 'display_name' => 'ESP Misclassified', + 'platform' => 'all', + ]); + + $version = PolicyVersion::create([ + 'tenant_id' => $tenant->id, + 'policy_id' => $policy->id, + 'version_number' => 1, + 'policy_type' => 'enrollmentRestriction', + 'platform' => 'all', + 'created_by' => 'tester@example.com', + 'captured_at' => CarbonImmutable::now(), + 'snapshot' => [ + '@odata.type' => '#microsoft.graph.windows10EnrollmentCompletionPageConfiguration', + 'deviceEnrollmentConfigurationType' => 'windows10EnrollmentCompletionPageConfiguration', + 'displayName' => 'ESP Misclassified', + ], + ]); + + $this->artisan('intune:reclassify-enrollment-configurations', ['--tenant' => $tenant->tenant_id]) + ->assertSuccessful(); + + $version->refresh(); + $policy->refresh(); + + expect($version->policy_type)->toBe('enrollmentRestriction'); + expect($policy->policy_type)->toBe('enrollmentRestriction'); + + $this->artisan('intune:reclassify-enrollment-configurations', ['--tenant' => $tenant->tenant_id, '--write' => true]) + ->assertSuccessful(); + + $version->refresh(); + $policy->refresh(); + + expect($version->policy_type)->toBe('windowsEnrollmentStatusPage'); + expect($policy->policy_type)->toBe('windowsEnrollmentStatusPage'); +}); + +test('reclassify command can detect ESP even when a policy has no versions', function () { + $tenant = Tenant::create([ + 'tenant_id' => 'tenant-reclassify-no-versions', + 'name' => 'Tenant Reclassify (No Versions)', + 'metadata' => [], + 'is_current' => true, + ]); + + $tenant->makeCurrent(); + + $policy = Policy::create([ + 'tenant_id' => $tenant->id, + 'external_id' => 'esp-2', + 'policy_type' => 'enrollmentRestriction', + 'display_name' => 'ESP Misclassified (No Versions)', + 'platform' => 'all', + ]); + + $this->mock(GraphClientInterface::class, function (MockInterface $mock) { + $mock->shouldReceive('getPolicy') + ->andReturn(new GraphResponse(true, [ + 'payload' => [ + '@odata.type' => '#microsoft.graph.windows10EnrollmentCompletionPageConfiguration', + 'deviceEnrollmentConfigurationType' => 'windows10EnrollmentCompletionPageConfiguration', + 'displayName' => 'ESP Misclassified (No Versions)', + ], + ])); + }); + + $this->artisan('intune:reclassify-enrollment-configurations', ['--tenant' => $tenant->tenant_id]) + ->assertSuccessful(); + + $policy->refresh(); + expect($policy->policy_type)->toBe('enrollmentRestriction'); + + $this->artisan('intune:reclassify-enrollment-configurations', ['--tenant' => $tenant->tenant_id, '--write' => true]) + ->assertSuccessful(); + + $policy->refresh(); + expect($policy->policy_type)->toBe('windowsEnrollmentStatusPage'); +}); diff --git a/tests/Feature/RestoreGraphErrorMetadataTest.php b/tests/Feature/RestoreGraphErrorMetadataTest.php new file mode 100644 index 0000000..0222ca2 --- /dev/null +++ b/tests/Feature/RestoreGraphErrorMetadataTest.php @@ -0,0 +1,102 @@ +}> */ + public array $applyPolicyCalls = []; + + 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, ['payload' => []]); + } + + public function getOrganization(array $options = []): GraphResponse + { + return new GraphResponse(true, []); + } + + public function applyPolicy(string $policyType, string $policyId, array $payload, array $options = []): GraphResponse + { + $this->applyPolicyCalls[] = [ + 'policyType' => $policyType, + 'policyId' => $policyId, + 'payload' => $payload, + 'options' => $options, + ]; + + return new GraphResponse(false, ['error' => ['message' => 'Bad request']], 400, [], [], [ + 'error_code' => 'BadRequest', + 'error_message' => "Resource not found for the segment 'endpointSecurityPolicy'.", + 'request_id' => 'req-1', + 'client_request_id' => 'client-1', + 'method' => 'PATCH', + 'path' => 'deviceManagement/endpointSecurityPolicy/esp-1', + ]); + } + + public function getServicePrincipalPermissions(array $options = []): GraphResponse + { + return new GraphResponse(true, []); + } + + public function request(string $method, string $path, array $options = []): GraphResponse + { + return new GraphResponse(true, []); + } +} + +test('restore results include graph path and method on Graph failures', function () { + $client = new RestoreGraphErrorMetadataGraphClient; + app()->instance(GraphClientInterface::class, $client); + + $tenant = Tenant::factory()->create(); + $backupSet = BackupSet::factory()->for($tenant)->create([ + 'status' => 'completed', + 'item_count' => 1, + ]); + + $backupItem = BackupItem::factory()->for($tenant)->for($backupSet)->create([ + 'policy_id' => null, + 'policy_identifier' => 'esp-1', + 'policy_type' => 'endpointSecurityPolicy', + 'platform' => 'windows', + 'payload' => [ + 'id' => 'esp-1', + '@odata.type' => '#microsoft.graph.deviceManagementConfigurationPolicy', + 'name' => 'Endpoint Security Policy', + 'settings' => [], + ], + 'assignments' => null, + ]); + + $service = app(RestoreService::class); + $run = $service->execute( + tenant: $tenant, + backupSet: $backupSet, + selectedItemIds: [$backupItem->id], + dryRun: false, + ); + + expect($client->applyPolicyCalls)->toHaveCount(1); + expect($run->status)->toBe('failed'); + + $result = $run->results[0] ?? null; + expect($result)->toBeArray(); + expect($result['graph_method'] ?? null)->toBe('PATCH'); + expect($result['graph_path'] ?? null)->toBe('deviceManagement/endpointSecurityPolicy/esp-1'); +}); diff --git a/tests/Feature/RestoreGroupMappingTest.php b/tests/Feature/RestoreGroupMappingTest.php index 5746b5b..1ddd818 100644 --- a/tests/Feature/RestoreGroupMappingTest.php +++ b/tests/Feature/RestoreGroupMappingTest.php @@ -8,6 +8,7 @@ use App\Models\Tenant; use App\Models\User; use App\Services\Graph\GroupResolver; +use Filament\Facades\Filament; use Illuminate\Foundation\Testing\RefreshDatabase; use Livewire\Livewire; use Mockery\MockInterface; @@ -76,6 +77,11 @@ $user = User::factory()->create(); $this->actingAs($user); + $user->tenants()->syncWithoutDetaching([ + $tenant->getKey() => ['role' => 'owner'], + ]); + + Filament::setTenant($tenant, true); $component = Livewire::test(CreateRestoreRun::class) ->fillForm([ @@ -157,6 +163,11 @@ $user = User::factory()->create(); $this->actingAs($user); + $user->tenants()->syncWithoutDetaching([ + $tenant->getKey() => ['role' => 'owner'], + ]); + + Filament::setTenant($tenant, true); Livewire::test(CreateRestoreRun::class) ->fillForm([ diff --git a/tests/Feature/RestorePreviewDiffWizardTest.php b/tests/Feature/RestorePreviewDiffWizardTest.php index ab62af1..90c0caa 100644 --- a/tests/Feature/RestorePreviewDiffWizardTest.php +++ b/tests/Feature/RestorePreviewDiffWizardTest.php @@ -8,6 +8,7 @@ use App\Models\RestoreRun; use App\Models\Tenant; use App\Models\User; +use Filament\Facades\Filament; use Illuminate\Foundation\Testing\RefreshDatabase; use Livewire\Livewire; @@ -86,6 +87,11 @@ $user = User::factory()->create(); $this->actingAs($user); + $user->tenants()->syncWithoutDetaching([ + $tenant->getKey() => ['role' => 'owner'], + ]); + + Filament::setTenant($tenant, true); $component = Livewire::test(CreateRestoreRun::class) ->fillForm([ diff --git a/tests/Feature/RestoreRiskChecksWizardTest.php b/tests/Feature/RestoreRiskChecksWizardTest.php index c878d84..32a88f0 100644 --- a/tests/Feature/RestoreRiskChecksWizardTest.php +++ b/tests/Feature/RestoreRiskChecksWizardTest.php @@ -8,6 +8,7 @@ use App\Models\Tenant; use App\Models\User; use App\Services\Graph\GroupResolver; +use Filament\Facades\Filament; use Illuminate\Foundation\Testing\RefreshDatabase; use Livewire\Livewire; use Mockery\MockInterface; @@ -77,6 +78,11 @@ $user = User::factory()->create(); $this->actingAs($user); + $user->tenants()->syncWithoutDetaching([ + $tenant->getKey() => ['role' => 'owner'], + ]); + + Filament::setTenant($tenant, true); $component = Livewire::test(CreateRestoreRun::class) ->fillForm([ @@ -188,6 +194,11 @@ $user = User::factory()->create(); $this->actingAs($user); + $user->tenants()->syncWithoutDetaching([ + $tenant->getKey() => ['role' => 'owner'], + ]); + + Filament::setTenant($tenant, true); $component = Livewire::test(CreateRestoreRun::class) ->fillForm([ @@ -220,3 +231,81 @@ expect($skippedGroups)->toBeArray(); expect($skippedGroups[0]['id'] ?? null)->toBe('source-group-1'); }); + +test('restore wizard flags metadata-only snapshots as blocking for restore-enabled types', function () { + $tenant = Tenant::create([ + 'tenant_id' => 'tenant-1', + 'name' => 'Tenant One', + 'metadata' => [], + ]); + + $tenant->makeCurrent(); + + $policy = Policy::create([ + 'tenant_id' => $tenant->id, + 'external_id' => 'policy-1', + 'policy_type' => 'mamAppConfiguration', + 'display_name' => 'MAM App Config', + 'platform' => 'mobile', + ]); + + $backupSet = BackupSet::create([ + 'tenant_id' => $tenant->id, + 'name' => 'Backup', + 'status' => 'completed', + 'item_count' => 1, + ]); + + $backupItem = BackupItem::create([ + 'tenant_id' => $tenant->id, + 'backup_set_id' => $backupSet->id, + 'policy_id' => $policy->id, + 'policy_identifier' => $policy->external_id, + 'policy_type' => $policy->policy_type, + 'platform' => $policy->platform, + 'captured_at' => now(), + 'payload' => ['id' => $policy->external_id, 'displayName' => $policy->display_name], + 'assignments' => [], + 'metadata' => [ + 'source' => 'metadata_only', + 'warnings' => [ + 'Graph returned 500 for this policy type. Only local metadata was saved; settings and restore are unavailable until Graph works again.', + ], + ], + ]); + + $this->mock(GroupResolver::class, function (MockInterface $mock) { + $mock->shouldReceive('resolveGroupIds') + ->andReturn([]); + }); + + $user = User::factory()->create(); + $this->actingAs($user); + $user->tenants()->syncWithoutDetaching([ + $tenant->getKey() => ['role' => 'owner'], + ]); + + Filament::setTenant($tenant, true); + + $component = Livewire::test(CreateRestoreRun::class) + ->fillForm([ + 'backup_set_id' => $backupSet->id, + ]) + ->goToNextWizardStep() + ->fillForm([ + 'scope_mode' => 'selected', + 'backup_item_ids' => [$backupItem->id], + ]) + ->goToNextWizardStep() + ->callFormComponentAction('check_results', 'run_restore_checks'); + + $summary = $component->get('data.check_summary'); + $results = $component->get('data.check_results'); + + expect($summary['blocking'] ?? null)->toBe(1); + expect($summary['has_blockers'] ?? null)->toBeTrue(); + + $metadataOnly = collect($results)->firstWhere('code', 'metadata_only'); + expect($metadataOnly)->toBeArray(); + expect($metadataOnly['severity'] ?? null)->toBe('blocking'); +}); diff --git a/tests/Feature/RestoreRunArchiveGuardTest.php b/tests/Feature/RestoreRunArchiveGuardTest.php index 50c7c57..0520342 100644 --- a/tests/Feature/RestoreRunArchiveGuardTest.php +++ b/tests/Feature/RestoreRunArchiveGuardTest.php @@ -5,6 +5,7 @@ use App\Models\RestoreRun; use App\Models\Tenant; use App\Models\User; +use Filament\Facades\Filament; use Illuminate\Foundation\Testing\RefreshDatabase; use Livewire\Livewire; @@ -28,6 +29,11 @@ ]); $user = User::factory()->create(); + $user->tenants()->syncWithoutDetaching([ + $tenant->getKey() => ['role' => 'owner'], + ]); + + Filament::setTenant($tenant, true); Livewire::actingAs($user) ->test(ListRestoreRuns::class) diff --git a/tests/Feature/RestoreRunRerunTest.php b/tests/Feature/RestoreRunRerunTest.php index a6014ef..924a03a 100644 --- a/tests/Feature/RestoreRunRerunTest.php +++ b/tests/Feature/RestoreRunRerunTest.php @@ -6,6 +6,7 @@ use App\Models\RestoreRun; use App\Models\Tenant; use App\Models\User; +use Filament\Facades\Filament; use Illuminate\Foundation\Testing\RefreshDatabase; use Livewire\Livewire; @@ -13,7 +14,6 @@ test('rerun action creates a new restore run with the same selections', function () { $tenant = Tenant::factory()->create(); - $tenant->makeCurrent(); $backupSet = BackupSet::factory()->for($tenant)->create([ 'status' => 'completed', @@ -47,6 +47,11 @@ ]); $user = User::factory()->create(['email' => 'tester@example.com']); + $user->tenants()->syncWithoutDetaching([ + $tenant->getKey() => ['role' => 'owner'], + ]); + + Filament::setTenant($tenant, true); Livewire::actingAs($user) ->test(ListRestoreRuns::class) diff --git a/tests/Feature/RestoreRunWizardExecuteTest.php b/tests/Feature/RestoreRunWizardExecuteTest.php index 4332917..8528c5c 100644 --- a/tests/Feature/RestoreRunWizardExecuteTest.php +++ b/tests/Feature/RestoreRunWizardExecuteTest.php @@ -9,6 +9,7 @@ use App\Models\Tenant; use App\Models\User; use App\Support\RestoreRunStatus; +use Filament\Facades\Filament; use Illuminate\Foundation\Testing\RefreshDatabase; use Illuminate\Support\Facades\Bus; use Livewire\Livewire; @@ -62,6 +63,11 @@ 'name' => 'Tester', ]); $this->actingAs($user); + $user->tenants()->syncWithoutDetaching([ + $tenant->getKey() => ['role' => 'owner'], + ]); + + Filament::setTenant($tenant, true); Livewire::test(CreateRestoreRun::class) ->fillForm([ @@ -130,6 +136,11 @@ 'name' => 'Executor', ]); $this->actingAs($user); + $user->tenants()->syncWithoutDetaching([ + $tenant->getKey() => ['role' => 'owner'], + ]); + + Filament::setTenant($tenant, true); Livewire::test(CreateRestoreRun::class) ->fillForm([ diff --git a/tests/Feature/RestoreRunWizardMetadataTest.php b/tests/Feature/RestoreRunWizardMetadataTest.php index 10c9697..9d29991 100644 --- a/tests/Feature/RestoreRunWizardMetadataTest.php +++ b/tests/Feature/RestoreRunWizardMetadataTest.php @@ -6,6 +6,7 @@ use App\Models\RestoreRun; use App\Models\Tenant; use App\Models\User; +use Filament\Facades\Filament; use Illuminate\Foundation\Testing\RefreshDatabase; use Livewire\Livewire; @@ -50,6 +51,11 @@ 'name' => 'Tester', ]); $this->actingAs($user); + $user->tenants()->syncWithoutDetaching([ + $tenant->getKey() => ['role' => 'owner'], + ]); + + Filament::setTenant($tenant, true); Livewire::test(CreateRestoreRun::class) ->fillForm([ diff --git a/tests/Feature/RestoreUnknownPolicyTypeSafetyTest.php b/tests/Feature/RestoreUnknownPolicyTypeSafetyTest.php new file mode 100644 index 0000000..f66f7c6 --- /dev/null +++ b/tests/Feature/RestoreUnknownPolicyTypeSafetyTest.php @@ -0,0 +1,117 @@ +}> */ + public array $applyPolicyCalls = []; + + 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, ['payload' => []]); + } + + public function getOrganization(array $options = []): GraphResponse + { + return new GraphResponse(true, []); + } + + public function applyPolicy(string $policyType, string $policyId, array $payload, array $options = []): GraphResponse + { + $this->applyPolicyCalls[] = [ + 'policyType' => $policyType, + 'policyId' => $policyId, + 'payload' => $payload, + 'options' => $options, + ]; + + return new GraphResponse(false, ['error' => ['message' => 'Bad request']], 400, [], [], [ + 'error_code' => 'BadRequest', + 'error_message' => 'Bad request', + ]); + } + + public function getServicePrincipalPermissions(array $options = []): GraphResponse + { + return new GraphResponse(true, []); + } + + public function request(string $method, string $path, array $options = []): GraphResponse + { + return new GraphResponse(true, []); + } +} + +beforeEach(function () { + $this->originalSupportedTypes = config('tenantpilot.supported_policy_types'); + $this->originalSecurityBaselineContract = config('graph_contracts.types.securityBaselinePolicy'); +}); + +afterEach(function () { + config()->set('tenantpilot.supported_policy_types', $this->originalSupportedTypes); + + if (is_array($this->originalSecurityBaselineContract)) { + config()->set('graph_contracts.types.securityBaselinePolicy', $this->originalSecurityBaselineContract); + } +}); + +test('restore skips security baseline policies when type metadata is missing', function () { + $client = new RestoreUnknownTypeGraphClient; + app()->instance(GraphClientInterface::class, $client); + + $supported = array_values(array_filter( + config('tenantpilot.supported_policy_types', []), + static fn (array $type): bool => ($type['type'] ?? null) !== 'securityBaselinePolicy' + )); + + config()->set('tenantpilot.supported_policy_types', $supported); + config()->set('graph_contracts.types.securityBaselinePolicy', []); + + $tenant = Tenant::factory()->create(); + $backupSet = BackupSet::factory()->for($tenant)->create([ + 'status' => 'completed', + 'item_count' => 1, + ]); + + $backupItem = BackupItem::factory()->for($tenant)->for($backupSet)->create([ + 'policy_id' => null, + 'policy_identifier' => 'baseline-1', + 'policy_type' => 'securityBaselinePolicy', + 'platform' => 'windows', + 'payload' => [ + 'id' => 'baseline-1', + '@odata.type' => '#microsoft.graph.deviceManagementConfigurationPolicy', + 'name' => 'Security Baseline Policy', + ], + 'assignments' => null, + ]); + + $service = app(RestoreService::class); + $run = $service->execute( + tenant: $tenant, + backupSet: $backupSet, + selectedItemIds: [$backupItem->id], + dryRun: false, + ); + + expect($client->applyPolicyCalls)->toHaveCount(0); + + $result = $run->results[0] ?? null; + expect($result)->toBeArray(); + expect($result['status'] ?? null)->toBe('skipped'); + expect($result['restore_mode'] ?? null)->toBe('preview-only'); +}); diff --git a/tests/Feature/TermsAndConditionsPolicyTypeTest.php b/tests/Feature/TermsAndConditionsPolicyTypeTest.php new file mode 100644 index 0000000..84cce93 --- /dev/null +++ b/tests/Feature/TermsAndConditionsPolicyTypeTest.php @@ -0,0 +1,218 @@ + + */ + public array $requestCalls = []; + + /** + * @param array $requestResponses + */ + public function __construct( + private readonly GraphResponse $applyPolicyResponse, + private array $requestResponses = [], + ) {} + + 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, ['payload' => []]); + } + + public function getOrganization(array $options = []): GraphResponse + { + return new GraphResponse(true, []); + } + + public function applyPolicy(string $policyType, string $policyId, array $payload, array $options = []): GraphResponse + { + return $this->applyPolicyResponse; + } + + public function getServicePrincipalPermissions(array $options = []): GraphResponse + { + return new GraphResponse(true, []); + } + + public function request(string $method, string $path, array $options = []): GraphResponse + { + $this->requestCalls[] = [ + 'method' => strtoupper($method), + 'path' => $path, + 'payload' => $options['json'] ?? null, + ]; + + return array_shift($this->requestResponses) ?? new GraphResponse(true, []); + } +} + +it('includes terms and conditions policy type in supported types', function () { + $byType = collect(config('tenantpilot.supported_policy_types', [])) + ->keyBy('type'); + + expect($byType)->toHaveKey('termsAndConditions'); + expect($byType['termsAndConditions']['endpoint'] ?? null)->toBe('deviceManagement/termsAndConditions'); +}); + +it('defines terms and conditions graph contract with assignments paths', function () { + $contract = config('graph_contracts.types.termsAndConditions'); + + expect($contract)->toBeArray(); + expect($contract['resource'] ?? null)->toBe('deviceManagement/termsAndConditions'); + expect($contract['assignments_list_path'] ?? null)->toBe('/deviceManagement/termsAndConditions/{id}/assignments'); + expect($contract['assignments_payload_key'] ?? null)->toBe('termsAndConditionsAssignments'); +}); + +it('restores terms and conditions assignments via assignments endpoint', function () { + $client = new TermsAndConditionsRestoreGraphClient( + applyPolicyResponse: new GraphResponse(true, []), + requestResponses: [ + new GraphResponse(true, ['value' => []]), // existing assignments list + new GraphResponse(true, []), // create assignments + ], + ); + app()->instance(GraphClientInterface::class, $client); + + $tenant = Tenant::factory()->create(['tenant_id' => 'tenant-1']); + $policy = Policy::factory()->create([ + 'tenant_id' => $tenant->id, + 'external_id' => 'tc-1', + 'policy_type' => 'termsAndConditions', + 'platform' => 'all', + ]); + + $backupSet = \App\Models\BackupSet::factory()->create([ + 'tenant_id' => $tenant->id, + 'status' => 'completed', + 'item_count' => 1, + ]); + + $backupItem = \App\Models\BackupItem::factory()->create([ + 'tenant_id' => $tenant->id, + 'backup_set_id' => $backupSet->id, + 'policy_id' => $policy->id, + 'policy_identifier' => $policy->external_id, + 'policy_type' => $policy->policy_type, + 'platform' => $policy->platform, + 'payload' => [ + 'id' => $policy->external_id, + '@odata.type' => '#microsoft.graph.termsAndConditions', + ], + 'assignments' => [ + [ + 'id' => 'assignment-1', + 'intent' => 'apply', + 'target' => [ + '@odata.type' => '#microsoft.graph.groupAssignmentTarget', + 'groupId' => 'source-group-1', + ], + ], + ], + ]); + + $user = User::factory()->create(['email' => 'tester@example.com']); + $this->actingAs($user); + + $service = app(RestoreService::class); + $service->execute( + tenant: $tenant, + backupSet: $backupSet, + selectedItemIds: [$backupItem->id], + dryRun: false, + actorEmail: $user->email, + actorName: $user->name, + groupMapping: [ + 'source-group-1' => 'target-group-1', + ], + ); + + $postCalls = collect($client->requestCalls) + ->filter(fn (array $call) => $call['method'] === 'POST') + ->values(); + + expect($postCalls)->toHaveCount(1); + expect($postCalls[0]['path'])->toBe('/deviceManagement/termsAndConditions/tc-1/assignments'); + + $payload = $postCalls[0]['payload'] ?? []; + expect($payload['target']['groupId'] ?? null)->toBe('target-group-1'); +}); + +it('normalizes terms and conditions key fields', function () { + $normalized = app(PolicyNormalizer::class)->normalize([ + '@odata.type' => '#microsoft.graph.termsAndConditions', + 'displayName' => 'Terms and Conditions Alpha', + 'title' => 'Alpha terms', + 'description' => 'Long form description', + 'acceptanceStatement' => 'I agree', + 'bodyText' => str_repeat('Line.', 100), + 'version' => 3, + 'roleScopeTagIds' => ['0', '1'], + ], 'termsAndConditions', 'all'); + + $entries = $normalized['settings'][0]['entries'] ?? []; + $byKey = collect($entries)->keyBy('key'); + + expect($byKey['Display name']['value'] ?? null)->toBe('Terms and Conditions Alpha'); + expect($byKey['Title']['value'] ?? null)->toBe('Alpha terms'); + expect($byKey['Acceptance statement']['value'] ?? null)->toBe('I agree'); + expect($byKey['Version']['value'] ?? null)->toBe(3); + expect($byKey['Scope tag IDs']['value'] ?? null)->toBe(['0', '1']); +}); + +it('syncs terms and conditions from graph', function () { + $tenant = Tenant::factory()->create(['status' => 'active']); + $logger = mock(GraphLogger::class); + + $logger->shouldReceive('logRequest') + ->zeroOrMoreTimes() + ->andReturnNull(); + + $logger->shouldReceive('logResponse') + ->zeroOrMoreTimes() + ->andReturnNull(); + + mock(GraphClientInterface::class) + ->shouldReceive('listPolicies') + ->once() + ->with('termsAndConditions', mockery::type('array')) + ->andReturn(new GraphResponse( + success: true, + data: [ + [ + 'id' => 'tc-1', + 'displayName' => 'T&C', + '@odata.type' => '#microsoft.graph.termsAndConditions', + ], + ], + )); + + $service = app(PolicySyncService::class); + + $service->syncPolicies($tenant, [ + ['type' => 'termsAndConditions', 'platform' => 'all'], + ]); + + expect(Policy::query()->where('tenant_id', $tenant->id)->where('policy_type', 'termsAndConditions')->count()) + ->toBe(1); +}); diff --git a/tests/Feature/VersionCaptureMetadataOnlyTest.php b/tests/Feature/VersionCaptureMetadataOnlyTest.php new file mode 100644 index 0000000..6560ab7 --- /dev/null +++ b/tests/Feature/VersionCaptureMetadataOnlyTest.php @@ -0,0 +1,66 @@ +create(); + $policy = Policy::factory()->for($tenant)->create([ + 'policy_type' => 'mamAppConfiguration', + 'platform' => 'mobile', + 'external_id' => 'A_meta_only', + 'display_name' => 'MAM Config Meta', + ]); + + $this->mock(PolicySnapshotService::class, function ($mock) { + $mock->shouldReceive('fetch') + ->once() + ->andReturn([ + 'payload' => [ + 'id' => 'A_meta_only', + 'displayName' => 'MAM Config Meta', + '@odata.type' => '#microsoft.graph.targetedManagedAppConfiguration', + ], + 'metadata' => [ + 'source' => 'metadata_only', + 'original_status' => 500, + 'original_failure' => 'InternalServerError: upstream', + ], + 'warnings' => [ + 'Snapshot captured from local metadata only (Graph API returned 500).', + ], + ]); + }); + + $this->mock(AssignmentFetcher::class, function ($mock) { + $mock->shouldReceive('fetch')->never(); + }); + + $this->mock(ScopeTagResolver::class, function ($mock) { + $mock->shouldReceive('resolve')->never(); + }); + + $service = app(VersionService::class); + + $version = $service->captureFromGraph( + tenant: $tenant, + policy: $policy, + createdBy: 'tester@example.test', + includeAssignments: false, + includeScopeTags: false, + ); + + expect($version->metadata['source'])->toBe('metadata_only'); + expect($version->metadata['original_status'])->toBe(500); + expect($version->metadata['original_failure'])->toContain('InternalServerError'); + expect($version->metadata['capture_source'])->toBe('version_capture'); + expect($version->metadata['warnings'])->toBeArray(); + expect($version->metadata['warnings'][0])->toContain('metadata only'); +}); diff --git a/tests/Feature/VersionCaptureWithAssignmentsTest.php b/tests/Feature/VersionCaptureWithAssignmentsTest.php index 2a1ebfb..2cc8455 100644 --- a/tests/Feature/VersionCaptureWithAssignmentsTest.php +++ b/tests/Feature/VersionCaptureWithAssignmentsTest.php @@ -98,6 +98,76 @@ expect($version->assignments[0]['target']['assignment_filter_name'])->toBe('Targeted Devices'); }); +it('captures enrollment limit configuration version with assignments from graph', function () { + $this->policy->forceFill([ + 'policy_type' => 'deviceEnrollmentLimitConfiguration', + 'platform' => 'all', + ])->save(); + + $this->mock(PolicySnapshotService::class, function ($mock) { + $mock->shouldReceive('fetch') + ->once() + ->andReturn([ + 'payload' => [ + '@odata.type' => '#microsoft.graph.deviceEnrollmentLimitConfiguration', + 'id' => 'test-policy-id', + 'displayName' => 'Enrollment Limit', + 'limit' => 5, + ], + ]); + }); + + $this->mock(AssignmentFetcher::class, function ($mock) { + $mock->shouldReceive('fetch') + ->once() + ->withArgs(function (string $policyType): bool { + return $policyType === 'deviceEnrollmentLimitConfiguration'; + }) + ->andReturn([ + [ + 'id' => 'assignment-1', + 'intent' => 'apply', + 'target' => [ + '@odata.type' => '#microsoft.graph.groupAssignmentTarget', + 'groupId' => 'group-123', + ], + ], + ]); + }); + + $this->mock(GroupResolver::class, function ($mock) { + $mock->shouldReceive('resolveGroupIds') + ->once() + ->andReturn([ + 'group-123' => [ + 'id' => 'group-123', + 'displayName' => 'Test Group', + 'orphaned' => false, + ], + ]); + }); + + $this->mock(AssignmentFilterResolver::class, function ($mock) { + $mock->shouldReceive('resolve') + ->once() + ->andReturn([]); + }); + + $versionService = app(VersionService::class); + $version = $versionService->captureFromGraph( + $this->tenant, + $this->policy, + 'test@example.com' + ); + + expect($version)->not->toBeNull() + ->and($version->policy_type)->toBe('deviceEnrollmentLimitConfiguration') + ->and($version->snapshot['@odata.type'] ?? null)->toBe('#microsoft.graph.deviceEnrollmentLimitConfiguration') + ->and($version->snapshot['limit'] ?? null)->toBe(5) + ->and($version->assignments)->toHaveCount(1) + ->and($version->metadata['assignments_count'])->toBe(1); +}); + it('hydrates assignment filter names when filter data is stored at root', function () { $this->mock(PolicySnapshotService::class, function ($mock) { $mock->shouldReceive('fetch') diff --git a/tests/Pest.php b/tests/Pest.php index 2e65b3a..dccc767 100644 --- a/tests/Pest.php +++ b/tests/Pest.php @@ -1,5 +1,7 @@ create(); + $tenant ??= Tenant::factory()->create(); + + $user->tenants()->syncWithoutDetaching([ + $tenant->getKey() => ['role' => $role], + ]); + + return [$user, $tenant]; +} + +/** + * @return array{tenant: string} + */ +function filamentTenantRouteParams(Tenant $tenant): array +{ + return ['tenant' => (string) $tenant->external_id]; +} diff --git a/tests/TestCase.php b/tests/TestCase.php index ee63ad0..808b72f 100644 --- a/tests/TestCase.php +++ b/tests/TestCase.php @@ -4,4 +4,13 @@ use Illuminate\Foundation\Testing\TestCase as BaseTestCase; -abstract class TestCase extends BaseTestCase {} +abstract class TestCase extends BaseTestCase +{ + protected function setUp(): void + { + parent::setUp(); + + putenv('INTUNE_TENANT_ID'); + unset($_ENV['INTUNE_TENANT_ID'], $_SERVER['INTUNE_TENANT_ID']); + } +} diff --git a/tests/Unit/AssignmentFetcherTest.php b/tests/Unit/AssignmentFetcherTest.php index ce174ee..b634752 100644 --- a/tests/Unit/AssignmentFetcherTest.php +++ b/tests/Unit/AssignmentFetcherTest.php @@ -75,15 +75,11 @@ expect($result)->toBe($assignments); }); -test('fallback on empty response', function () { +test('does not use fallback when primary succeeds with empty assignments', function () { $tenantId = 'tenant-123'; $policyId = 'policy-456'; $policyType = 'settingsCatalogPolicy'; - $assignments = [ - ['id' => 'assign-1', 'target' => ['@odata.type' => '#microsoft.graph.groupAssignmentTarget', 'groupId' => 'group-1']], - ]; - // Primary returns empty $primaryResponse = new GraphResponse( success: true, data: ['value' => []] @@ -97,7 +93,34 @@ ]) ->andReturn($primaryResponse); - // Fallback returns assignments + $result = $this->fetcher->fetch($policyType, $tenantId, $policyId); + + expect($result)->toBe([]); +}); + +test('uses fallback when primary endpoint fails', function () { + $tenantId = 'tenant-123'; + $policyId = 'policy-456'; + $policyType = 'settingsCatalogPolicy'; + $assignments = [ + ['id' => 'assign-1', 'target' => ['@odata.type' => '#microsoft.graph.groupAssignmentTarget', 'groupId' => 'group-1']], + ]; + + $primaryFailure = new GraphResponse( + success: false, + data: [], + status: 400, + errors: [['message' => 'Bad Request']] + ); + + $this->graphClient + ->shouldReceive('request') + ->once() + ->with('GET', "/deviceManagement/configurationPolicies/{$policyId}/assignments", [ + 'tenant' => $tenantId, + ]) + ->andReturn($primaryFailure); + $fallbackResponse = new GraphResponse( success: true, data: ['value' => [['id' => $policyId, 'assignments' => $assignments]]] @@ -152,18 +175,6 @@ ->with('GET', "/deviceManagement/configurationPolicies/{$policyId}/assignments", Mockery::any()) ->andReturn($primaryResponse); - // Fallback returns empty - $fallbackResponse = new GraphResponse( - success: true, - data: ['value' => []] - ); - - $this->graphClient - ->shouldReceive('request') - ->once() - ->with('GET', 'deviceManagement/configurationPolicies', Mockery::any()) - ->andReturn($fallbackResponse); - $result = $this->fetcher->fetch($policyType, $tenantId, $policyId); expect($result)->toBe([]); @@ -174,9 +185,8 @@ $policyId = 'policy-456'; $policyType = 'settingsCatalogPolicy'; - // Primary returns empty $primaryResponse = new GraphResponse( - success: true, + success: false, data: ['value' => []] ); diff --git a/tests/Unit/BackupScheduling/ScheduleTimeServiceTest.php b/tests/Unit/BackupScheduling/ScheduleTimeServiceTest.php new file mode 100644 index 0000000..e73b14c --- /dev/null +++ b/tests/Unit/BackupScheduling/ScheduleTimeServiceTest.php @@ -0,0 +1,45 @@ +forceFill([ + 'frequency' => 'daily', + 'timezone' => 'Europe/Berlin', + 'time_of_day' => '02:30:00', + 'days_of_week' => [], + ]); + + $service = app(ScheduleTimeService::class); + + // On 2026-03-29 in Europe/Berlin, the clock jumps from 02:00 to 03:00 (02:30 is nonexistent). + // Using an "after" cursor later than 02:30 on the previous day forces the candidate day to be 2026-03-29. + $after = CarbonImmutable::create(2026, 3, 28, 3, 0, 0, 'Europe/Berlin'); + + $next = $service->nextRunFor($schedule, $after); + + expect($next)->not->toBeNull(); + expect($next->timezone('UTC')->format('Y-m-d H:i:s'))->toBe('2026-03-30 00:30:00'); +}); + +it('returns null for weekly schedules without allowed days', function () { + $schedule = new BackupSchedule; + $schedule->forceFill([ + 'frequency' => 'weekly', + 'timezone' => 'UTC', + 'time_of_day' => '10:00:00', + 'days_of_week' => [], + ]); + + $service = app(ScheduleTimeService::class); + + $next = $service->nextRunFor($schedule, CarbonImmutable::create(2026, 1, 5, 0, 0, 0, 'UTC')); + + expect($next)->toBeNull(); +}); diff --git a/tests/Unit/BulkActionPermissionTest.php b/tests/Unit/BulkActionPermissionTest.php index a9da5b9..02e99b6 100644 --- a/tests/Unit/BulkActionPermissionTest.php +++ b/tests/Unit/BulkActionPermissionTest.php @@ -4,6 +4,7 @@ use App\Models\Policy; use App\Models\Tenant; use App\Models\User; +use Filament\Facades\Filament; use Illuminate\Foundation\Testing\RefreshDatabase; use Livewire\Livewire; use Tests\TestCase; @@ -12,8 +13,12 @@ test('policies bulk actions are available for authenticated users', function () { $tenant = Tenant::factory()->create(); - $tenant->makeCurrent(); $user = User::factory()->create(); + $user->tenants()->syncWithoutDetaching([ + $tenant->getKey() => ['role' => 'owner'], + ]); + + Filament::setTenant($tenant, true); $policies = Policy::factory()->count(2)->create(['tenant_id' => $tenant->id]); Livewire::actingAs($user) diff --git a/tests/Unit/FoundationSnapshotServiceTest.php b/tests/Unit/FoundationSnapshotServiceTest.php index bcd1f8d..e9c4c84 100644 --- a/tests/Unit/FoundationSnapshotServiceTest.php +++ b/tests/Unit/FoundationSnapshotServiceTest.php @@ -115,7 +115,7 @@ public function request(string $method, string $path, array $options = []): Grap expect($result['items'][1]['source_id'])->toBe('filter-2'); expect($client->requests[0]['path'])->toBe('deviceManagement/assignmentFilters'); - expect($client->requests[0]['options']['query']['$select'])->toBe(['id', 'displayName']); + expect($client->requests[0]['options']['query']['$select'])->toBe('id,displayName'); expect($client->requests[1]['path'])->toBe('deviceManagement/assignmentFilters?$skiptoken=abc'); expect($client->requests[1]['options']['query'])->toBe([]); }); diff --git a/tests/Unit/GraphClientEndpointResolutionTest.php b/tests/Unit/GraphClientEndpointResolutionTest.php new file mode 100644 index 0000000..532b799 --- /dev/null +++ b/tests/Unit/GraphClientEndpointResolutionTest.php @@ -0,0 +1,100 @@ +set('graph.base_url', 'https://graph.microsoft.com'); + config()->set('graph.version', 'beta'); + config()->set('graph.tenant_id', 'tenant'); + config()->set('graph.client_id', 'client'); + config()->set('graph.client_secret', 'secret'); + config()->set('graph.scope', 'https://graph.microsoft.com/.default'); + + // Ensure we don't accidentally resolve via supported_policy_types + config()->set('tenantpilot.supported_policy_types', []); +}); + +it('uses graph contract resource path for applyPolicy', function () { + config()->set('graph_contracts.types.mamAppConfiguration', [ + 'resource' => 'deviceAppManagement/targetedManagedAppConfigurations', + 'allowed_select' => ['id', 'displayName'], + 'allowed_expand' => [], + 'type_family' => ['#microsoft.graph.targetedManagedAppConfiguration'], + 'create_method' => 'POST', + 'update_method' => 'PATCH', + 'id_field' => 'id', + 'hydration' => 'properties', + ]); + + Http::fake([ + 'https://login.microsoftonline.com/*' => Http::response([ + 'access_token' => 'fake-token', + 'expires_in' => 3600, + ], 200), + 'https://graph.microsoft.com/*' => Http::response(['id' => 'A_1'], 200), + ]); + + $client = new MicrosoftGraphClient( + logger: app(GraphLogger::class), + contracts: app(GraphContractRegistry::class), + ); + + $client->applyPolicy( + policyType: 'mamAppConfiguration', + policyId: 'A_1', + payload: ['displayName' => 'Test'], + options: ['tenant' => 'tenant', 'client_id' => 'client', 'client_secret' => 'secret'], + ); + + Http::assertSent(function (Request $request) { + if (! str_contains($request->url(), 'graph.microsoft.com')) { + return false; + } + + return str_contains($request->url(), '/beta/deviceAppManagement/targetedManagedAppConfigurations/A_1'); + }); +}); + +it('uses built-in endpoint mapping for endpoint security policies when config is missing', function () { + config()->set('graph_contracts.types.endpointSecurityPolicy', []); + config()->set('tenantpilot.foundation_types', []); + + Http::fake([ + 'https://login.microsoftonline.com/*' => Http::response([ + 'access_token' => 'fake-token', + 'expires_in' => 3600, + ], 200), + 'https://graph.microsoft.com/*' => Http::response(['id' => 'E_1'], 200), + ]); + + $client = new MicrosoftGraphClient( + logger: app(GraphLogger::class), + contracts: app(GraphContractRegistry::class), + ); + + $client->applyPolicy( + policyType: 'endpointSecurityPolicy', + policyId: 'E_1', + payload: ['name' => 'Test'], + options: ['tenant' => 'tenant', 'client_id' => 'client', 'client_secret' => 'secret'], + ); + + Http::assertSent(function (Request $request) { + if (! str_contains($request->url(), 'graph.microsoft.com')) { + return false; + } + + if (! str_contains($request->url(), '/beta/deviceManagement/configurationPolicies/E_1')) { + return false; + } + + return ! str_contains($request->url(), '/beta/deviceManagement/endpointSecurityPolicy/E_1'); + }); +}); diff --git a/tests/Unit/ManagedDeviceAppConfigurationNormalizerTest.php b/tests/Unit/ManagedDeviceAppConfigurationNormalizerTest.php new file mode 100644 index 0000000..03266bc --- /dev/null +++ b/tests/Unit/ManagedDeviceAppConfigurationNormalizerTest.php @@ -0,0 +1,45 @@ + 'policy-1', + 'displayName' => 'MAMDevice', + '@odata.type' => '#microsoft.graph.iosMobileAppConfiguration', + 'settings' => [ + [ + 'appConfigKey' => 'com.microsoft.outlook.EmailProfile.AccountType', + 'appConfigKeyType' => 'stringType', + 'appConfigKeyValue' => 'ModernAuth', + ], + [ + 'appConfigKey' => 'com.microsoft.outlook.Mail.FocusedInbox', + 'appConfigKeyType' => 'booleanType', + 'appConfigKeyValue' => 'true', + ], + ], + ]; + + $normalized = $normalizer->normalize($snapshot, 'managedDeviceAppConfiguration', 'mobile'); + + $blocks = collect($normalized['settings'] ?? []); + + $appConfig = $blocks->firstWhere('title', 'App configuration settings'); + expect($appConfig)->not->toBeNull(); + expect($appConfig['type'] ?? null)->toBe('table'); + + $rows = collect($appConfig['rows'] ?? []); + $row = $rows->firstWhere('label', 'com.microsoft.outlook.EmailProfile.AccountType'); + expect($row)->not->toBeNull(); + expect($row['value'] ?? null)->toBe('ModernAuth'); + + $boolRow = $rows->firstWhere('label', 'com.microsoft.outlook.Mail.FocusedInbox'); + expect($boolRow)->not->toBeNull(); + expect($boolRow['value'] ?? null)->toBeTrue(); +}); diff --git a/tests/Unit/MicrosoftGraphClientListPoliciesSelectTest.php b/tests/Unit/MicrosoftGraphClientListPoliciesSelectTest.php new file mode 100644 index 0000000..8987140 --- /dev/null +++ b/tests/Unit/MicrosoftGraphClientListPoliciesSelectTest.php @@ -0,0 +1,160 @@ + Http::response(['value' => []], 200), + ]); + + $logger = mock(GraphLogger::class); + $logger->shouldReceive('logRequest')->zeroOrMoreTimes()->andReturnNull(); + $logger->shouldReceive('logResponse')->zeroOrMoreTimes()->andReturnNull(); + + $client = new MicrosoftGraphClient( + logger: $logger, + contracts: app(GraphContractRegistry::class), + ); + + $client->listPolicies('endpointSecurityPolicy', [ + 'access_token' => 'test-token', + ]); + + $client->listPolicies('securityBaselinePolicy', [ + 'access_token' => 'test-token', + ]); + + $client->listPolicies('settingsCatalogPolicy', [ + 'access_token' => 'test-token', + ]); + + Http::assertSent(function (Request $request) { + $url = $request->url(); + + if (! str_contains($url, '/deviceManagement/configurationPolicies')) { + return false; + } + + parse_str((string) parse_url($url, PHP_URL_QUERY), $query); + + expect($query)->toHaveKey('$select'); + + $select = (string) $query['$select']; + + expect($select)->toContain('technologies') + ->and($select)->toContain('templateReference') + ->and($select)->toContain('name') + ->and($select)->not->toContain('@odata.type'); + + expect($select)->not->toContain('displayName'); + expect($select)->not->toContain('version'); + + return true; + }); +}); + +it('retries list policies without $select on select/expand parsing errors', function () { + Http::fake([ + 'graph.microsoft.com/*' => Http::sequence() + ->push([ + 'error' => [ + 'code' => 'BadRequest', + 'message' => "Parsing OData Select and Expand failed: Could not find a property named 'version' on type 'microsoft.graph.deviceManagementConfigurationPolicy'.", + ], + ], 400) + ->push([ + 'error' => [ + 'code' => 'BadRequest', + 'message' => "Parsing OData Select and Expand failed: Could not find a property named 'version' on type 'microsoft.graph.deviceManagementConfigurationPolicy'.", + ], + ], 400) + ->push(['value' => [['id' => 'policy-1', 'name' => 'Policy One']]], 200), + ]); + + $logger = mock(GraphLogger::class); + $logger->shouldReceive('logRequest')->zeroOrMoreTimes()->andReturnNull(); + $logger->shouldReceive('logResponse')->zeroOrMoreTimes()->andReturnNull(); + + $client = new MicrosoftGraphClient( + logger: $logger, + contracts: app(GraphContractRegistry::class), + ); + + $response = $client->listPolicies('settingsCatalogPolicy', [ + 'access_token' => 'test-token', + ]); + + expect($response->successful())->toBeTrue(); + expect($response->data)->toHaveCount(1); + expect($response->warnings)->toContain('Capability fallback applied: removed $select for compatibility.'); + + $recorded = Http::recorded(); + + expect($recorded)->toHaveCount(3); + + [$firstRequest] = $recorded[0]; + [$secondRequest] = $recorded[1]; + [$thirdRequest] = $recorded[2]; + + parse_str((string) parse_url($firstRequest->url(), PHP_URL_QUERY), $firstQuery); + parse_str((string) parse_url($secondRequest->url(), PHP_URL_QUERY), $secondQuery); + parse_str((string) parse_url($thirdRequest->url(), PHP_URL_QUERY), $thirdQuery); + + expect($firstQuery)->toHaveKey('$select'); + expect($secondQuery)->toHaveKey('$select'); + expect($thirdQuery)->not->toHaveKey('$select'); +}); + +it('paginates list policies when nextLink is present', function () { + $nextLink = 'https://graph.microsoft.com/beta/deviceManagement/configurationPolicies?$skiptoken=page2'; + + Http::fake([ + 'graph.microsoft.com/*' => Http::sequence() + ->push([ + 'value' => [ + ['id' => 'policy-1', 'name' => 'Policy One'], + ], + '@odata.nextLink' => $nextLink, + ], 200) + ->push([ + 'value' => [ + ['id' => 'policy-2', 'name' => 'Policy Two'], + ], + ], 200), + ]); + + $logger = mock(GraphLogger::class); + $logger->shouldReceive('logRequest')->zeroOrMoreTimes()->andReturnNull(); + $logger->shouldReceive('logResponse')->zeroOrMoreTimes()->andReturnNull(); + + $client = new MicrosoftGraphClient( + logger: $logger, + contracts: app(GraphContractRegistry::class), + ); + + $response = $client->listPolicies('settingsCatalogPolicy', [ + 'access_token' => 'test-token', + ]); + + expect($response->successful())->toBeTrue(); + expect($response->data)->toHaveCount(2); + expect(collect($response->data)->pluck('id')->all())->toMatchArray(['policy-1', 'policy-2']); + + $recorded = Http::recorded(); + + expect($recorded)->toHaveCount(2); + + [$firstRequest] = $recorded[0]; + [$secondRequest] = $recorded[1]; + + expect($firstRequest->url())->toContain('/deviceManagement/configurationPolicies'); + expect($secondRequest->url())->toBe($nextLink); +}); diff --git a/tests/Unit/PolicyCaptureOrchestratorTest.php b/tests/Unit/PolicyCaptureOrchestratorTest.php new file mode 100644 index 0000000..2bfc775 --- /dev/null +++ b/tests/Unit/PolicyCaptureOrchestratorTest.php @@ -0,0 +1,64 @@ +create([ + 'tenant_id' => 'tenant-1', + 'app_client_id' => 'client-1', + 'app_client_secret' => 'secret-1', + 'is_current' => true, + ]); + + $policy = Policy::factory()->create([ + 'tenant_id' => $tenant->id, + 'policy_type' => 'mamAppConfiguration', + 'external_id' => 'A_f38e7f58-ac7c-455d-bb0e-f56bf1b3890e', + 'display_name' => 'MAM Example', + 'platform' => 'mobile', + ]); + + $snapshotService = Mockery::mock(PolicySnapshotService::class); + $snapshotService + ->shouldReceive('fetch') + ->once() + ->andReturn([ + 'failure' => [ + 'reason' => 'InternalServerError: upstream', + 'status' => 500, + ], + ]); + + $orchestrator = new PolicyCaptureOrchestrator( + versionService: Mockery::mock(VersionService::class), + snapshotService: $snapshotService, + assignmentFetcher: Mockery::mock(AssignmentFetcher::class), + groupResolver: Mockery::mock(GroupResolver::class), + assignmentFilterResolver: Mockery::mock(AssignmentFilterResolver::class), + scopeTagResolver: Mockery::mock(ScopeTagResolver::class), + ); + + $result = $orchestrator->capture( + policy: $policy, + tenant: $tenant, + includeAssignments: true, + includeScopeTags: true, + createdBy: 'admin@example.test', + ); + + expect($result)->toHaveKey('failure'); + expect($result['failure']['status'])->toBe(500); + expect($result['failure']['reason'])->toContain('InternalServerError'); +}); diff --git a/tests/Unit/PolicyNormalizerTest.php b/tests/Unit/PolicyNormalizerTest.php index 3685f2d..a4cba28 100644 --- a/tests/Unit/PolicyNormalizerTest.php +++ b/tests/Unit/PolicyNormalizerTest.php @@ -66,3 +66,91 @@ expect(collect($result['warnings'])->join(' '))->toContain('@odata.type mismatch'); }); + +it('normalizes enrollment platform restriction payload', function () { + $snapshot = [ + '@odata.type' => '#microsoft.graph.deviceEnrollmentPlatformRestrictionConfiguration', + 'deviceEnrollmentConfigurationType' => 'deviceEnrollmentPlatformRestrictionConfiguration', + 'displayName' => 'DeviceTypeRestriction', + 'version' => 2, + 'platformRestriction' => [ + 'platformBlocked' => false, + 'personalDeviceEnrollmentBlocked' => true, + ], + ]; + + $result = $this->normalizer->normalize($snapshot, 'deviceEnrollmentPlatformRestrictionsConfiguration', 'all'); + + $block = collect($result['settings'])->firstWhere('title', 'Platform restrictions (enrollment)'); + expect($block)->not->toBeNull(); + + expect(collect($block['entries'] ?? [])->firstWhere('key', 'Platform: Platform blocked')['value'] ?? null)->toBe('Disabled'); + expect(collect($block['entries'] ?? [])->firstWhere('key', 'Platform: Personal device enrollment blocked')['value'] ?? null)->toBe('Enabled'); + + expect(collect($block['entries'] ?? [])->firstWhere('key', 'Platform: OS minimum version')['value'] ?? null)->toBe('None'); + expect(collect($block['entries'] ?? [])->firstWhere('key', 'Platform: OS maximum version')['value'] ?? null)->toBe('None'); + expect(collect($block['entries'] ?? [])->firstWhere('key', 'Platform: Blocked manufacturers')['value'] ?? null)->toBe(['None']); + expect(collect($block['entries'] ?? [])->firstWhere('key', 'Platform: Blocked SKUs')['value'] ?? null)->toBe(['None']); +}); + +it('normalizes Autopilot deployment profile key fields', function () { + $snapshot = [ + '@odata.type' => '#microsoft.graph.azureADWindowsAutopilotDeploymentProfile', + 'displayName' => 'Autopilot Profile A', + 'description' => 'Used for standard devices', + 'deviceNameTemplate' => 'DEV-%SERIAL%', + 'deploymentMode' => 'singleUser', + 'deviceType' => 'windowsPc', + 'enableWhiteGlove' => true, + 'outOfBoxExperienceSettings' => [ + 'hideEULA' => true, + 'userType' => 'standard', + ], + ]; + + $result = $this->normalizer->normalize($snapshot, 'windowsAutopilotDeploymentProfile', 'windows'); + + expect($result['status'])->toBe('ok'); + expect($result['warnings'])->toBe([]); + + $general = collect($result['settings'])->firstWhere('title', 'General'); + expect($general)->not->toBeNull(); + expect(collect($general['entries'] ?? [])->firstWhere('key', 'Type')['value'] ?? null)->toBe('windowsAutopilotDeploymentProfile'); + expect(collect($general['entries'] ?? [])->firstWhere('key', 'Display name')['value'] ?? null)->toBe('Autopilot Profile A'); + + $block = collect($result['settings'])->firstWhere('title', 'Autopilot profile'); + expect($block)->not->toBeNull(); + expect(collect($block['entries'] ?? [])->firstWhere('key', 'Device name template')['value'] ?? null)->toBe('DEV-%SERIAL%'); + expect(collect($block['entries'] ?? [])->firstWhere('key', 'Pre-provisioning (White Glove)')['value'] ?? null)->toBe('Enabled'); + expect(collect($block['entries'] ?? [])->firstWhere('key', 'OOBE: Hide EULA')['value'] ?? null)->toBe('Enabled'); + expect(collect($block['entries'] ?? [])->firstWhere('key', 'OOBE: User type')['value'] ?? null)->toBe('standard'); +}); + +it('normalizes Enrollment Status Page key fields', function () { + $snapshot = [ + '@odata.type' => '#microsoft.graph.windowsEnrollmentStatusPageConfiguration', + 'displayName' => 'ESP A', + 'priority' => 1, + 'showInstallationProgress' => true, + 'blockDeviceSetupRetryByUser' => false, + 'installProgressTimeoutInMinutes' => 60, + 'selectedMobileAppIds' => ['app-1', 'app-2'], + ]; + + $result = $this->normalizer->normalize($snapshot, 'windowsEnrollmentStatusPage', 'windows'); + + expect($result['status'])->toBe('ok'); + expect($result['warnings'])->toBe([]); + + $general = collect($result['settings'])->firstWhere('title', 'General'); + expect($general)->not->toBeNull(); + expect(collect($general['entries'] ?? [])->firstWhere('key', 'Type')['value'] ?? null)->toBe('windowsEnrollmentStatusPage'); + expect(collect($general['entries'] ?? [])->firstWhere('key', 'Display name')['value'] ?? null)->toBe('ESP A'); + + $block = collect($result['settings'])->firstWhere('title', 'Enrollment Status Page (ESP)'); + expect($block)->not->toBeNull(); + expect(collect($block['entries'] ?? [])->firstWhere('key', 'Priority')['value'] ?? null)->toBe(1); + expect(collect($block['entries'] ?? [])->firstWhere('key', 'Show installation progress')['value'] ?? null)->toBe('Enabled'); + expect(collect($block['entries'] ?? [])->firstWhere('key', 'Block retry by user')['value'] ?? null)->toBe('Disabled'); + expect(collect($block['entries'] ?? [])->firstWhere('key', 'Selected mobile app IDs')['value'] ?? null)->toBe(['app-1', 'app-2']); +}); diff --git a/tests/Unit/PolicyPickerOptionLabelTest.php b/tests/Unit/PolicyPickerOptionLabelTest.php new file mode 100644 index 0000000..ae24d71 --- /dev/null +++ b/tests/Unit/PolicyPickerOptionLabelTest.php @@ -0,0 +1,13 @@ +toBe('1234abcd'); + + expect(\App\Livewire\BackupSetPolicyPickerTable::externalIdShort(null)) + ->toBe('—'); +}); diff --git a/tests/Unit/PolicySnapshotServiceTest.php b/tests/Unit/PolicySnapshotServiceTest.php index 9fa44ae..e3100ad 100644 --- a/tests/Unit/PolicySnapshotServiceTest.php +++ b/tests/Unit/PolicySnapshotServiceTest.php @@ -41,6 +41,27 @@ public function getPolicy(string $policyType, string $policyId, array $options = ]); } + if ($policyType === 'windowsDriverUpdateProfile') { + return new GraphResponse(success: true, data: [ + 'payload' => [ + 'id' => $policyId, + 'displayName' => 'Driver Updates A', + 'description' => 'Drivers rollout policy', + '@odata.type' => '#microsoft.graph.windowsDriverUpdateProfile', + 'approvalType' => 'automatic', + 'deploymentDeferralInDays' => 7, + 'deviceReporting' => 12, + 'newUpdates' => 3, + 'roleScopeTagIds' => ['0'], + 'inventorySyncStatus' => [ + '@odata.type' => '#microsoft.graph.windowsDriverUpdateProfileInventorySyncStatus', + 'driverInventorySyncState' => 'success', + 'lastSuccessfulSyncDateTime' => '2026-01-01T00:00:00Z', + ], + ], + ]); + } + return new GraphResponse(success: true, data: [ 'payload' => [ 'id' => $policyId, @@ -89,6 +110,182 @@ public function request(string $method, string $path, array $options = []): Grap } } +class ConfigurationPolicySettingsSnapshotGraphClient implements GraphClientInterface +{ + public array $requests = []; + + public function listPolicies(string $policyType, array $options = []): GraphResponse + { + return new GraphResponse(success: true, data: []); + } + + public function getPolicy(string $policyType, string $policyId, array $options = []): GraphResponse + { + $this->requests[] = ['getPolicy', $policyType, $policyId, $options]; + + return new GraphResponse(success: true, data: [ + 'payload' => [ + 'id' => $policyId, + 'name' => 'Endpoint Security Alpha', + '@odata.type' => '#microsoft.graph.deviceManagementConfigurationPolicy', + ], + ]); + } + + public function getOrganization(array $options = []): GraphResponse + { + return new GraphResponse(success: true, data: []); + } + + public function applyPolicy(string $policyType, string $policyId, array $payload, array $options = []): GraphResponse + { + return new GraphResponse(success: true, data: []); + } + + public function getServicePrincipalPermissions(array $options = []): GraphResponse + { + return new GraphResponse(success: true, data: []); + } + + public function request(string $method, string $path, array $options = []): GraphResponse + { + $this->requests[] = [$method, $path, $options]; + + if ($method === 'GET' && str_contains($path, 'deviceManagement/configurationPolicies/') && str_ends_with($path, '/settings')) { + return new GraphResponse(success: true, data: [ + 'value' => [ + [ + 'id' => 'setting-1', + 'settingInstance' => [ + '@odata.type' => '#microsoft.graph.deviceManagementConfigurationSimpleSettingInstance', + 'settingDefinitionId' => 'device_vendor_msft_policy_config_firewall_policy_alpha', + 'simpleSettingValue' => [ + 'value' => true, + ], + ], + ], + ], + ]); + } + + return new GraphResponse(success: true, data: []); + } +} + +class EnrollmentNotificationSnapshotGraphClient implements GraphClientInterface +{ + public array $requests = []; + + public function listPolicies(string $policyType, array $options = []): GraphResponse + { + return new GraphResponse(success: true, data: []); + } + + public function getPolicy(string $policyType, string $policyId, array $options = []): GraphResponse + { + $this->requests[] = ['getPolicy', $policyType, $policyId, $options]; + + if ($policyType === 'deviceEnrollmentNotificationConfiguration') { + return new GraphResponse(success: true, data: [ + 'payload' => [ + 'id' => $policyId, + 'displayName' => 'Enrollment Notifications', + '@odata.type' => '#microsoft.graph.deviceEnrollmentNotificationConfiguration', + 'priority' => 1, + 'version' => 1, + 'platformType' => 'windows', + 'brandingOptions' => 'none', + 'templateType' => '0', + 'notificationMessageTemplateId' => '00000000-0000-0000-0000-000000000000', + 'notificationTemplates' => [ + 'Email_email-template-1', + 'Push_push-template-1', + ], + 'deviceEnrollmentConfigurationType' => 'enrollmentNotificationsConfiguration', + ], + ]); + } + + return new GraphResponse(success: true, data: [ + 'payload' => [ + 'id' => $policyId, + 'displayName' => 'Policy', + '@odata.type' => '#microsoft.graph.deviceConfiguration', + ], + ]); + } + + public function getOrganization(array $options = []): GraphResponse + { + return new GraphResponse(success: true, data: []); + } + + public function applyPolicy(string $policyType, string $policyId, array $payload, array $options = []): GraphResponse + { + return new GraphResponse(success: true, data: []); + } + + public function getServicePrincipalPermissions(array $options = []): GraphResponse + { + return new GraphResponse(success: true, data: []); + } + + public function request(string $method, string $path, array $options = []): GraphResponse + { + $this->requests[] = [$method, $path, $options]; + + if ($method === 'GET' && str_contains($path, 'deviceManagement/notificationMessageTemplates/email-template-1/localizedNotificationMessages')) { + return new GraphResponse(success: true, data: [ + 'value' => [ + [ + 'id' => 'email-template-1_en-us', + 'locale' => 'en-us', + 'subject' => 'Email Subject', + 'messageTemplate' => 'Email Body', + 'isDefault' => true, + ], + ], + ]); + } + + if ($method === 'GET' && str_contains($path, 'deviceManagement/notificationMessageTemplates/push-template-1/localizedNotificationMessages')) { + return new GraphResponse(success: true, data: [ + 'value' => [ + [ + 'id' => 'push-template-1_en-us', + 'locale' => 'en-us', + 'subject' => 'Push Subject', + 'messageTemplate' => 'Push Body', + 'isDefault' => true, + ], + ], + ]); + } + + if ($method === 'GET' && str_contains($path, 'deviceManagement/notificationMessageTemplates/email-template-1')) { + return new GraphResponse(success: true, data: [ + '@odata.context' => 'https://graph.microsoft.com/beta/$metadata#deviceManagement/notificationMessageTemplates/$entity', + 'id' => 'email-template-1', + 'displayName' => 'Email Template', + 'defaultLocale' => 'en-us', + 'brandingOptions' => 'none', + ]); + } + + if ($method === 'GET' && str_contains($path, 'deviceManagement/notificationMessageTemplates/push-template-1')) { + return new GraphResponse(success: true, data: [ + '@odata.context' => 'https://graph.microsoft.com/beta/$metadata#deviceManagement/notificationMessageTemplates/$entity', + 'id' => 'push-template-1', + 'displayName' => 'Push Template', + 'defaultLocale' => 'en-us', + 'brandingOptions' => 'none', + ]); + } + + return new GraphResponse(success: true, data: []); + } +} + it('hydrates compliance policy scheduled actions into snapshots', function () { $client = new PolicySnapshotGraphClient; app()->instance(GraphClientInterface::class, $client); @@ -125,6 +322,86 @@ public function request(string $method, string $path, array $options = []): Grap ->toBe('scheduledActionsForRule($expand=scheduledActionConfigurations)'); }); +it('hydrates configuration policy settings into snapshots', function (string $policyType) { + $client = new ConfigurationPolicySettingsSnapshotGraphClient; + app()->instance(GraphClientInterface::class, $client); + + $tenant = Tenant::factory()->create([ + 'tenant_id' => 'tenant-endpoint-security', + 'app_client_id' => 'client-123', + 'app_client_secret' => 'secret-123', + 'is_current' => true, + ]); + $tenant->makeCurrent(); + + $policy = Policy::factory()->create([ + 'tenant_id' => $tenant->id, + 'external_id' => 'esp-123', + 'policy_type' => $policyType, + 'display_name' => 'Endpoint Security Alpha', + 'platform' => 'windows', + ]); + + $service = app(PolicySnapshotService::class); + $result = $service->fetch($tenant, $policy); + + expect($result)->toHaveKey('payload'); + expect($result['payload'])->toHaveKey('settings'); + expect($result['payload']['settings'])->toHaveCount(1); + expect($result['metadata']['settings_hydration'] ?? null)->toBe('complete'); + + $paths = collect($client->requests) + ->filter(fn (array $entry): bool => ($entry[0] ?? null) === 'GET') + ->map(fn (array $entry): string => (string) ($entry[1] ?? '')) + ->values(); + + expect($paths->contains(fn (string $path): bool => str_contains($path, 'deviceManagement/configurationPolicies/esp-123/settings')))->toBeTrue(); +})->with([ + 'endpointSecurityPolicy', + 'securityBaselinePolicy', +]); + +it('hydrates enrollment notification templates into snapshots', function () { + $client = new EnrollmentNotificationSnapshotGraphClient; + app()->instance(GraphClientInterface::class, $client); + + $tenant = Tenant::factory()->create([ + 'tenant_id' => 'tenant-enrollment-notifications', + 'app_client_id' => 'client-123', + 'app_client_secret' => 'secret-123', + 'is_current' => true, + ]); + $tenant->makeCurrent(); + + $policy = Policy::factory()->create([ + 'tenant_id' => $tenant->id, + 'external_id' => 'enroll-notify-123', + 'policy_type' => 'deviceEnrollmentNotificationConfiguration', + 'display_name' => 'Enrollment Notifications', + 'platform' => 'all', + ]); + + $service = app(PolicySnapshotService::class); + $result = $service->fetch($tenant, $policy); + + expect($result)->toHaveKey('payload'); + expect($result['payload'])->toHaveKey('notificationTemplateSnapshots'); + expect($result['payload']['notificationTemplateSnapshots'])->toHaveCount(2); + expect($result['metadata']['enrollment_notification_templates_hydration'] ?? null)->toBe('complete'); + + $email = collect($result['payload']['notificationTemplateSnapshots'])->firstWhere('channel', 'Email'); + expect($email)->not->toBeNull() + ->and($email['template_id'] ?? null)->toBe('email-template-1') + ->and($email['template']['displayName'] ?? null)->toBe('Email Template') + ->and($email['localized_notification_messages'][0]['subject'] ?? null)->toBe('Email Subject'); + + $push = collect($result['payload']['notificationTemplateSnapshots'])->firstWhere('channel', 'Push'); + expect($push)->not->toBeNull() + ->and($push['template_id'] ?? null)->toBe('push-template-1') + ->and($push['template']['displayName'] ?? null)->toBe('Push Template') + ->and($push['localized_notification_messages'][0]['subject'] ?? null)->toBe('Push Subject'); +}); + it('filters mobile app snapshots to metadata-only keys', function () { $client = new PolicySnapshotGraphClient; app()->instance(GraphClientInterface::class, $client); @@ -169,3 +446,312 @@ public function request(string $method, string $path, array $options = []): Grap expect($client->requests[0][3]['select'])->toContain('roleScopeTagIds'); expect($client->requests[0][3]['select'])->not->toContain('@odata.type'); }); + +it('captures windows driver update profile snapshots with full payload', function () { + $client = new PolicySnapshotGraphClient; + app()->instance(GraphClientInterface::class, $client); + + $tenant = Tenant::factory()->create([ + 'tenant_id' => 'tenant-driver', + 'app_client_id' => 'client-123', + 'app_client_secret' => 'secret-123', + 'is_current' => true, + ]); + $tenant->makeCurrent(); + + $policy = Policy::factory()->create([ + 'tenant_id' => $tenant->id, + 'external_id' => 'wdp-123', + 'policy_type' => 'windowsDriverUpdateProfile', + 'display_name' => 'Driver Updates A', + 'platform' => 'windows', + ]); + + $service = app(PolicySnapshotService::class); + $result = $service->fetch($tenant, $policy); + + expect($result)->toHaveKey('payload'); + expect($result['payload']['approvalType'] ?? null)->toBe('automatic'); + expect($result['payload']['deploymentDeferralInDays'] ?? null)->toBe(7); + expect($result['payload']['deviceReporting'] ?? null)->toBe(12); + expect($result['payload']['newUpdates'] ?? null)->toBe(3); + expect($result['payload']['inventorySyncStatus']['driverInventorySyncState'] ?? null)->toBe('success'); + + expect($client->requests[0][0])->toBe('getPolicy'); + expect($client->requests[0][1])->toBe('windowsDriverUpdateProfile'); + expect($client->requests[0][2])->toBe('wdp-123'); +}); + +test('falls back to metadata-only snapshot when mamAppConfiguration returns 500', function () { + $client = Mockery::mock(\App\Services\Graph\GraphClientInterface::class); + $client->shouldReceive('getPolicy') + ->once() + ->andThrow(new \App\Services\Graph\GraphException('InternalServerError: upstream', 500)); + + app()->instance(\App\Services\Graph\GraphClientInterface::class, $client); + + $tenant = Tenant::factory()->create([ + 'tenant_id' => 'tenant-mam-fallback', + 'app_client_id' => 'client-123', + 'app_client_secret' => 'secret-123', + 'is_current' => true, + ]); + + $policy = Policy::factory()->create([ + 'tenant_id' => $tenant->id, + 'external_id' => 'A_fallback-policy', + 'policy_type' => 'mamAppConfiguration', + 'display_name' => 'MAM Config Alpha', + 'platform' => 'iOS', + ]); + + $service = app(\App\Services\Intune\PolicySnapshotService::class); + $result = $service->fetch($tenant, $policy); + + expect($result)->toHaveKey('payload'); + expect($result)->toHaveKey('metadata'); + expect($result)->toHaveKey('warnings'); + expect($result['payload']['id'])->toBe('A_fallback-policy'); + expect($result['payload']['displayName'])->toBe('MAM Config Alpha'); + expect($result['payload']['@odata.type'])->toBe('#microsoft.graph.targetedManagedAppConfiguration'); + expect($result['payload']['platform'])->toBe('iOS'); + expect($result['metadata']['source'])->toBe('metadata_only'); + expect($result['metadata']['original_status'])->toBe(500); + expect($result['warnings'])->toHaveCount(1); + expect($result['warnings'][0])->toContain('Snapshot captured from local metadata only'); + expect($result['warnings'][0])->toContain('Restore preview available, full restore not possible'); +}); + +test('does not fallback to metadata for non-5xx errors', function () { + $client = Mockery::mock(\App\Services\Graph\GraphClientInterface::class); + $client->shouldReceive('getPolicy') + ->once() + ->andThrow(new \App\Services\Graph\GraphException('NotFound', 404)); + + app()->instance(\App\Services\Graph\GraphClientInterface::class, $client); + + $tenant = Tenant::factory()->create([ + 'tenant_id' => 'tenant-404', + 'app_client_id' => 'client-123', + 'app_client_secret' => 'secret-123', + 'is_current' => true, + ]); + + $policy = Policy::factory()->create([ + 'tenant_id' => $tenant->id, + 'external_id' => 'A_missing', + 'policy_type' => 'mamAppConfiguration', + 'display_name' => 'Missing Policy', + 'platform' => 'iOS', + ]); + + $service = app(\App\Services\Intune\PolicySnapshotService::class); + $result = $service->fetch($tenant, $policy); + + expect($result)->toHaveKey('failure'); + expect($result['failure']['status'])->toBe(404); + expect($result['failure']['reason'])->toContain('NotFound'); +}); + +test('falls back to metadata-only when graph client returns failed response for mamAppConfiguration', function () { + $client = Mockery::mock(\App\Services\Graph\GraphClientInterface::class); + $client->shouldReceive('getPolicy') + ->once() + ->andReturn(new \App\Services\Graph\GraphResponse( + success: false, + data: [ + 'error' => [ + 'code' => 'InternalServerError', + 'message' => 'Upstream MAM failure', + ], + ], + status: 500, + errors: [['code' => 'InternalServerError', 'message' => 'Upstream MAM failure']], + meta: [ + 'client_request_id' => 'client-req-1', + 'request_id' => 'req-1', + ], + )); + + app()->instance(\App\Services\Graph\GraphClientInterface::class, $client); + + $tenant = Tenant::factory()->create([ + 'tenant_id' => 'tenant-mam-fallback-response', + 'app_client_id' => 'client-123', + 'app_client_secret' => 'secret-123', + 'is_current' => true, + ]); + + $policy = Policy::factory()->create([ + 'tenant_id' => $tenant->id, + 'external_id' => 'A_resp_fallback', + 'policy_type' => 'mamAppConfiguration', + 'display_name' => 'MAM Config Response', + 'platform' => 'iOS', + ]); + + $service = app(\App\Services\Intune\PolicySnapshotService::class); + $result = $service->fetch($tenant, $policy); + + expect($result)->toHaveKey('payload'); + expect($result['metadata']['source'])->toBe('metadata_only'); + expect($result['metadata']['original_status'])->toBe(500); + expect($result['metadata']['original_failure'])->toContain('InternalServerError'); +}); + +class WindowsUpdateRingSnapshotGraphClient implements GraphClientInterface +{ + public array $requests = []; + + public function listPolicies(string $policyType, array $options = []): GraphResponse + { + return new GraphResponse(success: true, data: []); + } + + public function getPolicy(string $policyType, string $policyId, array $options = []): GraphResponse + { + $this->requests[] = ['getPolicy', $policyType, $policyId, $options]; + + return new GraphResponse(success: true, data: [ + 'payload' => [ + 'id' => $policyId, + '@odata.type' => '#microsoft.graph.windowsUpdateForBusinessConfiguration', + 'displayName' => 'Ring A', + ], + ]); + } + + public function getOrganization(array $options = []): GraphResponse + { + return new GraphResponse(success: true, data: []); + } + + public function applyPolicy(string $policyType, string $policyId, array $payload, array $options = []): GraphResponse + { + return new GraphResponse(success: true, data: []); + } + + public function getServicePrincipalPermissions(array $options = []): GraphResponse + { + return new GraphResponse(success: true, data: []); + } + + public function request(string $method, string $path, array $options = []): GraphResponse + { + $this->requests[] = [$method, $path]; + + if ($method === 'GET' && $path === 'deviceManagement/deviceConfigurations/policy-wuring/microsoft.graph.windowsUpdateForBusinessConfiguration') { + return new GraphResponse(success: true, data: [ + 'automaticUpdateMode' => 'autoInstallAtMaintenanceTime', + 'featureUpdatesDeferralPeriodInDays' => 14, + ]); + } + + return new GraphResponse(success: true, data: []); + } +} + +it('hydrates windows update ring snapshots via derived type cast endpoint', function () { + $client = new WindowsUpdateRingSnapshotGraphClient; + app()->instance(GraphClientInterface::class, $client); + + $tenant = Tenant::factory()->create([ + 'tenant_id' => 'tenant-wuring', + 'app_client_id' => 'client-123', + 'app_client_secret' => 'secret-123', + 'is_current' => true, + ]); + $tenant->makeCurrent(); + + $policy = Policy::factory()->create([ + 'tenant_id' => $tenant->id, + 'external_id' => 'policy-wuring', + 'policy_type' => 'windowsUpdateRing', + 'display_name' => 'Ring A', + 'platform' => 'windows', + ]); + + $service = app(PolicySnapshotService::class); + $result = $service->fetch($tenant, $policy); + + expect($result)->toHaveKey('payload'); + expect($result['payload']['@odata.type'])->toBe('#microsoft.graph.windowsUpdateForBusinessConfiguration'); + expect($result['payload']['automaticUpdateMode'])->toBe('autoInstallAtMaintenanceTime'); + expect($result['payload']['featureUpdatesDeferralPeriodInDays'])->toBe(14); + expect($result['metadata']['properties_hydration'] ?? null)->toBe('complete'); +}); + +class FailedSnapshotGraphClient implements GraphClientInterface +{ + public function listPolicies(string $policyType, array $options = []): GraphResponse + { + return new GraphResponse(success: true, data: []); + } + + public function getPolicy(string $policyType, string $policyId, array $options = []): GraphResponse + { + return new GraphResponse( + success: false, + data: [], + status: 500, + errors: [], + warnings: [], + meta: [ + 'error_code' => 'InternalServerError', + 'error_message' => 'An internal server error has occurred', + 'request_id' => 'req-123', + 'client_request_id' => 'client-456', + ], + ); + } + + public function getOrganization(array $options = []): GraphResponse + { + return new GraphResponse(success: true, data: []); + } + + public function applyPolicy(string $policyType, string $policyId, array $payload, array $options = []): GraphResponse + { + return new GraphResponse(success: true, data: []); + } + + public function getServicePrincipalPermissions(array $options = []): GraphResponse + { + return new GraphResponse(success: true, data: []); + } + + public function request(string $method, string $path, array $options = []): GraphResponse + { + return new GraphResponse(success: true, data: []); + } +} + +it('returns actionable reasons when graph snapshot fails', function () { + app()->instance(GraphClientInterface::class, new FailedSnapshotGraphClient); + + $tenant = Tenant::factory()->create([ + 'tenant_id' => 'tenant-failure', + 'app_client_id' => 'client-123', + 'app_client_secret' => 'secret-123', + 'is_current' => true, + ]); + $tenant->makeCurrent(); + + $policy = Policy::factory()->create([ + 'tenant_id' => $tenant->id, + 'external_id' => 'mam-123', + 'policy_type' => 'deviceCompliancePolicy', + 'display_name' => 'Compliance Config', + 'platform' => 'mobile', + ]); + + $service = app(PolicySnapshotService::class); + $result = $service->fetch($tenant, $policy); + + expect($result)->toHaveKey('failure'); + expect($result['failure']['status'])->toBe(500); + expect($result['failure']['reason'])->toContain('InternalServerError'); + expect($result['failure']['reason'])->toContain('An internal server error has occurred'); + expect($result['failure']['reason'])->toContain('client_request_id=client-456'); + expect($result['failure']['reason'])->toContain('request_id=req-123'); +}); diff --git a/tests/Unit/ScriptsPolicyNormalizerTest.php b/tests/Unit/ScriptsPolicyNormalizerTest.php new file mode 100644 index 0000000..61a63a7 --- /dev/null +++ b/tests/Unit/ScriptsPolicyNormalizerTest.php @@ -0,0 +1,168 @@ + '#microsoft.graph.deviceManagementScript', + 'displayName' => 'My PS script', + 'description' => 'Does a thing', + 'scriptContent' => str_repeat('A', 10), + 'runFrequency' => 'weekly', + ]; + + $result = $normalizer->normalize($snapshot, 'deviceManagementScript', 'windows'); + + expect($result['status'])->toBe('ok'); + expect($result['settings'])->toBeArray()->not->toBeEmpty(); + expect($result['settings'][0]['type'])->toBe('keyValue'); + expect(collect($result['settings'][0]['entries'])->pluck('key')->all())->toContain('Display name'); +}); + +it('normalizes deviceShellScript into readable settings', function () { + $normalizer = app(PolicyNormalizer::class); + + $snapshot = [ + '@odata.type' => '#microsoft.graph.deviceShellScript', + 'displayName' => 'My macOS shell script', + 'scriptContent' => str_repeat('B', 5), + ]; + + $result = $normalizer->normalize($snapshot, 'deviceShellScript', 'macOS'); + + expect($result['status'])->toBe('ok'); + expect($result['settings'])->toBeArray()->not->toBeEmpty(); + expect($result['settings'][0]['type'])->toBe('keyValue'); +}); + +it('normalizes deviceHealthScript into readable settings', function () { + $normalizer = app(PolicyNormalizer::class); + + $snapshot = [ + '@odata.type' => '#microsoft.graph.deviceHealthScript', + 'displayName' => 'My remediation', + 'detectionScriptContent' => str_repeat('C', 3), + 'remediationScriptContent' => str_repeat('D', 4), + ]; + + $result = $normalizer->normalize($snapshot, 'deviceHealthScript', 'windows'); + + expect($result['status'])->toBe('ok'); + expect($result['settings'])->toBeArray()->not->toBeEmpty(); + expect($result['settings'][0]['type'])->toBe('keyValue'); +}); + +it('summarizes script content by default', function () { + config([ + 'tenantpilot.display.show_script_content' => false, + ]); + + $normalizer = app(PolicyNormalizer::class); + + $snapshot = [ + '@odata.type' => '#microsoft.graph.deviceManagementScript', + 'displayName' => 'My PS script', + 'scriptContent' => 'ABC', + ]; + + $result = $normalizer->normalize($snapshot, 'deviceManagementScript', 'windows'); + + $entries = collect($result['settings'][0]['entries']); + + expect($entries->firstWhere('key', 'scriptContent')['value'])->toBe('[content: 3 chars]'); +}); + +it('shows script content when enabled', function () { + config([ + 'tenantpilot.display.show_script_content' => true, + 'tenantpilot.display.max_script_content_chars' => 100, + ]); + + $normalizer = app(PolicyNormalizer::class); + + $snapshot = [ + '@odata.type' => '#microsoft.graph.deviceManagementScript', + 'displayName' => 'My PS script', + 'scriptContent' => "line1\nline2", + ]; + + $result = $normalizer->normalize($snapshot, 'deviceManagementScript', 'windows'); + + $entries = collect($result['settings'][0]['entries']); + + expect($entries->firstWhere('key', 'scriptContent')['value'])->toBe("line1\nline2"); +}); + +it('decodes scriptContentBase64 when enabled and scriptContent is missing', function () { + config([ + 'tenantpilot.display.show_script_content' => true, + 'tenantpilot.display.max_script_content_chars' => 50, + ]); + + $normalizer = app(PolicyNormalizer::class); + + $snapshot = [ + '@odata.type' => '#microsoft.graph.deviceShellScript', + 'displayName' => 'My macOS shell script', + 'scriptContentBase64' => base64_encode('echo hello'), + ]; + + $result = $normalizer->normalize($snapshot, 'deviceShellScript', 'macOS'); + + $entries = collect($result['settings'][0]['entries']); + + expect($entries->firstWhere('key', 'scriptContent')['value'])->toBe('echo hello'); +}); + +it('decodes base64-looking scriptContent when enabled', function () { + config([ + 'tenantpilot.display.show_script_content' => true, + 'tenantpilot.display.max_script_content_chars' => 5000, + ]); + + $normalizer = app(PolicyNormalizer::class); + + $plain = "# hello\nWrite-Host \"hi\""; + $snapshot = [ + '@odata.type' => '#microsoft.graph.deviceManagementScript', + 'displayName' => 'My PS script', + 'scriptContent' => base64_encode($plain), + ]; + + $result = $normalizer->normalize($snapshot, 'deviceManagementScript', 'windows'); + + $entries = collect($result['settings'][0]['entries']); + + expect($entries->firstWhere('key', 'scriptContent')['value'])->toBe($plain); +}); + +it('decodes base64-looking detection/remediation script content when enabled', function () { + config([ + 'tenantpilot.display.show_script_content' => true, + 'tenantpilot.display.max_script_content_chars' => 5000, + ]); + + $normalizer = app(PolicyNormalizer::class); + + $detection = "# detection\nWrite-Host \"detect\""; + $remediation = "# remediation\nWrite-Host \"fix\""; + + $snapshot = [ + '@odata.type' => '#microsoft.graph.deviceHealthScript', + 'displayName' => 'My remediation', + 'detectionScriptContent' => base64_encode($detection), + 'remediationScriptContent' => base64_encode($remediation), + ]; + + $result = $normalizer->normalize($snapshot, 'deviceHealthScript', 'windows'); + + $entries = collect($result['settings'][0]['entries']); + + expect($entries->firstWhere('key', 'detectionScriptContent')['value'])->toBe($detection); + expect($entries->firstWhere('key', 'remediationScriptContent')['value'])->toBe($remediation); +}); diff --git a/tests/Unit/SettingsCatalogPolicyNormalizerTest.php b/tests/Unit/SettingsCatalogPolicyNormalizerTest.php index fcc9a14..d4ddd36 100644 --- a/tests/Unit/SettingsCatalogPolicyNormalizerTest.php +++ b/tests/Unit/SettingsCatalogPolicyNormalizerTest.php @@ -31,3 +31,139 @@ expect($rows)->toHaveCount(1); expect($rows[0]['definition_id'] ?? null)->toBe('device_vendor_msft_policy_config_defender_allowrealtimemonitoring'); }); + +it('builds a settings table for endpoint security configuration policies', function (string $policyType) { + $normalizer = app(SettingsCatalogPolicyNormalizer::class); + + $snapshot = [ + '@odata.type' => '#microsoft.graph.deviceManagementConfigurationPolicy', + 'settings' => [ + [ + 'id' => 's1', + 'settingInstance' => [ + '@odata.type' => '#microsoft.graph.deviceManagementConfigurationSimpleSettingInstance', + 'settingDefinitionId' => 'device_vendor_msft_policy_config_defender_allowrealtimemonitoring', + 'simpleSettingValue' => [ + 'value' => 1, + ], + ], + ], + ], + ]; + + $normalized = $normalizer->normalize($snapshot, $policyType, 'windows'); + + $rows = $normalized['settings_table']['rows'] ?? []; + + expect($rows)->toHaveCount(1); + expect($rows[0]['definition_id'] ?? null)->toBe('device_vendor_msft_policy_config_defender_allowrealtimemonitoring'); +})->with([ + 'endpointSecurityPolicy', + 'securityBaselinePolicy', +]); + +it('prettifies endpoint security firewall rules settings for display', function () { + $normalizer = app(SettingsCatalogPolicyNormalizer::class); + + $groupDefinitionId = 'vendor_msft_firewall_mdmstore_firewallrules_{FirewallRuleId}'; + $nameDefinitionId = 'vendor_msft_firewall_mdmstore_firewallrules_{FirewallRuleId}_displayname'; + $directionDefinitionId = 'vendor_msft_firewall_mdmstore_firewallrules_{FirewallRuleId}_direction'; + $actionDefinitionId = 'vendor_msft_firewall_mdmstore_firewallrules_{FirewallRuleId}_action'; + $interfaceTypesDefinitionId = 'vendor_msft_firewall_mdmstore_firewallrules_{FirewallRuleId}_interfacetypes'; + + $snapshot = [ + '@odata.type' => '#microsoft.graph.deviceManagementConfigurationPolicy', + 'templateReference' => [ + 'templateFamily' => 'endpointSecurityFirewall', + 'templateDisplayName' => 'Windows Firewall Rules', + 'templateDisplayVersion' => 'Version 1', + ], + 'settings' => [ + [ + 'id' => 'rule-1', + 'settingInstance' => [ + '@odata.type' => '#microsoft.graph.deviceManagementConfigurationGroupSettingCollectionInstance', + 'settingDefinitionId' => $groupDefinitionId, + 'groupSettingCollectionValue' => [ + [ + 'children' => [ + [ + '@odata.type' => '#microsoft.graph.deviceManagementConfigurationSimpleSettingInstance', + 'settingDefinitionId' => $nameDefinitionId, + 'simpleSettingValue' => [ + 'value' => 'Test0', + ], + ], + [ + '@odata.type' => '#microsoft.graph.deviceManagementConfigurationChoiceSettingInstance', + 'settingDefinitionId' => $directionDefinitionId, + 'choiceSettingValue' => [ + 'value' => "{$directionDefinitionId}_in", + ], + ], + [ + '@odata.type' => '#microsoft.graph.deviceManagementConfigurationChoiceSettingInstance', + 'settingDefinitionId' => $actionDefinitionId, + 'choiceSettingValue' => [ + 'value' => "{$actionDefinitionId}_allow", + ], + ], + [ + '@odata.type' => '#microsoft.graph.deviceManagementConfigurationChoiceSettingCollectionInstance', + 'settingDefinitionId' => $interfaceTypesDefinitionId, + 'choiceSettingCollectionValue' => [ + [ + 'value' => "{$interfaceTypesDefinitionId}_lan", + 'children' => [], + ], + [ + 'value' => "{$interfaceTypesDefinitionId}_remoteaccess", + 'children' => [], + ], + ], + ], + ], + ], + ], + ], + ], + ], + ]; + + $normalized = $normalizer->normalize($snapshot, 'endpointSecurityPolicy', 'windows'); + $rows = collect($normalized['settings_table']['rows'] ?? []); + + $groupRow = $rows->firstWhere('definition_id', $groupDefinitionId); + expect($groupRow)->not->toBeNull(); + expect($groupRow['category'] ?? null)->toBe('Windows Firewall Rules'); + expect($groupRow['definition'] ?? null)->toBe('Firewall rule'); + expect($groupRow['data_type'] ?? null)->toBe('Group'); + expect($groupRow['value'] ?? null)->toBe('(group)'); + + $nameRow = $rows->firstWhere('definition_id', $nameDefinitionId); + expect($nameRow)->not->toBeNull(); + expect($nameRow['category'] ?? null)->toBe('Windows Firewall Rules'); + expect($nameRow['definition'] ?? null)->toBe('Name'); + expect($nameRow['value'] ?? null)->toBe('Test0'); + + $directionRow = $rows->firstWhere('definition_id', $directionDefinitionId); + expect($directionRow)->not->toBeNull(); + expect($directionRow['category'] ?? null)->toBe('Windows Firewall Rules'); + expect($directionRow['definition'] ?? null)->toBe('Direction'); + expect($directionRow['data_type'] ?? null)->toBe('Choice'); + expect($directionRow['value'] ?? null)->toBe('Inbound'); + + $actionRow = $rows->firstWhere('definition_id', $actionDefinitionId); + expect($actionRow)->not->toBeNull(); + expect($actionRow['category'] ?? null)->toBe('Windows Firewall Rules'); + expect($actionRow['definition'] ?? null)->toBe('Action'); + expect($actionRow['data_type'] ?? null)->toBe('Choice'); + expect($actionRow['value'] ?? null)->toBe('Allow'); + + $interfaceTypesRow = $rows->firstWhere('definition_id', $interfaceTypesDefinitionId); + expect($interfaceTypesRow)->not->toBeNull(); + expect($interfaceTypesRow['category'] ?? null)->toBe('Windows Firewall Rules'); + expect($interfaceTypesRow['definition'] ?? null)->toBe('Interface types'); + expect($interfaceTypesRow['data_type'] ?? null)->toBe('Choice'); + expect($interfaceTypesRow['value'] ?? null)->toBe('LAN, Remote access'); +}); diff --git a/tests/Unit/TenantCurrentTest.php b/tests/Unit/TenantCurrentTest.php index 462d952..86687c1 100644 --- a/tests/Unit/TenantCurrentTest.php +++ b/tests/Unit/TenantCurrentTest.php @@ -108,3 +108,27 @@ function restoreIntuneTenantId(string|false $original): void restoreIntuneTenantId($originalEnv); }); + +it('makeCurrent keeps tenant current when already current', function () { + $originalEnv = getenv('INTUNE_TENANT_ID'); + putenv('INTUNE_TENANT_ID='); + + $current = Tenant::create([ + 'tenant_id' => 'tenant-current', + 'name' => 'Already Current', + 'is_current' => true, + ]); + + $other = Tenant::create([ + 'tenant_id' => 'tenant-other', + 'name' => 'Other Tenant', + 'is_current' => false, + ]); + + $current->makeCurrent(); + + expect($current->fresh()->is_current)->toBeTrue(); + expect($other->fresh()->is_current)->toBeFalse(); + + restoreIntuneTenantId($originalEnv); +}); diff --git a/tests/Unit/WindowsDriverUpdateProfileNormalizerTest.php b/tests/Unit/WindowsDriverUpdateProfileNormalizerTest.php new file mode 100644 index 0000000..226e594 --- /dev/null +++ b/tests/Unit/WindowsDriverUpdateProfileNormalizerTest.php @@ -0,0 +1,38 @@ + '#microsoft.graph.windowsDriverUpdateProfile', + 'displayName' => 'Driver Updates A', + 'description' => 'Drivers rollout policy', + 'approvalType' => 'automatic', + 'deploymentDeferralInDays' => 7, + 'deviceReporting' => 12, + 'newUpdates' => 3, + 'inventorySyncStatus' => [ + 'driverInventorySyncState' => 'success', + 'lastSuccessfulSyncDateTime' => '2026-01-01T00:00:00Z', + ], + ]; + + $result = $normalizer->normalize($snapshot, 'windowsDriverUpdateProfile', 'windows'); + + expect($result['status'])->toBe('success'); + expect($result['settings'])->toBeArray()->not->toBeEmpty(); + + $driverBlock = collect($result['settings']) + ->first(fn (array $block) => ($block['title'] ?? null) === 'Driver Update Profile'); + + expect($driverBlock)->not->toBeNull(); + + $keys = collect($driverBlock['entries'] ?? [])->pluck('key')->all(); + + expect($keys)->toContain('Approval type', 'Deployment deferral (days)', 'Devices reporting', 'New driver updates'); +});