feat/047-inventory-foundations-nodes (#51)

Adds Inventory Sync toggle include_foundations (default true) + persistence tests
Adds Coverage “Dependencies” column (/—) derived deterministically from graph_contracts (no Graph calls)
Spec/tasks/checklists updated + tasks ticked off

Co-authored-by: Ahmed Darrazi <ahmeddarrazi@adsmac.local>
Reviewed-on: #51
This commit is contained in:
ahmido 2026-01-10 20:47:29 +00:00
parent da18d3cb14
commit 9c56a2349a
21 changed files with 1069 additions and 16 deletions

View File

@ -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
<!-- MANUAL ADDITIONS START -->

View File

@ -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<int, array<string, mixed>>
*/
public array $supportedTypes = [];
public array $supportedPolicyTypes = [];
/**
* @var array<int, array<string, mixed>>
*/
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();
}
}

View File

@ -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'];
}

View File

@ -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<int, array<string, mixed>>
*/
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 : [],
);
}
}

View File

@ -0,0 +1,25 @@
<?php
namespace App\Services\Inventory;
class CoverageCapabilitiesResolver
{
public function supportsDependencies(string $type): bool
{
$contracts = config('graph_contracts.types', []);
if (! is_array($contracts)) {
return false;
}
$meta = $contracts[$type] ?? null;
if (! is_array($meta)) {
return false;
}
if (array_key_exists('assignments_list_path', $meta)) {
return true;
}
return ($meta['supports_scope_tags'] ?? false) === true;
}
}

View File

@ -245,7 +245,16 @@ private function executeRun(InventorySyncRun $run, Tenant $tenant, array $normal
try {
$typesConfig = $this->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<int, array<string, mixed>> $supported */
$supported = config('tenantpilot.supported_policy_types', []);
/** @var array<int, array<string, mixed>> $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<string>
*/
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);

View File

@ -1,5 +1,6 @@
<x-filament::page>
<x-filament::section>
<div class="text-base font-medium">Policies</div>
<div class="overflow-x-auto">
<table class="min-w-full text-sm">
<thead>
@ -7,16 +8,48 @@
<th class="py-2 pr-4 font-medium">Type</th>
<th class="py-2 pr-4 font-medium">Label</th>
<th class="py-2 pr-4 font-medium">Category</th>
<th class="py-2 pr-4 font-medium">Dependencies</th>
<th class="py-2 pr-4 font-medium">Restore</th>
<th class="py-2 pr-4 font-medium">Risk</th>
</tr>
</thead>
<tbody class="divide-y divide-gray-200 dark:divide-gray-800">
@foreach ($supportedTypes as $row)
@foreach ($supportedPolicyTypes as $row)
<tr>
<td class="py-2 pr-4">{{ $row['type'] ?? '' }}</td>
<td class="py-2 pr-4">{{ $row['label'] ?? '' }}</td>
<td class="py-2 pr-4">{{ $row['category'] ?? '' }}</td>
<td class="py-2 pr-4">{{ ($row['dependencies'] ?? false) ? '✅' : '—' }}</td>
<td class="py-2 pr-4">{{ $row['restore'] ?? 'enabled' }}</td>
<td class="py-2 pr-4">{{ $row['risk'] ?? 'normal' }}</td>
</tr>
@endforeach
</tbody>
</table>
</div>
</x-filament::section>
<x-filament::section>
<div class="text-base font-medium">Foundations</div>
<div class="overflow-x-auto">
<table class="min-w-full text-sm">
<thead>
<tr class="text-left border-b border-gray-200 dark:border-gray-800">
<th class="py-2 pr-4 font-medium">Type</th>
<th class="py-2 pr-4 font-medium">Label</th>
<th class="py-2 pr-4 font-medium">Category</th>
<th class="py-2 pr-4 font-medium">Dependencies</th>
<th class="py-2 pr-4 font-medium">Restore</th>
<th class="py-2 pr-4 font-medium">Risk</th>
</tr>
</thead>
<tbody class="divide-y divide-gray-200 dark:divide-gray-800">
@foreach ($foundationTypes as $row)
<tr>
<td class="py-2 pr-4">{{ $row['type'] ?? '' }}</td>
<td class="py-2 pr-4">{{ $row['label'] ?? '' }}</td>
<td class="py-2 pr-4">{{ $row['category'] ?? '' }}</td>
<td class="py-2 pr-4">{{ ($row['dependencies'] ?? false) ? '✅' : '—' }}</td>
<td class="py-2 pr-4">{{ $row['restore'] ?? 'enabled' }}</td>
<td class="py-2 pr-4">{{ $row['risk'] ?? 'normal' }}</td>
</tr>

View File

@ -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).

View File

@ -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"]
}
}
}

View File

@ -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"]
}

View File

@ -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`.

View File

@ -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
<!--
ACTION REQUIRED: Replace the content in this section with the technical details
for the project. The structure here is presented in advisory capacity to guide
the iteration process.
-->
**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)
<!--
ACTION REQUIRED: Replace the placeholder tree below with the concrete layout
for this feature. Delete unused options and expand the chosen structure with
real paths (e.g., apps/admin, packages/something). The delivered plan must
not include Option labels.
-->
```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.

View File

@ -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 doesnt reflect changes, rebuild assets if needed (`npm run dev` / `composer run dev`) and confirm cache is cleared.

View File

@ -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).

View File

@ -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 (<id>)` 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 items 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 (<id>)` (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`

View File

@ -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 (T020T021)**.
---
## Parallel Execution Examples
### Within US1
- Run in parallel:
- T008 (include_foundations=true test) + T009 (include_foundations=false test)
- Then implement T006T007 and validate against T010
### Across Stories (after US1 complete)
- US2 UI tasks (T011T013) can proceed in parallel with US3 coverage tasks (T014T016).
---
## 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)

View File

@ -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('✅');
});

View File

@ -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();

View File

@ -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();

View File

@ -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();

View File

@ -0,0 +1,14 @@
<?php
use App\Services\Inventory\CoverageCapabilitiesResolver;
it('derives dependency support deterministically from graph contracts', function (string $type, bool $expected) {
$resolver = app(CoverageCapabilitiesResolver::class);
expect($resolver->supportsDependencies($type))->toBe($expected);
})->with([
'settingsCatalogPolicy' => ['settingsCatalogPolicy', true],
'deviceConfiguration' => ['deviceConfiguration', true],
'conditionalAccessPolicy' => ['conditionalAccessPolicy', false],
'roleScopeTag (foundation, MVP)' => ['roleScopeTag', false],
]);