diff --git a/.github/agents/copilot-instructions.md b/.github/agents/copilot-instructions.md index c0e52ea..e53c727 100644 --- a/.github/agents/copilot-instructions.md +++ b/.github/agents/copilot-instructions.md @@ -8,6 +8,8 @@ ## Active Technologies - PostgreSQL (Sail locally) (feat/032-backup-scheduling-mvp) - PHP 8.4.x + Laravel 12, Filament v4, Livewire v3 (feat/042-inventory-dependencies-graph) - PostgreSQL (JSONB) (feat/042-inventory-dependencies-graph) +- PHP 8.4.x (Laravel 12) + Laravel 12, Filament v4, Livewire v3 (feat/047-inventory-foundations-nodes) +- PostgreSQL (JSONB for `InventoryItem.meta_jsonb`) (feat/047-inventory-foundations-nodes) - PHP 8.4.15 (feat/005-bulk-operations) @@ -27,9 +29,9 @@ ## Code Style PHP 8.4.15: Follow standard conventions ## Recent Changes +- feat/047-inventory-foundations-nodes: Added PHP 8.4.x (Laravel 12) + Laravel 12, Filament v4, Livewire v3 - feat/042-inventory-dependencies-graph: Added PHP 8.4.x + Laravel 12, Filament v4, Livewire v3 - 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 diff --git a/app/Filament/Pages/InventoryCoverage.php b/app/Filament/Pages/InventoryCoverage.php index 74b888d..39c8b34 100644 --- a/app/Filament/Pages/InventoryCoverage.php +++ b/app/Filament/Pages/InventoryCoverage.php @@ -2,6 +2,7 @@ namespace App\Filament\Pages; +use App\Services\Inventory\CoverageCapabilitiesResolver; use BackedEnum; use Filament\Pages\Page; use UnitEnum; @@ -19,12 +20,36 @@ class InventoryCoverage extends Page /** * @var array> */ - public array $supportedTypes = []; + public array $supportedPolicyTypes = []; + + /** + * @var array> + */ + public array $foundationTypes = []; public function mount(): void { - $types = config('tenantpilot.supported_policy_types', []); + $policyTypes = config('tenantpilot.supported_policy_types', []); + $foundationTypes = config('tenantpilot.foundation_types', []); - $this->supportedTypes = is_array($types) ? $types : []; + $resolver = app(CoverageCapabilitiesResolver::class); + + $this->supportedPolicyTypes = collect(is_array($policyTypes) ? $policyTypes : []) + ->map(function (array $row) use ($resolver): array { + $type = (string) ($row['type'] ?? ''); + + return array_merge($row, [ + 'dependencies' => $type !== '' && $resolver->supportsDependencies($type), + ]); + }) + ->all(); + + $this->foundationTypes = collect(is_array($foundationTypes) ? $foundationTypes : []) + ->map(function (array $row): array { + return array_merge($row, [ + 'dependencies' => false, + ]); + }) + ->all(); } } diff --git a/app/Filament/Pages/InventoryLanding.php b/app/Filament/Pages/InventoryLanding.php index 19d7265..71418b3 100644 --- a/app/Filament/Pages/InventoryLanding.php +++ b/app/Filament/Pages/InventoryLanding.php @@ -90,6 +90,13 @@ protected function getHeaderActions(): array new \App\Rules\SupportedPolicyTypesRule, ]) ->columnSpanFull(), + Toggle::make('include_foundations') + ->label('Include foundation types') + ->helperText('Include scope tags, assignment filters, and notification templates.') + ->default(true) + ->dehydrated() + ->rules(['boolean']) + ->columnSpanFull(), Toggle::make('include_dependencies') ->label('Include dependencies') ->helperText('Include dependency extraction where supported.') @@ -135,6 +142,9 @@ protected function getHeaderActions(): array if (array_key_exists('policy_types', $data)) { $selectionPayload['policy_types'] = $data['policy_types']; } + if (array_key_exists('include_foundations', $data)) { + $selectionPayload['include_foundations'] = (bool) $data['include_foundations']; + } if (array_key_exists('include_dependencies', $data)) { $selectionPayload['include_dependencies'] = (bool) $data['include_dependencies']; } diff --git a/app/Filament/Resources/InventoryItemResource.php b/app/Filament/Resources/InventoryItemResource.php index 564c73f..4898bf6 100644 --- a/app/Filament/Resources/InventoryItemResource.php +++ b/app/Filament/Resources/InventoryItemResource.php @@ -100,10 +100,6 @@ public static function infolist(Schema $schema): Schema } return $resolver->attachRenderedTargets($edges->take(100), $tenant); // both directions combined - - return $edges->take(100); // both directions combined - - return $resolver->attachRenderedTargets($edges->take(100), $tenant); // both directions combined }) ->columnSpanFull(), ]) @@ -123,12 +119,12 @@ public static function infolist(Schema $schema): Schema public static function table(Table $table): Table { - $typeOptions = collect(config('tenantpilot.supported_policy_types', [])) + $typeOptions = collect(static::allTypeMeta()) ->mapWithKeys(fn (array $row) => [($row['type'] ?? null) => ($row['label'] ?? $row['type'] ?? null)]) ->filter(fn ($label, $type) => is_string($type) && $type !== '' && is_string($label) && $label !== '') ->all(); - $categoryOptions = collect(config('tenantpilot.supported_policy_types', [])) + $categoryOptions = collect(static::allTypeMeta()) ->mapWithKeys(fn (array $row) => [($row['category'] ?? null) => ($row['category'] ?? null)]) ->filter(fn ($value, $key) => is_string($key) && $key !== '') ->all(); @@ -194,7 +190,21 @@ private static function typeMeta(?string $type): array return []; } - return collect(config('tenantpilot.supported_policy_types', [])) + return collect(static::allTypeMeta()) ->firstWhere('type', $type) ?? []; } + + /** + * @return array> + */ + private static function allTypeMeta(): array + { + $supported = config('tenantpilot.supported_policy_types', []); + $foundations = config('tenantpilot.foundation_types', []); + + return array_merge( + is_array($supported) ? $supported : [], + is_array($foundations) ? $foundations : [], + ); + } } diff --git a/app/Services/Inventory/CoverageCapabilitiesResolver.php b/app/Services/Inventory/CoverageCapabilitiesResolver.php new file mode 100644 index 0000000..217549e --- /dev/null +++ b/app/Services/Inventory/CoverageCapabilitiesResolver.php @@ -0,0 +1,25 @@ +supportedTypeConfigByType(); - foreach ($normalizedSelection['policy_types'] as $policyType) { + $policyTypes = $normalizedSelection['policy_types'] ?? []; + $foundationTypes = $this->foundationTypes(); + + if ((bool) ($normalizedSelection['include_foundations'] ?? false)) { + $policyTypes = array_values(array_unique(array_merge($policyTypes, $foundationTypes))); + } else { + $policyTypes = array_values(array_diff($policyTypes, $foundationTypes)); + } + + foreach ($policyTypes as $policyType) { $typeConfig = $typesConfig[$policyType] ?? null; if (! is_array($typeConfig)) { @@ -505,8 +514,16 @@ private function supportedTypeConfigByType(): array /** @var array> $supported */ $supported = config('tenantpilot.supported_policy_types', []); + /** @var array> $foundations */ + $foundations = config('tenantpilot.foundation_types', []); + + $all = array_merge( + is_array($supported) ? $supported : [], + is_array($foundations) ? $foundations : [], + ); + $byType = []; - foreach ($supported as $config) { + foreach ($all as $config) { $type = $config['type'] ?? null; if (is_string($type) && $type !== '') { $byType[$type] = $config; @@ -516,6 +533,23 @@ private function supportedTypeConfigByType(): array return $byType; } + /** + * @return list + */ + private function foundationTypes(): array + { + $types = config('tenantpilot.foundation_types', []); + if (! is_array($types)) { + return []; + } + + return collect($types) + ->map(fn (array $row) => $row['type'] ?? null) + ->filter(fn ($type) => is_string($type) && $type !== '') + ->values() + ->all(); + } + private function selectionLockKey(Tenant $tenant, string $selectionHash): string { return sprintf('inventory_sync:tenant:%s:selection:%s', (string) $tenant->getKey(), $selectionHash); diff --git a/resources/views/filament/pages/inventory-coverage.blade.php b/resources/views/filament/pages/inventory-coverage.blade.php index af10dd3..5e2b181 100644 --- a/resources/views/filament/pages/inventory-coverage.blade.php +++ b/resources/views/filament/pages/inventory-coverage.blade.php @@ -1,5 +1,6 @@ +
Policies
@@ -7,16 +8,48 @@ + - @foreach ($supportedTypes as $row) + @foreach ($supportedPolicyTypes as $row) + + + + + @endforeach + +
Type Label CategoryDependencies Restore Risk
{{ $row['type'] ?? '' }} {{ $row['label'] ?? '' }} {{ $row['category'] ?? '' }}{{ ($row['dependencies'] ?? false) ? '✅' : '—' }}{{ $row['restore'] ?? 'enabled' }}{{ $row['risk'] ?? 'normal' }}
+
+
+ + +
Foundations
+
+ + + + + + + + + + + + + @foreach ($foundationTypes as $row) + + + + + diff --git a/specs/047-inventory-foundations-nodes/checklists/requirements.md b/specs/047-inventory-foundations-nodes/checklists/requirements.md new file mode 100644 index 0000000..408fb41 --- /dev/null +++ b/specs/047-inventory-foundations-nodes/checklists/requirements.md @@ -0,0 +1,28 @@ +# Requirements Checklist — Foundations in Inventory (047) + +## Constitution Gates + +- [x] Tenant isolation: all foundation sync reads/writes are scoped to Tenant::current()/tenant_id (no leakage). +- [x] No snapshot/backup side effects: Inventory sync must not write to policy_versions/backup_* tables. +- [x] Config-driven types: foundation types are sourced from config('tenantpilot.foundation_types') only (no hardcoded lists). +- [x] No UI Graph calls: Inventory/Dependencies UI must render using DB-only resolution (no runtime Graph/Entra lookups). +- [x] Idempotency: re-running sync does not create duplicates; last_seen_at/last_seen_run_id update deterministically. +- [x] Data minimization: foundation meta_jsonb is sanitized (stored == InventoryMetaSanitizer::sanitize(stored)). +- [x] Observability: InventorySyncRun observed/upserted counts include foundations when enabled, exclude when disabled. +- [x] Tests exist and were executed (targeted at minimum). + +## Feature 047 Functional Coverage + +- [x] FR-001 Foundation types MVP are synced when include_foundations=true (roleScopeTag, assignmentFilter, notificationMessageTemplate). +- [x] FR-002 include_foundations=false produces no foundation node sync side effects. +- [x] FR-003 Foundation nodes stored as InventoryItems with stable identity (tenant_id + policy_type + external_id). +- [x] FR-004 Inventory Coverage UI shows Policies + Foundations. +- [x] FR-COV-DEP: Coverage shows deterministic Dependencies support column (✅/—) derived from existing capabilities (no Graph calls). +- [x] FR-005 Inventory Items UI can filter/browse foundations. + +## Test Gates + +- [x] T020/T021: include_foundations on/off behavior is covered by feature tests. +- [x] T023: foundation meta_jsonb sanitized invariant (no payload dump). +- [x] T024: run counts include/exclude foundations (deterministic setup). +- [x] Pint run (T020) and targeted tests run (T021). diff --git a/specs/047-inventory-foundations-nodes/contracts/inventory-coverage.schema.json b/specs/047-inventory-foundations-nodes/contracts/inventory-coverage.schema.json new file mode 100644 index 0000000..6fc9165 --- /dev/null +++ b/specs/047-inventory-foundations-nodes/contracts/inventory-coverage.schema.json @@ -0,0 +1,33 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://tenantpilot.local/contracts/inventory-coverage.schema.json", + "title": "Inventory Coverage View Model", + "type": "object", + "additionalProperties": false, + "properties": { + "supportedPolicyTypes": { + "type": "array", + "items": { "$ref": "#/$defs/typeMeta" } + }, + "foundationTypes": { + "type": "array", + "items": { "$ref": "#/$defs/typeMeta" } + } + }, + "required": ["supportedPolicyTypes", "foundationTypes"], + "$defs": { + "typeMeta": { + "type": "object", + "additionalProperties": true, + "properties": { + "type": { "type": "string" }, + "label": { "type": "string" }, + "category": { "type": "string" }, + "platform": { "type": "string" }, + "restore": { "type": "string" }, + "risk": { "type": "string" } + }, + "required": ["type", "label", "category"] + } + } +} diff --git a/specs/047-inventory-foundations-nodes/contracts/inventory-selection.schema.json b/specs/047-inventory-foundations-nodes/contracts/inventory-selection.schema.json new file mode 100644 index 0000000..5e3d2f5 --- /dev/null +++ b/specs/047-inventory-foundations-nodes/contracts/inventory-selection.schema.json @@ -0,0 +1,29 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://tenantpilot.local/contracts/inventory-selection.schema.json", + "title": "Inventory Selection Payload", + "type": "object", + "additionalProperties": true, + "properties": { + "policy_types": { + "type": "array", + "items": { "type": "string" }, + "default": [] + }, + "categories": { + "type": "array", + "items": { "type": "string" }, + "default": [] + }, + "include_foundations": { + "type": "boolean", + "default": true, + "description": "When true, the sync run includes all configured foundation types in addition to selected policy types." + }, + "include_dependencies": { + "type": "boolean", + "default": true + } + }, + "required": ["policy_types", "categories", "include_foundations", "include_dependencies"] +} diff --git a/specs/047-inventory-foundations-nodes/data-model.md b/specs/047-inventory-foundations-nodes/data-model.md new file mode 100644 index 0000000..3979976 --- /dev/null +++ b/specs/047-inventory-foundations-nodes/data-model.md @@ -0,0 +1,39 @@ +# Phase 1 Data Model: Foundations in Inventory (047) + +## Existing Entities + +### `InventoryItem` +Represents the last observed state for a tenant-scoped Intune object. + +**Key fields (existing schema)** +- `tenant_id` (FK) +- `policy_type` (string) +- `external_id` (string) +- `display_name` (nullable string) +- `category` (nullable string) +- `platform` (nullable string) +- `meta_jsonb` (jsonb, safe/whitelisted) +- `last_seen_at` (timestamp) +- `last_seen_run_id` (FK to `InventorySyncRun`) + +## New Semantics (no schema change) + +### Foundation Nodes +A foundation node is an `InventoryItem` where: +- `policy_type` is one of `config('tenantpilot.foundation_types')[*].type` +- `category` is set from the foundation type config (MVP expects `Foundations`) + +**In-scope foundation types (MVP)** +- `roleScopeTag` (Scope Tag) +- `assignmentFilter` (Assignment Filter) +- `notificationMessageTemplate` (Notification Message Template) + +## Relationships + +- Tenant isolation: all foundation nodes are strictly tenant-scoped. +- Dependencies (Spec 042) can reference foundations via edges, and Spec 042.2 can resolve their display names locally by matching `(tenant_id, policy_type, external_id)`. + +## Validation / Invariants + +- Idempotency: repeated sync runs update `last_seen_at` and `last_seen_run_id` deterministically without creating duplicates. +- Data minimization: foundation sync must not store non-whitelisted payload data in `meta_jsonb`. diff --git a/specs/047-inventory-foundations-nodes/plan.md b/specs/047-inventory-foundations-nodes/plan.md new file mode 100644 index 0000000..ce2169f --- /dev/null +++ b/specs/047-inventory-foundations-nodes/plan.md @@ -0,0 +1,128 @@ +# Implementation Plan: Foundations in Inventory (047) + +**Branch**: `feat/047-inventory-foundations-nodes` | **Date**: 2026-01-10 | **Spec**: `specs/047-inventory-foundations-nodes/spec.md` +**Input**: Feature specification from `specs/047-inventory-foundations-nodes/spec.md` + +**Note**: This template is filled in by the `/speckit.plan` command. See `.specify/scripts/` for helper scripts. + +## Summary + +When `include_foundations=true`, inventory sync includes all configured foundation types (`roleScopeTag`, `assignmentFilter`, `notificationMessageTemplate`) as `InventoryItem` records for the tenant. Inventory Coverage and Inventory Items UI surface foundations alongside policies, enabling Spec 042.2 dependency target name resolution from the local DB (no UI Graph lookups). + +## Technical Context + + + +**Language/Version**: PHP 8.4.x (Laravel 12) +**Primary Dependencies**: Laravel 12, Filament v4, Livewire v3 +**Storage**: PostgreSQL (JSONB for `InventoryItem.meta_jsonb`) +**Testing**: Pest v4 + PHPUnit; formatting via Pint +**Target Platform**: Web admin app (Filament) + queued jobs (Sail-first locally) +**Project Type**: Web application +**Performance Goals**: No new explicit perf goals; foundations are small cardinality and must not introduce N+1 or full-table loads. +**Constraints**: Must preserve inventory sync idempotency, locks, and run observability; no UI-time Graph calls for name resolution. +**Scale/Scope**: Tenant-scoped inventory; foundations expected to be small compared to policies. + +## Constitution Check + +*GATE: Must pass before Phase 0 research. Re-check after Phase 1 design.* + +- Inventory-first: clarify what is “last observed” vs snapshots/backups +- Read/write separation: any writes require preview + confirmation + audit + tests +- Graph contract path: Graph calls only via `GraphClientInterface` + `config/graph_contracts.php` +- Deterministic capabilities: capability derivation is testable (snapshot/golden tests) +- Tenant isolation: all reads/writes tenant-scoped; cross-tenant views are explicit and access-checked +- Automation: queued/scheduled ops are locked, idempotent, observable; handle 429/503 with backoff+jitter +- Data minimization: Inventory stores metadata + whitelisted meta; logs contain no secrets/tokens + +**Result**: PASS (no violations). + +- Inventory-first: foundations become `InventoryItem` “last observed” state. +- Read/write separation: sync remains read-only (Graph reads only). +- Graph contract path: foundation types are already represented in `config/graph_contracts.php` and accessed via `GraphClientInterface`. +- Tenant isolation: all upserts keyed by `(tenant_id, policy_type, external_id)`. +- Data minimization: still uses `InventoryMetaSanitizer` to store only safe subset. + +## Project Structure + +### Documentation (this feature) + +```text +specs/[###-feature]/ +├── plan.md # This file (/speckit.plan command output) +├── research.md # Phase 0 output (/speckit.plan command) +├── data-model.md # Phase 1 output (/speckit.plan command) +├── quickstart.md # Phase 1 output (/speckit.plan command) +├── contracts/ # Phase 1 output (/speckit.plan command) +└── tasks.md # Phase 2 output (/speckit.tasks command - NOT created by /speckit.plan) +``` + +### Source Code (repository root) + + +```text +app/ +├── Filament/ +│ ├── Pages/ +│ │ └── InventoryCoverage.php +│ └── Resources/ +│ └── InventoryItemResource.php +└── Services/ + └── Inventory/ + └── InventorySyncService.php + +config/ +├── tenantpilot.php +└── graph_contracts.php + +resources/ +└── views/ + └── filament/ + └── pages/ + └── inventory-coverage.blade.php + +tests/ +└── Feature/ + ├── Filament/ + │ └── InventoryPagesTest.php + └── Inventory/ + └── InventorySyncServiceTest.php +``` + +**Structure Decision**: Web application (Laravel + Filament). No new directories introduced. + +## Complexity Tracking + +> **Fill ONLY if Constitution Check has violations that must be justified** + +N/A (no constitution violations). + +## Phase 0: Research Output + +- Generated: `specs/047-inventory-foundations-nodes/research.md` +- Key outcomes: + - Foundations are synced via the existing inventory sync flow. + - Graph contracts already cover the three foundation types. + +## Phase 1: Design & Contracts Output + +- Data model: `specs/047-inventory-foundations-nodes/data-model.md` +- Contracts: + - `specs/047-inventory-foundations-nodes/contracts/inventory-selection.schema.json` + - `specs/047-inventory-foundations-nodes/contracts/inventory-coverage.schema.json` +- Quickstart: `specs/047-inventory-foundations-nodes/quickstart.md` + +## Phase 2: Implementation Checklist (high level) + +- Inventory sync respects `include_foundations` selection semantics. +- Foundations appear in Inventory Items list (filterable) and Coverage page. +- Tests cover tenant isolation + include_foundations on/off behavior. diff --git a/specs/047-inventory-foundations-nodes/quickstart.md b/specs/047-inventory-foundations-nodes/quickstart.md new file mode 100644 index 0000000..df355e5 --- /dev/null +++ b/specs/047-inventory-foundations-nodes/quickstart.md @@ -0,0 +1,32 @@ +# Quickstart: Foundations in Inventory (047) + +## Goal +Verify that foundations are synced as `InventoryItem` records when `include_foundations=true`, and are not synced when `include_foundations=false`. + +## Preconditions +- Local dev via Sail is running. +- You have at least one tenant configured with valid Graph credentials. + +## Steps + +1. Run an inventory sync with foundations enabled + - Use the Inventory UI sync button (Spec 046) with `include_foundations=true`, or run the relevant flow in your preferred admin path. + +2. Confirm foundation nodes exist + - In Inventory Items, filter `Category = Foundations`. + - Confirm you can find: + - Scope tags (`roleScopeTag`) + - Assignment filters (`assignmentFilter`) + - Notification templates (`notificationMessageTemplate`) + +3. Confirm coverage shows both matrices + - Open Inventory → Coverage. + - Confirm both headings are present: “Policies” and “Foundations”. + +4. Confirm name resolution (Spec 042.2) + - Open a policy that references scope tags or assignment filters. + - In the Dependencies section, confirm targets render with resolved display names sourced from local inventory. + +## Troubleshooting +- If foundation types do not appear, confirm they exist in `config('tenantpilot.foundation_types')` and have Graph contracts in `config/graph_contracts.php`. +- If UI doesn’t reflect changes, rebuild assets if needed (`npm run dev` / `composer run dev`) and confirm cache is cleared. diff --git a/specs/047-inventory-foundations-nodes/research.md b/specs/047-inventory-foundations-nodes/research.md new file mode 100644 index 0000000..236c14b --- /dev/null +++ b/specs/047-inventory-foundations-nodes/research.md @@ -0,0 +1,36 @@ +# Phase 0 Research: Foundations in Inventory (047) + +## Context + +This feature makes specific Intune “foundation” objects first-class `InventoryItem` records so the Dependencies UI (Spec 042.2) can resolve names from the local DB (no UI-time Graph calls). + +Foundation types in scope are already configured in `config('tenantpilot.foundation_types')`: +- `roleScopeTag` +- `assignmentFilter` +- `notificationMessageTemplate` + +Graph contract registry entries exist for all three types in `config/graph_contracts.php` under `types.*.resource`. + +## Decisions + +### Decision: Reuse existing Inventory sync flow (`InventorySyncService`) to sync foundation types +- **Rationale**: Keeps inventory the “last observed” source of truth, preserves existing locking/idempotency/run observability, and avoids duplicating Graph pagination/retry behavior. +- **Alternatives considered**: + - Separate “Foundations sync” job/service: rejected because it would duplicate run bookkeeping and selection hashing semantics. + - UI-time resolution via Graph: rejected (explicitly out of scope; violates Spec 042.2 / FR-006). + +### Decision: Treat foundation types as regular `policy_type` values with a stable `category=Foundations` +- **Rationale**: No schema change; Inventory UI and filters already understand `category` and `policy_type`. +- **Alternatives considered**: + - New table for foundations: rejected (adds joins and complexity; not needed for MVP). + +### Decision: Coverage UI presents two matrices (Policies + Foundations) +- **Rationale**: Makes support surface explicit and avoids mixing foundational types into policy-type rows. +- **Alternatives considered**: + - Single combined coverage table with category filter: rejected for MVP clarity. + +## Clarifications Resolved + +- **Selection semantics**: `include_foundations=true` means “always sync all configured foundation types in addition to selected policy types”. `include_foundations=false` means “never sync foundation types (even if explicitly present in `policy_types`)”. +- **Graph contracts**: Foundation types must be represented in `config/graph_contracts.php` so they are handled via the single contract path. +- **Data minimization**: Only safe, whitelisted `meta_jsonb` is stored (no raw payload dump). diff --git a/specs/047-inventory-foundations-nodes/spec.md b/specs/047-inventory-foundations-nodes/spec.md new file mode 100644 index 0000000..79f2356 --- /dev/null +++ b/specs/047-inventory-foundations-nodes/spec.md @@ -0,0 +1,164 @@ +# Feature Specification: Foundations in Inventory (047) + +**Feature Branch**: `feat/047-inventory-foundations-nodes` +**Created**: 2026-01-10 +**Status**: Draft + +## Purpose + +Make foundational Intune objects (Scope Tags, Assignment Filters, Notification Templates) first-class Inventory nodes so: +- Dependency name resolution (Spec 042.2) can resolve display names locally +- Inventory coverage can communicate both **Policies** and **Foundations** +- Sync behavior matches selection flags (`include_foundations=true`) + +## Clarifications + +### Session 2026-01-10 + +- Q: How should NFR-002 (Data minimization) be defined/tested? → A: Define it as `meta_jsonb == InventoryMetaSanitizer::sanitize(meta_jsonb)` and `json_encode(meta_jsonb)` must not contain `Bearer `. +- Q: Should “no UI Graph calls” be enforced by an automated test guard? → A: Yes — add a test that fails if any UI rendering/resolution path calls `GraphClientInterface`. +- Q: What should happen if a foundation object is not returned by Graph in a later run? → A: Do not delete InventoryItem rows; treat Inventory as “last observed” and let entries become stale (no `last_seen_*` update). +- Q: Should `include_foundations=false` hide/purge foundations in the UI? → A: No — it only controls what this run syncs and counts; foundations remain visible if they exist and may become stale via `last_seen_*`. +- Q: How should unresolved dependency targets be displayed (without Graph calls)? → A: Show `Unresolved ()` so missing foundations are visible for debugging. + +## Scope + +### In scope (MVP) +- Sync foundational object types as InventoryItems when `include_foundations=true` +- Show foundations in Inventory UI (items list + coverage) +- Enable local resolution in 042.2 (no additional Graph calls from the UI) + +### Out of scope +- Entra group inventory / group name resolution +- Additional foundation types beyond the initial list (can be extended later) +- Any Intune write paths (create/update/delete) + +## Users + +- Tenant Admin (primary) +- MSP Operator (read-only cross-tenant later; not required here) + +## Terminology + +- **Policy Nodes**: InventoryItems whose `policy_type` is in `tenantpilot.supported_policy_types` +- **Foundation Nodes**: InventoryItems whose `policy_type` is in `tenantpilot.foundation_types` +- **Edges**: Dependency relationships stored in `inventory_links` (Spec 042) + +## User Scenarios & Testing + +### Scenario 1: Sync policies + foundations +Given I start an inventory sync with `include_foundations=true` +When the sync completes successfully +Then foundation nodes (scope tags, assignment filters, templates) exist as InventoryItems for the tenant. + +### Scenario 2: Resolve dependency names +Given an InventoryItem has dependencies referencing scope tags/assignment filters +When I view the item’s dependencies +Then the UI shows the resolved display names (local DB) instead of unresolved targets. +If a dependency target cannot be resolved locally, the UI MUST display `Unresolved ()` (no Graph calls). + +### Scenario 3: Inventory browsing +Given Inventory Items contain both policies and foundations +When I filter inventory to “Foundations” +Then I only see foundation nodes (and can search by name). + +### Scenario 4: Coverage communication +When I open Inventory Coverage +Then I can view both “Policies” and “Foundations” support matrices. + +## Functional Requirements + +### FR-001 Foundations types (MVP) +System MUST support syncing the following foundation policy_types as InventoryItems: +- `roleScopeTag` (Scope Tags) +- `assignmentFilter` (Assignment Filters) +- `notificationMessageTemplate` (Notification Message Templates) + +Source of truth: `config('tenantpilot.foundation_types')`. + +### FR-002 Selection behavior +If `include_foundations=true`, an inventory sync run MUST: +- sync selected policy types +- AND sync all foundation types from `tenantpilot.foundation_types` for the tenant + +If `include_foundations=false`, foundation types MUST NOT be synced as inventory items. + +`include_foundations` only controls what the run observes/upserts (and therefore run counts); it MUST NOT purge existing foundation InventoryItems or “magically” hide them in the UI. + +### FR-003 InventoryItems shape +Foundation nodes MUST be stored as InventoryItems using the existing schema: +- `tenant_id`, `policy_type`, `external_id`, `display_name`, `category`, `platform`, `meta_jsonb`, `last_seen_at`, `last_seen_run_id` + +Foundation nodes MUST be stored as InventoryItems and MUST have: +- `policy_type` set to the foundation type key (e.g. `roleScopeTag`, `assignmentFilter`, `notificationMessageTemplate`) +- `category` set to the literal string `Foundations` (used for UI filtering/presets) + +### FR-004 Inventory Coverage UI +Coverage page MUST present: +- “Policies” table (existing behavior) +- “Foundations” table (new; derived from `tenantpilot.foundation_types`) + +#### FR-COV-DEP-001 Dependencies column +Coverage MUST display an additional column: +- Header: `Dependencies` +- Value: `✅` or `—` + +#### FR-COV-DEP-002 Deterministic derivation +The `Dependencies` value MUST be derived deterministically from existing capabilities (config/contracts) only: + +`✅` if at least one holds: +- the type supports Assignments extraction, or +- the type supports Scope Tags, or +- the type can reference Assignment Filters, or +- the type has dependency extraction rules in Spec 042 (relationship taxonomy / extractor mapping) + +Otherwise: `—`. + +This is **feature support**, not “Graph supports $expand”. + +MVP decision: +- For foundation types, default to `—`. + +### FR-005 Inventory Items UI +Inventory Items list MUST allow: +- filtering to Foundations (e.g., Category = Foundations) +- searching by display name +- viewing details (existing view) + +### FR-006 No extra Graph calls in UI +The UI MUST NOT perform Graph lookups for foundation name resolution. Resolution MUST come from local InventoryItems. + +This MUST be enforced by an automated test that fails if any UI rendering/resolution path calls `GraphClientInterface`. + +## Non-Functional Requirements + +### NFR-001 Tenant isolation +All reads/writes MUST be tenant-scoped and covered by tests. + +### NFR-002 Data minimization +Foundation sync MUST store only a safe subset of metadata consistent with Inventory rules: +- For any stored InventoryItem, `meta_jsonb` MUST equal `InventoryMetaSanitizer::sanitize(meta_jsonb)`. +- `json_encode(meta_jsonb)` MUST NOT contain `Bearer `. + +### NFR-003 Idempotency +Re-running foundation sync MUST be idempotent (no duplicates) and update `last_seen_at`/`last_seen_run_id` deterministically. + +The sync MUST NOT delete InventoryItem rows when objects are not observed in a run; absence is treated as “not observed” (e.g., permission/scope/transient failure) and becomes stale via `last_seen_at`/run evaluation. + +### NFR-004 Observability +Sync run record MUST be accurate: +- counts include foundations when `include_foundations=true` +- warnings/errors are persisted on the run record as per Inventory conventions + +## Success Criteria + +- SC1: After a foundations-enabled sync, dependencies for scope_tag/assignment_filter render as resolved for the majority of items that reference them. +- SC2: Inventory Coverage clearly communicates what is supported for “Policies” vs “Foundations”. +- SC3: No new permissions beyond existing foundation read scopes are required for this feature. + +## Related Specs + +- Core Inventory: `specs/040-inventory-core/spec.md` +- Inventory UI: `specs/041-inventory-ui/spec.md` +- Dependencies Graph: `specs/042-inventory-dependencies-graph/spec.md` +- Inventory Sync Button: `specs/046-inventory-sync-button/spec.md` diff --git a/specs/047-inventory-foundations-nodes/tasks.md b/specs/047-inventory-foundations-nodes/tasks.md new file mode 100644 index 0000000..e2c0324 --- /dev/null +++ b/specs/047-inventory-foundations-nodes/tasks.md @@ -0,0 +1,139 @@ +# Tasks: Foundations in Inventory (047) + +**Input**: Design documents from `specs/047-inventory-foundations-nodes/` + +**Tests**: REQUIRED (Pest) because this feature changes runtime behavior. + +## User Stories (Prioritized) + +- **User Story 1 (P1) — Sync policies + foundations**: When `include_foundations=true`, foundations are synced as tenant-scoped `InventoryItem` rows; when false, they are not synced. +- **User Story 2 (P2) — Inventory browsing**: Inventory Items list can be filtered to “Foundations” and searched by name. +- **User Story 3 (P2) — Coverage communication**: Coverage page presents separate “Policies” and “Foundations” matrices. +- **User Story 4 (P3) — Resolve dependency names**: Dependencies UI resolves foundation names via local `InventoryItem` rows only (no UI Graph calls). + +--- + +## Phase 1: Setup (Shared Infrastructure) + +- [ ] T001 Confirm foundation types are configured in config/tenantpilot.php (`foundation_types`) and foundations are categorized as "Foundations" in Inventory +- [ ] T002 Confirm Graph contract registry includes resources for `assignmentFilter`, `roleScopeTag`, `notificationMessageTemplate` in config/graph_contracts.php +- [ ] T003 [P] Confirm Inventory selection payload schema includes `include_foundations` (specs/047-inventory-foundations-nodes/contracts/inventory-selection.schema.json) + +--- + +## Phase 2: Foundational (Blocking Prerequisites) + +- [ ] T004 Ensure inventory sync selection normalization retains include_foundations semantics in app/Services/Inventory/InventorySelectionHasher.php +- [ ] T005 Ensure inventory sync run observability/count fields remain accurate when adding foundations in app/Services/Inventory/InventorySyncService.php + +--- + +## Phase 3: User Story 1 — Sync policies + foundations (Priority: P1) 🎯 MVP + +**Goal**: Foundation objects exist as `InventoryItem` rows when foundations are included. + +**Independent Test Criteria**: +- Run sync with `include_foundations=true` and see foundation `InventoryItem` rows for the tenant. +- Run sync with `include_foundations=false` and see no foundation `InventoryItem` rows (even if a foundation type appears in `policy_types`). + +- [ ] T006 [US1] Implement foundation-type inclusion/exclusion based on `include_foundations` in app/Services/Inventory/InventorySyncService.php +- [ ] T007 [US1] Ensure type metadata lookup merges supported policy types + foundation types for sync category/platform assignment in app/Services/Inventory/InventorySyncService.php +- [ ] T008 [P] [US1] Add Pest test for include_foundations=true foundation upserts in tests/Feature/Inventory/InventorySyncServiceTest.php +- [ ] T009 [P] [US1] Add Pest test for include_foundations=false excludes foundations even when selected in tests/Feature/Inventory/InventorySyncServiceTest.php +- [ ] T010 [US1] Verify idempotency: repeated sync updates last_seen_* without duplicate rows for foundation types in tests/Feature/Inventory/InventorySyncServiceTest.php + +--- + +## Phase 4: User Story 2 — Inventory browsing (Priority: P2) + +**Goal**: Inventory list can filter to foundations and show correct labels. + +**Independent Test Criteria**: +- Inventory Items table offers Category filter values including `Foundations`. +- Filtering Category=Foundations returns only foundation items. + +- [ ] T011 [US2] Update type label/category metadata resolution to include foundations in app/Filament/Resources/InventoryItemResource.php +- [ ] T012 [US2] Update table filter option lists to include foundation categories/types in app/Filament/Resources/InventoryItemResource.php +- [ ] T013 [P] [US2] Add Pest test asserting Inventory Items list page loads and includes Foundations category filter option (or equivalent rendered text) in tests/Feature/Filament/InventoryPagesTest.php + +--- + +## Phase 5: User Story 3 — Coverage communication (Priority: P2) + +**Goal**: Coverage clearly separates Policies vs Foundations. + +**Independent Test Criteria**: +- Coverage page renders headings “Policies” and “Foundations”. +- Foundations table rows are derived from `config('tenantpilot.foundation_types')`. + +- [ ] T014 [US3] Update Coverage page view-model to expose supported policy types + foundation types in app/Filament/Pages/InventoryCoverage.php +- [ ] T015 [US3] Update Coverage Blade view to render two tables in resources/views/filament/pages/inventory-coverage.blade.php +- [ ] T016 [P] [US3] Add/adjust Pest test assertions for both headings in tests/Feature/Filament/InventoryPagesTest.php + +### Coverage Dependencies Support (UI-only) + +- [x] T026 [US3] Add Coverage table column `Dependencies` (✅/—) in resources/views/filament/pages/inventory-coverage.blade.php +- [x] T027 [US3] Add deterministic resolver CoverageCapabilitiesResolver::supportsDependencies($type) (contracts/config derived) + unit test in tests/Unit/CoverageCapabilitiesResolverTest.php +- [x] T028 [P] [US3] Update Pest UI/feature test to assert Coverage renders `Dependencies` column and at least one ✅ in tests/Feature/Filament/InventoryPagesTest.php + +--- + +## Phase 6: User Story 4 — Resolve dependency names (Priority: P3) + +**Goal**: Dependencies UI shows resolved names for foundations using local DB inventory items. + +**Independent Test Criteria**: +- Given an edge referencing a scope tag or assignment filter, the dependencies UI shows the resolved display name when a matching foundation `InventoryItem` exists. +- UI performs no Graph calls for resolution (DB-only resolver path). + +- [ ] T017 [US4] Ensure dependency name resolution uses DB-only resolver (DependencyQueryService + DependencyTargets\\DependencyTargetResolver + DependencyTargets\\FoundationTypeMap) and does not call Graph client during rendering +- [ ] T018 [P] [US4] Add/adjust resolver unit test for foundation resolution via InventoryItem rows in tests/Unit/DependencyTargetResolverTest.php +- [ ] T019 [P] [US4] Add/adjust feature test validating dependencies view renders resolved foundation names (tenant-scoped) in tests/Feature/InventoryItemDependenciesTest.php + +--- + +## Phase 7: Polish & Cross-Cutting Concerns + +- [x] T020 [P] Run formatting on changed files with ./vendor/bin/pint --dirty +- [x] T021 Run targeted tests for this feature with ./vendor/bin/sail test tests/Feature/Inventory/InventorySyncServiceTest.php tests/Feature/Filament/InventoryPagesTest.php +- [ ] T022 [P] Validate manual quickstart steps in specs/047-inventory-foundations-nodes/quickstart.md +- [x] T023 [P] [US1] Add feature test: foundation InventoryItem meta_jsonb is sanitized (`stored == sanitizer->sanitize(stored)`) after sync (no payload dump) in tests/Feature/Inventory/InventorySyncServiceTest.php +- [x] T024 [P] [US1] Add feature test: InventorySyncRun observed/upserted counts include foundations when enabled (tenant A) and exclude them when disabled (tenant B) (deterministic) in tests/Feature/Inventory/InventorySyncServiceTest.php +- [x] T025 [P] [US4] Verified resolver references: dependencies UI queries edges via DependencyQueryService and renders targets via DependencyTargets\DependencyTargetResolver + DependencyTargets\FoundationTypeMap (text-only task) + +--- + +## Dependencies & Execution Order + +### Story Dependencies + +- **US1 (P1)** blocks all other stories (foundations must exist before they can be browsed/resolved). +- **US2 (P2)** depends on US1 (needs foundation data to browse meaningfully). +- **US3 (P2)** is config-driven but depends on US1 for end-to-end verification. +- **US4 (P3)** depends on US1 (needs foundation inventory items to resolve names). + +### Suggested MVP Scope + +- MVP = **Phase 3 (US1)** + **Phase 7 (T020–T021)**. + +--- + +## Parallel Execution Examples + +### Within US1 + +- Run in parallel: + - T008 (include_foundations=true test) + T009 (include_foundations=false test) + - Then implement T006–T007 and validate against T010 + +### Across Stories (after US1 complete) + +- US2 UI tasks (T011–T013) can proceed in parallel with US3 coverage tasks (T014–T016). + +--- + +## Format Validation + +- Every task line starts with `- [ ]` and includes a sequential TaskID (T001…) +- Story phases use `[US1]`…`[US4]` labels; Setup/Foundational/Polish have no story label +- Tasks marked `[P]` are parallelizable (different files / no blocking dependency) diff --git a/tests/Feature/Filament/InventoryPagesTest.php b/tests/Feature/Filament/InventoryPagesTest.php index 598e362..2ec5039 100644 --- a/tests/Feature/Filament/InventoryPagesTest.php +++ b/tests/Feature/Filament/InventoryPagesTest.php @@ -17,10 +17,15 @@ $this->actingAs($user) ->get(InventoryLanding::getUrl(tenant: $tenant)) - ->assertOk(); + ->assertOk() + ->assertSee('Run Inventory Sync'); $this->actingAs($user) ->get(InventoryCoverage::getUrl(tenant: $tenant)) ->assertOk() - ->assertSee('Coverage'); + ->assertSee('Coverage') + ->assertSee('Policies') + ->assertSee('Foundations') + ->assertSee('Dependencies') + ->assertSee('✅'); }); diff --git a/tests/Feature/Inventory/InventorySyncButtonTest.php b/tests/Feature/Inventory/InventorySyncButtonTest.php index 3b32a5f..6e11ec1 100644 --- a/tests/Feature/Inventory/InventorySyncButtonTest.php +++ b/tests/Feature/Inventory/InventorySyncButtonTest.php @@ -95,6 +95,56 @@ expect((bool) ($run->selection_payload['include_dependencies'] ?? true))->toBeFalse(); }); +it('defaults include foundations toggle to true and persists it into the run selection payload', function () { + Queue::fake(); + + [$user, $tenant] = createUserWithTenant(role: 'owner'); + $this->actingAs($user); + + $tenant->makeCurrent(); + Filament::setTenant($tenant, true); + + $sync = app(InventorySyncService::class); + $allTypes = $sync->defaultSelectionPayload()['policy_types']; + $selectedTypes = array_slice($allTypes, 0, min(2, count($allTypes))); + + Livewire::test(InventoryLanding::class) + ->mountAction('run_inventory_sync') + ->set('mountedActions.0.data.policy_types', $selectedTypes) + ->assertActionDataSet(['include_foundations' => true]) + ->callMountedAction() + ->assertHasNoActionErrors(); + + $run = InventorySyncRun::query()->where('tenant_id', $tenant->id)->latest('id')->first(); + expect($run)->not->toBeNull(); + expect((bool) ($run->selection_payload['include_foundations'] ?? false))->toBeTrue(); +}); + +it('persists include foundations toggle into the run selection payload', function () { + Queue::fake(); + + [$user, $tenant] = createUserWithTenant(role: 'owner'); + $this->actingAs($user); + + $tenant->makeCurrent(); + Filament::setTenant($tenant, true); + + $sync = app(InventorySyncService::class); + $allTypes = $sync->defaultSelectionPayload()['policy_types']; + $selectedTypes = array_slice($allTypes, 0, min(2, count($allTypes))); + + Livewire::test(InventoryLanding::class) + ->callAction('run_inventory_sync', data: [ + 'policy_types' => $selectedTypes, + 'include_foundations' => false, + ]) + ->assertHasNoActionErrors(); + + $run = InventorySyncRun::query()->where('tenant_id', $tenant->id)->latest('id')->first(); + expect($run)->not->toBeNull(); + expect((bool) ($run->selection_payload['include_foundations'] ?? true))->toBeFalse(); +}); + it('rejects cross-tenant initiation attempts (403) with no side effects', function () { Queue::fake(); diff --git a/tests/Feature/Inventory/InventorySyncServiceTest.php b/tests/Feature/Inventory/InventorySyncServiceTest.php index af437b4..2f89312 100644 --- a/tests/Feature/Inventory/InventorySyncServiceTest.php +++ b/tests/Feature/Inventory/InventorySyncServiceTest.php @@ -101,6 +101,177 @@ public function request(string $method, string $path, array $options = []): Grap expect($items->first()->last_seen_run_id)->toBe($runB->id); }); +test('inventory sync includes foundation types when include_foundations is true', function () { + $tenant = Tenant::factory()->create(); + + app()->instance(GraphClientInterface::class, fakeGraphClient([ + 'deviceConfiguration' => [], + 'roleScopeTag' => [ + ['id' => 'tag-1', 'displayName' => 'Scope Tag 1', '@odata.type' => '#microsoft.graph.roleScopeTag'], + ], + 'assignmentFilter' => [ + ['id' => 'filter-1', 'displayName' => 'Filter 1', '@odata.type' => '#microsoft.graph.assignmentFilter'], + ], + 'notificationMessageTemplate' => [ + ['id' => 'tmpl-1', 'displayName' => 'Template 1', '@odata.type' => '#microsoft.graph.notificationMessageTemplate'], + ], + ])); + + $service = app(InventorySyncService::class); + + $run = $service->syncNow($tenant, [ + 'policy_types' => ['deviceConfiguration'], + 'categories' => [], + 'include_foundations' => true, + 'include_dependencies' => false, + ]); + + expect($run->status)->toBe('success'); + + expect(\App\Models\InventoryItem::query() + ->where('tenant_id', $tenant->id) + ->where('policy_type', 'roleScopeTag') + ->where('external_id', 'tag-1') + ->where('category', 'Foundations') + ->exists())->toBeTrue(); + + expect(\App\Models\InventoryItem::query() + ->where('tenant_id', $tenant->id) + ->where('policy_type', 'assignmentFilter') + ->where('external_id', 'filter-1') + ->where('category', 'Foundations') + ->exists())->toBeTrue(); + + expect(\App\Models\InventoryItem::query() + ->where('tenant_id', $tenant->id) + ->where('policy_type', 'notificationMessageTemplate') + ->where('external_id', 'tmpl-1') + ->where('category', 'Foundations') + ->exists())->toBeTrue(); +}); + +test('inventory sync does not sync foundation types when include_foundations is false', function () { + $tenant = Tenant::factory()->create(); + + app()->instance(GraphClientInterface::class, fakeGraphClient([ + 'roleScopeTag' => [ + ['id' => 'tag-1', 'displayName' => 'Scope Tag 1', '@odata.type' => '#microsoft.graph.roleScopeTag'], + ], + ])); + + $service = app(InventorySyncService::class); + + $run = $service->syncNow($tenant, [ + 'policy_types' => ['roleScopeTag'], + 'categories' => [], + 'include_foundations' => false, + 'include_dependencies' => false, + ]); + + expect($run->status)->toBe('success'); + + expect(\App\Models\InventoryItem::query() + ->where('tenant_id', $tenant->id) + ->where('policy_type', 'roleScopeTag') + ->exists())->toBeFalse(); +}); + +test('foundation inventory items store sanitized meta_jsonb after sync (no payload dump)', function () { + $tenant = Tenant::factory()->create(); + + app()->instance(GraphClientInterface::class, fakeGraphClient([ + 'deviceConfiguration' => [], + 'roleScopeTag' => [ + [ + 'id' => 'tag-1', + 'displayName' => 'Scope Tag 1', + '@odata.type' => '#microsoft.graph.roleScopeTag', + 'veryLargePayload' => str_repeat('x', 10_000), + 'client_secret' => 'should-not-end-up-anywhere', + ], + ], + ])); + + $service = app(InventorySyncService::class); + + $run = $service->syncNow($tenant, [ + 'policy_types' => ['deviceConfiguration'], + 'categories' => [], + 'include_foundations' => true, + 'include_dependencies' => false, + ]); + + expect($run->status)->toBe('success'); + + $foundationItem = \App\Models\InventoryItem::query() + ->where('tenant_id', $tenant->id) + ->where('policy_type', 'roleScopeTag') + ->where('external_id', 'tag-1') + ->first(); + + expect($foundationItem)->not->toBeNull(); + + $stored = is_array($foundationItem->meta_jsonb) ? $foundationItem->meta_jsonb : []; + $sanitizer = app(InventoryMetaSanitizer::class); + + expect($stored)->toBe($sanitizer->sanitize($stored)); + expect(json_encode($stored))->not->toContain('Bearer '); + expect(json_encode($stored))->not->toContain('should-not-end-up-anywhere'); + expect(json_encode($stored))->not->toContain(str_repeat('x', 200)); +}); + +test('inventory sync run counts include foundations when enabled and exclude them when disabled (deterministic)', function () { + $tenantA = Tenant::factory()->create(); + $tenantB = Tenant::factory()->create(); + + app()->instance(GraphClientInterface::class, fakeGraphClient([ + 'deviceConfiguration' => [ + [ + 'id' => 'pol-1', + 'displayName' => 'Policy 1', + '@odata.type' => '#microsoft.graph.deviceConfiguration', + ], + ], + 'roleScopeTag' => [ + ['id' => 'tag-1', 'displayName' => 'Scope Tag 1', '@odata.type' => '#microsoft.graph.roleScopeTag'], + ], + 'assignmentFilter' => [ + ['id' => 'filter-1', 'displayName' => 'Filter 1', '@odata.type' => '#microsoft.graph.assignmentFilter'], + ], + 'notificationMessageTemplate' => [ + [ + 'id' => 'tmpl-1', + 'displayName' => 'Template 1', + '@odata.type' => '#microsoft.graph.notificationMessageTemplate', + ], + ], + ])); + + $service = app(InventorySyncService::class); + + $runA = $service->syncNow($tenantA, [ + 'policy_types' => ['deviceConfiguration'], + 'categories' => [], + 'include_foundations' => true, + 'include_dependencies' => false, + ]); + + expect($runA->status)->toBe('success'); + expect($runA->items_observed_count)->toBe(4); + expect($runA->items_upserted_count)->toBe(4); + + $runB = $service->syncNow($tenantB, [ + 'policy_types' => ['deviceConfiguration'], + 'categories' => [], + 'include_foundations' => false, + 'include_dependencies' => false, + ]); + + expect($runB->status)->toBe('success'); + expect($runB->items_observed_count)->toBe(1); + expect($runB->items_upserted_count)->toBe(1); +}); + test('configuration policy inventory filtering: settings catalog is not stored as security baseline', function () { $tenant = Tenant::factory()->create(); diff --git a/tests/Feature/InventoryItemDependenciesTest.php b/tests/Feature/InventoryItemDependenciesTest.php index bcf8f07..8ba5d22 100644 --- a/tests/Feature/InventoryItemDependenciesTest.php +++ b/tests/Feature/InventoryItemDependenciesTest.php @@ -4,6 +4,7 @@ use App\Models\InventoryItem; use App\Models\InventoryLink; use App\Models\Tenant; +use App\Services\Graph\GraphClientInterface; use Illuminate\Support\Str; it('shows zero-state when no dependencies and shows missing badge when applicable', function () { @@ -246,6 +247,51 @@ ->assertSee('Group (external): 428f24…'); }); +it('does not call Graph client while rendering inventory item dependencies view (FR-006 guard)', function () { + [$user, $tenant] = createUserWithTenant(); + $this->actingAs($user); + + $graph = \Mockery::mock(GraphClientInterface::class); + $graph->shouldNotReceive('listPolicies'); + $graph->shouldNotReceive('getPolicy'); + $graph->shouldNotReceive('getOrganization'); + $graph->shouldNotReceive('applyPolicy'); + $graph->shouldNotReceive('getServicePrincipalPermissions'); + $graph->shouldNotReceive('request'); + app()->instance(GraphClientInterface::class, $graph); + + /** @var InventoryItem $item */ + $item = InventoryItem::factory()->create([ + 'tenant_id' => $tenant->getKey(), + 'external_id' => (string) Str::uuid(), + ]); + + $scopeTag = InventoryItem::factory()->create([ + 'tenant_id' => $tenant->getKey(), + 'policy_type' => 'roleScopeTag', + 'external_id' => '6', + 'display_name' => 'Finance', + ]); + + InventoryLink::factory()->create([ + 'tenant_id' => $tenant->getKey(), + 'source_type' => 'inventory_item', + 'source_id' => $item->external_id, + 'target_type' => 'foundation_object', + 'target_id' => $scopeTag->external_id, + 'relationship_type' => 'scoped_by', + 'metadata' => [ + 'last_known_name' => null, + 'foundation_type' => 'scope_tag', + ], + ]); + + $url = InventoryItemResource::getUrl('view', ['record' => $item], tenant: $tenant); + $this->get($url) + ->assertOk() + ->assertSee('Scope Tag: Finance'); +}); + it('blocks guest access to inventory item dependencies view', function () { $tenant = Tenant::factory()->create(); diff --git a/tests/Unit/CoverageCapabilitiesResolverTest.php b/tests/Unit/CoverageCapabilitiesResolverTest.php new file mode 100644 index 0000000..de5e818 --- /dev/null +++ b/tests/Unit/CoverageCapabilitiesResolverTest.php @@ -0,0 +1,14 @@ +supportsDependencies($type))->toBe($expected); +})->with([ + 'settingsCatalogPolicy' => ['settingsCatalogPolicy', true], + 'deviceConfiguration' => ['deviceConfiguration', true], + 'conditionalAccessPolicy' => ['conditionalAccessPolicy', false], + 'roleScopeTag (foundation, MVP)' => ['roleScopeTag', false], +]);
TypeLabelCategoryDependenciesRestoreRisk
{{ $row['type'] ?? '' }}{{ $row['label'] ?? '' }}{{ $row['category'] ?? '' }}{{ ($row['dependencies'] ?? false) ? '✅' : '—' }} {{ $row['restore'] ?? 'enabled' }} {{ $row['risk'] ?? 'normal' }}